使用 NextJS、Supabase 和 Flatfile 构建联系人管理系统

奋斗吧
奋斗吧
擅长邻域:未填写

标签: 使用 NextJS、Supabase 和 Flatfile 构建联系人管理系统 Java博客 51CTO博客

2023-07-28 18:24:29 213浏览

使用 NextJS、Supabase 和 Flatfile 构建联系人管理系统,.长话短说今天我要建立一个联系人管理系统:您可以从任何类型/大小的文件添加来自不同资源的所有联系人?动态内联编辑它们-就像Excel工作表?当其他人更改工作表时获取实时更新⤴️让我们来做吧?实时管理您的联系人??我们将构建一个很酷的可以实时更新的Excel电子表格为此,我们必须使用Websockets或服务器发送事件(SSE)。为了简化流程,我们将使用Supabaserea

.

使用 NextJS、Supabase 和 Flatfile 构建联系人管理系统_电子表格

长话短说

今天我要建立一个联系人管理系统:

  • 您可以从任何类型/大小的文件添加来自不同资源的所有联系人?
  • 动态内联编辑它们 - 就像 Excel 工作表 ?
  • 当其他人更改工作表时获取实时更新⤴️

让我们来做吧?


实时管理您的联系人??

我们将构建一个很酷的可以实时更新的 Excel 电子表格

为此,我们必须使用 Websockets 或服务器发送事件 (SSE)。

为了简化流程,我们将使用 Supabase real-time。

什么是 Supabase 实时?

Supabase 实时非常简洁。

它基本上是一个位于云中的 Postgres 数据库,当其中发生变化时,它会通过 WebSockets 发送有关新更改的事件。

您可以在此处了解有关 WebSocket 的更多信息。


让我们来设置一下吧?

让我们首先启动一个新的 NextJS 项目。

npx create-next-app@latest contacts


我们不会在该项目中使用新的应用程序路由器,因此请选择您不需要它。

要使用电子表格,让我们安装react-spreadsheet. 这是一个年轻的图书馆,但我对它寄予厚望!

npm install react-spreadsheet --save


让我们打开index.tsx页面内部并添加数据状态和反应电子表格。

import Spreadsheet from "react-spreadsheet";

export default function Home() {
  const [data, setData] = useState<{ value: string }[][]>([]);

    return (
        <div className="flex justify-center items-stretch">
            <div className="flex flex-col">
                <Spreadsheet darkMode={true} data={data} />
            </div>
        </div>
    );
}


好吧,没什么可看的,我们会到达那里的。

开箱react-spreadsheet即用,可以选择修改其中的列。

但它缺少以下选项:

  • 添加新列
  • 添加新行
  • 删除列
  • 删除行

因此,让我们添加这些内容,但在此之前,我们必须注意一件小事。

我们不想对每个单词的更改向 Supabase 发送垃圾邮件。

最简单的方法是使用去抖器。


反弹者是谁?

防抖器是一种告诉我们的功能的方法 - 在我被触发后经过 X 时间后激活我。

因此,如果用户尝试更改文本,只会在完成后 1 秒触发该功能。

让我们安装去抖动器:

npm install use-debounce --save


并将其导入到我们的项目中:

import { useDebouncedCallback } from "use-debounce";


不是我们可以创建我们的更新函数

const debouncer = useDebouncedCallback((newData: any, diff) => {
  setData((oldData) => {
        // update the server with our new data
    updateServer(diff);
    return newData;
  });
}, 500);


正如你所看到的,去抖器从状态更新我们的数据,但该功能只会在用户触发该功能后 500 毫秒激活。

主要问题是去抖器不知道通过引用进行的数据突变 ( data)。
因为它不知道,所以最好先检查一下。

所以这里是从 获取新数据的函数<Spreadsheet />,如果确实发生变化,它将触发我们的去抖动器。

const setNewData = (newData: {value: string}[][], ignoreDiff?: boolean) => {
    // This function will tell us what actually changed in the data (the column / row)
  const diff = findDiff(data, newData);

  // Only if there was not real change, or we didn't ask to ignore changes, trigger the debouncer.
  if (diff || ignoreDiff) {
    return debouncer(newData, diff);
  }
};


