.
長話短說
今天我要建立一個聯系人管理系統:
- 您可以從任何類型/大小的檔案添加來自不同資源的所有聯系人
- 動态内聯編輯它們 - 就像 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,是以它現在應該是這樣的:
動圖
一切都在本地運作(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并注冊。
轉到項目并添加一個新項目。
現在轉到 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. 是以,如果該值存在,我們隻需更新它。
由于我們将從SELECT用戶端進行查詢,是以讓我們SELECT向每個人授予權限,然後啟用RLS。
現在讓我們檢查一下我們的設定并複制我們的anon公鑰和service role密鑰。
在項目中建立一個名為的新檔案.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
并将它們粘貼到我們的.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 檔案,看看會發生什麼。
我希望你喜歡這個!
關注并回複1,領取程式設計學習資料大禮包!