天天看點

使用 NextJS、Supabase 和 Flatfile 建構聯系人管理系統

作者:千鋒IT教育

.

使用 NextJS、Supabase 和 Flatfile 建構聯系人管理系統

長話短說

今天我要建立一個聯系人管理系統:

  • 您可以從任何類型/大小的檔案添加來自不同資源的所有聯系人
  • 動态内聯編輯它們 - 就像 Excel 工作表
  • 當其他人更改工作表時擷取實時更新⤴️

讓我們來做吧

使用 NextJS、Supabase 和 Flatfile 建構聯系人管理系統

實時管理您的聯系人

我們将建構一個很酷的可以實時更新的 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 建構聯系人管理系統

一切都在本地運作(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 建構聯系人管理系統

現在轉到 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 建構聯系人管理系統

由于我們将從SELECT用戶端進行查詢,是以讓我們SELECT向每個人授予權限,然後啟用RLS。

使用 NextJS、Supabase 和 Flatfile 建構聯系人管理系統
使用 NextJS、Supabase 和 Flatfile 建構聯系人管理系統

現在讓我們檢查一下我們的設定并複制我們的anon公鑰和service role密鑰。

使用 NextJS、Supabase 和 Flatfile 建構聯系人管理系統

在項目中建立一個名為的新檔案.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_col1, row2_col2,會發生什麼row2_col3?

是以,為了解決這個問題,我們隻需要檢查最高的行和最高的列,并使用這些值建立一個二維數組。

這[…new Array(value)]是一個很酷的技巧,可以建立一個具有所需大小的空值的數組。

太棒了我們已經建構了整個聯系人系統,但這還沒有結束!

讓我們從其他資源導入您的所有聯系人

即使您有數千個聯系人,我們也可以使用 FlatFile 輕松添加它們!

FlatFile是開發人員建構理想資料檔案導入體驗的最簡單、最快、最安全的方法。這些是我們将要采取的步驟:

  • 我們添加 FlatFile React 元件來加載任何檔案類型(CSV / XSLX / XML 等)
  • 我們建立一個函數來處理該檔案并将聯系人插入到我們的資料庫中。
  • 我們将函數部署到雲端,FlatFile 會處理一切,無需我們再維護

是以,繼續注冊到 Flatfile,前往設定并複制Environment ID、Publishable Key和Secret Key

使用 NextJS、Supabase 和 Flatfile 建構聯系人管理系統

并将它們粘貼到我們的.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>
    </>
  );
           

儲存所有内容後,您應該看到如下内容:

使用 NextJS、Supabase 和 Flatfile 建構聯系人管理系統

驚人的!

剩下的唯一一件事就是将所有内容加載到我們的資料庫中。

讓我們安裝一些 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
           
使用 NextJS、Supabase 和 Flatfile 建構聯系人管理系統

當您準備好部署它時,隻需使用

npx flatfile deploy listener.ts
           

這非常令人驚奇,因為如果您部署它,則不需要再次運作此指令。

您還将在 Flatflie 儀表闆内看到日志。

讓我們運作開發指令,導入 CSV 檔案,看看會發生什麼。

使用 NextJS、Supabase 和 Flatfile 建構聯系人管理系統

我希望你喜歡這個!

使用 NextJS、Supabase 和 Flatfile 建構聯系人管理系統

關注并回複1,領取程式設計學習資料大禮包!