现在,我们来编写该findDiff函数。

这是两个二维数组之间的简单比较。

const findDiff = useCallback(
    (oldData: { value: string }[][], newData: { value: string }[][]) => {
      for (let i = 0; i < oldData.length; i++) {
        for (let y = 0; y < oldData[i].length; y++) {
          if (oldData[i][y] !== newData[i][y]) {
            return {
              oldValue: oldData[i][y].value,
              value: newData[i][y].value,
              row: i,
              col: y,
            };
          }
        }
      }
    },
    []
  );


现在?,我们可以让我们的电子表格更新我们的数据!

<Spreadsheet
  darkMode={true}
  data={data}
  className="w-full"
  onChange={setNewData}
/>


正如我之前所说,react-spreadsheet还不够成熟,所以让我们构建我们缺失的功能。

// Add a new column
const addCol = useCallback(() => {
  setNewData(
    data.length === 0
      ? [[{ value: "" }]]
      : data.map((p: any) => [...p, { value: "" }]),
    true
  );
}, [data]);

// Add a new row
const addRow = useCallback(() => {
  setNewData(
    [...data, data?.[0]?.map(() => ({ value: "" })) || [{ value: "" }]],
    true
  );
}, [data]);

// Remove a column by index
const removeCol = useCallback(
  (index: number) => (event: any) => {
    setNewData(
      data.map((current) => {
        return [
          ...current.slice(0, index),
          ...current.slice((index || 0) + 1),
        ];
      }),
      true
    );
    event.stopPropagation();
  },
  [data]
);

// Remove a row by index
const removeRow = useCallback(
  (index: number) => (event: any) => {
    setNewData(
      [...data.slice(0, index), ...data.slice((index || 0) + 1)],
      true
    );
    event.stopPropagation();
  },
  [data]
);


现在让我们添加按钮来添加新行和列

<div className="flex justify-center items-stretch">
  <div className="flex flex-col">
    <Spreadsheet
      darkMode={true}
      data={data}
      className="w-full"
      onChange={setNewData}
    />
    <div
      onClick={addRow}
      className="bg-[#060606] border border-[#313131] border-t-0 mb-[6px] flex justify-center py-1 cursor-pointer"
    >
      +
    </div>
  </div>
  <div
    onClick={addCol}
    className="bg-[#060606] border border-[#313131] border-l-0 mb-[6px] flex items-center px-3 cursor-pointer"
  >
    +
  </div>
</div>


接下来的部分很棘手?

正如我之前所说,这个库有点不成熟:

<div className="flex justify-center items-stretch">
  <div className="flex flex-col">
    <Spreadsheet
      columnLabels={data?.[0]?.map((d, index) => (
        <div
          key={index}
          className="flex justify-center items-center space-x-2"
        >
          <div>{String.fromCharCode(64 + index + 1)}</div>
          <div
            className="text-xs text-red-500"
            onClick={removeCol(index)}
          >
            X
          </div>
        </div>
      ))}
      rowLabels={data?.map((d, index) => (
        <div
          key={index}
          className="flex justify-center items-center space-x-2"
        >
          <div>{index + 1}</div>
          <div
            className="text-xs text-red-500"
            onClick={removeRow(index)}
          >
            X
          </div>
        </div>
      ))}
      darkMode={true}
      data={data}
      className="w-full"
      onChange={setNewData}
    />
    <div
      onClick={addRow}
      className="bg-[#060606] border border-[#313131] border-t-0 mb-[6px] flex justify-center py-1 cursor-pointer"
    >
      +
    </div>
  </div>
  <div
    onClick={addCol}
    className="bg-[#060606] border border-[#313131] border-l-0 mb-[6px] flex items-center px-3 cursor-pointer"
  >
    +
  </div>
</div>


columnLabels期望rowLabels返回一个字符串数组,但我们给它一个组件数组?

您可能需要将它与 一起使用@ts-ignore,所以它现在应该是这样的:

动图

使用 NextJS、Supabase 和 Flatfile 构建联系人管理系统_数据库_02

一切都在本地运行(ATM),让我们向服务器发送更新请求?

首先,我们来安装axios

npm install axios --save


导入它:

import axios from "axios";


并编写我们的updateServer函数!

const updateServer = useCallback(
  (serverData?: { value: string; col: number; row: number }) => {
    if (!serverData) {
      return;
    }
    console.log(serverData);
    return axios.post("/api/update-record", serverData);
  },
  []
);



苏帕巴斯时间!⏰

前往Supabase并注册。

转到项目并添加一个新项目。

使用 NextJS、Supabase 和 Flatfile 构建联系人管理系统_电子表格_03

现在转到 SQL 编辑器并运行以下查询。

CREATE TABLE public."values" (
    "id" INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    "row" smallint DEFAULT '0'::smallint,
    "column" smallint DEFAULT '0'::smallint,
    "value" text,
    UNIQUE ("row", "column")
);


values此查询创建包含电子表格中的row和数字的表。column我们还在UNIQUE(一起)行和列上添加了一个键,因为我们的数据库中只能有一个匹配项。我们可以将它们更新到表中,因为我们将它们都标记为UNIQUE. 因此,如果该值存在,我们只需更新它。

使用 NextJS、Supabase 和 Flatfile 构建联系人管理系统_电子表格_04

由于我们将从SELECT客户端进行查询,因此让我们SELECT向每个人授予权限,然后启用RLS

使用 NextJS、Supabase 和 Flatfile 构建联系人管理系统_数据库_05

现在让我们检查一下我们的设置并复制我们的anon公钥和service role密钥。

使用 NextJS、Supabase 和 Flatfile 构建联系人管理系统_电子表格_06

在项目中创建一个名为的新文件.env

touch .env


并在里面添加键

SECRET_KEY=service_role_key
NEXT_PUBLIC_ANON_KEY=anon_key


现在让我们安装supabase-js

npm install @supabase/supabase-js


创建一个名为 的新文件夹helpers,添加一个名为 的新文件supabase.ts,并添加以下代码:

import {createClient} from "@supabase/supabase-js";

// You can take the URL from the project settings
export const createSupabase = (key: string) => createClient('https://IDENTIFIER.supabase.co', key);


pages现在在名为的内部创建一个新文件夹api(很可能该文件夹已经存在)。

创建一个名为的新文件update-record.ts并添加以下代码:

import type { NextApiRequest, NextApiResponse } from "next";
import { createSupabase } from "@contacts/helpers/supabase";
const supabase = createSupabase(process.env.SECRET_KEY!);

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  if (
    req.method !== "POST" ||
    typeof req.body.col === "undefined" ||
    typeof req.body.row === "undefined" ||
    typeof req.body.value === "undefined"
  ) {
    res.status(400).json({ valid: false });
    return;
  }
  const { data, error } = await supabase
    .from("values")
    .upsert(
      {
        column: req.body.col,
        row: req.body.row,
        value: req.body.value,
      },
      {
        onConflict: "row,column",
      }
    )
    .select();

  res.status(200).json({ valid: true });
}


让我们看看这里发生了什么。

我们导入之前创建的supabase.ts文件并使用 our 启动一个新实例SECRET_KEY- 这很重要,因为只有使用 ourSECRET_KEY才能改变数据库。

在路由中,我们检查方法是否为POST,以及 col、row 和 value 中是否有值。

检查很重要,undefined因为我们可能会获取0值或为空值。

然后,我们执行一个upsert查询,基本上添加行、列和值,但如果存在,它只会更新它。

现在让我们监听客户端的变化并更新我们的电子表格。

再次导入 superbase,但这次我们将使用ANON密钥

import { createSupabase } from "@contacts/helpers/supabase";
const supabase = createSupabase(process.env.NEXT_PUBLIC_ANON_KEY!);


现在,让我们向组件添加 useEffect:

useEffect(() => {
    supabase
      .channel("any")
      .on<any>(
        "postgres_changes",
        { event: "*", schema: "public", table: "values" },
        (payload) => {
          console.log(payload.new);
          setData((odata) => {
            const totalRows =
              payload?.new?.row + 1 > odata.length
                ? payload.new.row + 1
                : odata.length;

            const totalCols =
              payload.new?.column + 1 > odata[0].length
                ? payload.new?.column + 1
                : odata[0].length;

            return [...new Array(totalRows)].map((_, row) => {
              return [...new Array(totalCols)].map((_, col) => {
                if (payload.new.row === row && payload.new?.column === col) {
                  return { value: payload?.new?.value || "" };
                }

                return { value: odata?.[row]?.[col]?.value || "" };
              });
            });
          });
        }
      )
      .subscribe();
  }, []);


我们订阅该values表并在收到更改时更新我们的数据。

让我们看一下这里的一些亮点。

data格式通常看起来像这样:

[
    [row1_col1, row1_col2, row1_col3, row1_col4],
    [row2_col1, row2_col2, row2_col3, row2_col4]
]


row2_col4但是如果我们得到了, , 但没有row2_col1row2_col2,会发生什么row2_col3
因此,为了解决这个问题,我们只需要检查最高的行和最高的列,并使用这些值创建一个二维数组。

[…new Array(value)]是一个很酷的技巧,可以创建一个具有所需大小的空值的数组。

太棒了??我们已经构建了整个联系人系统,但这还没有结束!


让我们从其他资源导入您的所有联系人?

即使您有数千个联系人,我们也可以使用 FlatFile 轻松添加它们!

FlatFile是开发人员构建理想数据文件导入体验的最简单、最快、最安全的方法。这些是我们将要采取的步骤:

  • 我们添加 FlatFile React 组件来加载任何文件类型(CSV / XSLX / XML 等)
  • 我们创建一个函数来处理该文件并将联系人插入到我们的数据库中。
  • 我们将函数部署到云端,FlatFile 会处理一切,无需我们再维护 ?

因此,继续注册到 Flatfile,前往设置并复制Environment IDPublishable KeySecret Key

使用 NextJS、Supabase 和 Flatfile 构建联系人管理系统_电子表格_07

并将它们粘贴到我们的.env文件中。

NEXT_PUBLIC_FLAT_ENVIRONMENT_ID=us_env_
NEXT_PUBLIC_FLAT_PUBLISHABLE_KEY=pk_
FLATFILE_API_KEY=sk_


空间?

FlatFile 有一个概念叫做Spaces,它是微应用程序,每个应用程序都有自己的数据库、文件存储和身份验证。

每个空间内部都是不同的WorkBooks,基本上是不同电子表格的一组。

每次我们想要加载联系人时,我们都会创建一个包含一个工作簿和一张工作表的新空间。

现在让我们安装 FlatFile React 组件!

npm install @flatfile/react --save


让我们创建一个名为 的新文件夹components,并创建我们的文件导入器。

mkdir components
cd components
touch file.importer.tsx


然后创建一个按钮来导入我们的联系人

const FileImporterComponent: FC<{ data: string[] }> = (props) => {
  const { data } = props;
  const [showSpace, setShowSpace] = useState(false);

  return (
    <div className="flex justify-center py-5">
      <button
        className="bg-violet-900 p-3 rounded-3xl"
        onClick={() => {
          setShowSpace(!showSpace);
        }}
      >
        Import Contacts
      </button>
      {showSpace && (
        <div className="fixed w-full h-full left-0 top-0 z-50 text-black">
          <div className="w-[80%] m-auto top-[50%] absolute left-[50%] -translate-x-[50%] -translate-y-[50%] text-black space-modal">
            <FlatFileComponent
              data={data}
              closeSpace={() => setShowSpace(false)}
            />
          </div>
        </div>
      )}
    </div>
  );
};

export default FileImporterComponent;


正如您所看到的,我们传递了一个名为 的参数data,它基本上是上一步中所有标题(电子表格中的第一行)的名称。

我们将它们发送到 FlatFile,FlatFile 会尝试猜测哪个字段属于哪个字段 ?

单击“导入联系人”按钮后,它将打开 FlatFile 组件。

现在让我们创建 FlatFile 组件:

const FlatFileComponent: FC<{ data: string[]; closeSpace: () => void }> = (
  props
) => {
    const { data, closeSpace } = props;
  const theme = useMemo(() => ({
      name: "Dynamic Space",
      environmentId: "us_env_nSuIcnJx",
      publishableKey: process.env.NEXT_PUBLIC_FLAT_PUBLISHABLE_KEY!,
      themeConfig: makeTheme({ primaryColor: "#546a76", textColor: "#fff" }),
      workbook: {
        name: "Contacts Workbook",
        sheets: [
          {
            name: "ContactSheet",
            slug: "ContactSheet",
            fields: data.map((p, index) => ({
              key: String(index),
              type: "string",
              label: p,
            })),
          },
        ],
        actions: [
          {
            label: "Submit",
            operation: "contacts:submit",
            description: "Would you like to submit your workbook?",
            mode: "background",
            primary: true,
            confirm: true,
          },
        ],
      },
    } as ISpace), [data]);

  const space = useSpace({
    ...theme,
    closeSpace: {
      operation: "contacts:close",
      onClose: () => closeSpace(),
    },
  });

  return <>{space}</>;
};


让我们看看这里发生了什么:

  • 我们使用 React hookuseSpace来启动一个新的 FlatFile 向导。
  • 我们传递从设置中获得的environmentId和。publishableKey
  • fields我们从标头的名称映射。在中,key我传递了标题索引,因此当我稍后将其插入时,Supabase我知道列号。
  • 我们设置 的操作submit,并将 设为mode后台,因为我们不想在前面处理数据(我们基本上不能,因为我们的Anon用户无权访问INSERT我们的数据库)。

让我们将组件添加到主页中。

FlatFile 使用该window对象。由于我们使用的是 NextJS,因此我们无法在服务器渲染期间访问窗口对象。我们必须使用动态导入来添加它:

import dynamic from "next/dynamic";

const FileImporterComponent = dynamic(() => import("../components/file.importer"), {
  ssr: false,
});

return (
    <>
      {!!data.length && <SpaceComponent data={data[0].map((p) => p.value)} />}
      <div className="flex justify-center items-stretch">
        <div className="flex flex-col">
          <Spreadsheet
            columnLabels={data?.[0]?.map((d, index) => (
              <div
                key={index}
                className="flex justify-center items-center space-x-2"
              >
                <div>{String.fromCharCode(64 + index + 1)}</div>
                <div
                  className="text-xs text-red-500"
                  onClick={removeCol(index)}
                >
                  X
                </div>
              </div>
            ))}
            rowLabels={data?.map((d, index) => (
              <div
                key={index}
                className="flex justify-center items-center space-x-2"
              >
                <div>{index + 1}</div>
                <div
                  className="text-xs text-red-500"
                  onClick={removeRow(index)}
                >
                  X
                </div>
              </div>
            ))}
            darkMode={true}
            data={data}
            className="w-full"
            // @ts-ignore
            onChange={setNewData}
          />
          <div
            onClick={addRow}
            className="bg-[#060606] border border-[#313131] border-t-0 mb-[6px] flex justify-center py-1 cursor-pointer"
          >
            +
          </div>
        </div>
        <div
          onClick={addCol}
          className="bg-[#060606] border border-[#313131] border-l-0 mb-[6px] flex items-center px-3 cursor-pointer"
        >
          +
        </div>
      </div>
    </>
  );


保存所有内容后,您应该看到如下内容:

惊人的!

剩下的唯一一件事就是将所有内容加载到我们的数据库中。

让我们安装一些 FlatFile 依赖项

npm install @flatfile/listener @flatfile/api --save


创建一个名为的新文件listener.ts

这是一个监听文件导入的特殊文件。

让我们导入 FlatFile 和 Supabase。

import { FlatfileEvent, Client } from "@flatfile/listener";
import api from "@flatfile/api";
import { createSupabase } from "./src/database/supabase";
const supabase = createSupabase(process.env.SECRET_KEY!);


我们可以添加我们contacts:submit在前面的步骤中编码的侦听器:

export default function flatfileEventListener(listener: Client) {
  listener.filter({ job: "workbook:contacts:submit" }, (configure) => {
    configure.on(
      "job:ready",
      async ({ context: { jobId, workbookId }, payload }: FlatfileEvent) => {
                // add to supabase
            }
    );
  });
}


要插入新值,我们需要获取数据库中当前最高的行并递增它。

const row = await supabase
          .from("values")
          .select("row")
          .order("row", { ascending: false })
          .limit(1);

        let startRow = row.data?.length ? row.data[0].row + 1 : 0;


然后我们取出导入文件中的所有记录

const { data: sheets } = await api.sheets.list({ workbookId });
const records = (await api.records.get(sheets[0].id))?.data?.records || [];


我们获取它们并将其添加到我们的数据库中。

我们还使用该api.jobs.ack调用来通知前端用户导入的进度。

for (const record of records) {
    await api.jobs.ack(jobId, {
      info: "Loading contacts",
      progress: Math.ceil((index / records.length) * 100),
    });
    await Promise.all(
      Object.keys(record.values).map((key) => {
        console.log({
          row: startRow,
          column: +key,
          value: record.values[key].value,
        });
        return supabase
          .from("values")
          .upsert(
            {
              row: startRow,
              column: +key,
              value: record?.values?.[key]?.value || '',
            },
            {
              onConflict: "row,column",
            }
          )
          .select();
      })
    );
    startRow++;
    index++;
}


导入完成后,我们就可以在客户端完成工作了。

await api.jobs.complete(jobId, {
  outcome: {
    message: "Loaded all contacts!",
  },
});


完整listener.ts文件应如下所示:

import { FlatfileEvent, Client } from "@flatfile/listener";
import api from "@flatfile/api";
import { createSupabase } from "./src/database/supabase";
const supabase = createSupabase(process.env.SECRET_KEY!);

export default function flatfileEventListener(listener: Client) {
  listener.filter({ job: "workbook:contacts:submit" }, (configure) => {
    configure.on(
      "job:ready",
      async ({ context: { jobId, workbookId }, payload }: FlatfileEvent) => {
        const row = await supabase
          .from("values")
          .select("row")
          .order("row", { ascending: false })
          .limit(1);

        let startRow = row.data?.length ? row.data[0].row + 1 : 0;

        const { data: sheets } = await api.sheets.list({ workbookId });

        // loading all the records from the client
        const records =
          (await api.records.get(sheets[0].id))?.data?.records || [];
        let index = 1;
        try {
          for (const record of records) {

            // information the client about the amount of contacts loaded
            await api.jobs.ack(jobId, {
              info: "Loading contacts",
              progress: Math.ceil((index / records.length) * 100),
            });

            // inserting the row to the table (each cell has a separate insert)
            await Promise.all(
              Object.keys(record.values).map((key) => {
                console.log({
                  row: startRow,
                  column: +key,
                  value: record.values[key].value,
                });
                return supabase
                  .from("values")
                  .upsert(
                    {
                      row: startRow,
                      column: +key,
                      value: record?.values?.[key]?.value || "",
                    },
                    {
                      onConflict: "row,column",
                    }
                  )
                  .select();
              })
            );
            startRow++;
            index++;
          }
        } catch (err) {
            // failing the job in case we get an error
            await api.jobs.fail(jobId, {
                info: 'Could not load contacts'
            });

            return ;
        }

        // Finishing the job
        await api.jobs.complete(jobId, {
          outcome: {
            message: "Loaded all contacts!",
          },
        });
      }
    );
  });
}


回顾一下一切:

  • 我们创建了一个名为的新文件listener.ts,用于监听新的导入。
  • 我们添加了一个名为的过滤器workbook:contacts:submit来捕获所有联系人导入(如果您在不同的位置导入文件,您可以有多个过滤器)。
  • 我们迭代联系人并将它们添加到我们的数据库中。
  • 我们告知客户我们的进度百分比api.jobs.ack
  • 如果出现故障,我们将通知客户[api.jobs.fail](http://api.jobs.fail)
  • 如果一切正常,我们将通知客户api.jobs.complete

您可以在此处了解有关如何使用事件的更多信息。

保存文件并运行它

npx flatfile develop listener.ts


当您准备好部署它时,只需使用

npx flatfile deploy listener.ts


这非常令人惊奇,因为如果您部署它,则不需要再次运行此命令。

您还将在 Flatflie 仪表板内看到日志。

让我们运行开发命令,导入 CSV 文件,看看会发生什么。

我希望你喜欢这个!

我当然做到了?

好博客就要一起分享哦!分享海报

此处可发布评论

评论(0展开评论

暂无评论,快来写一下吧

展开评论

您可能感兴趣的博客

客服QQ 1913284695