使用 NextJS、Supabase 和 Flatfile 构建联系人管理系统
标签: 使用 NextJS、Supabase 和 Flatfile 构建联系人管理系统 Java博客 51CTO博客
2023-07-28 18:24:29 213浏览
.
长话短说
今天我要建立一个联系人管理系统:
- 您可以从任何类型/大小的文件添加来自不同资源的所有联系人?
- 动态内联编辑它们 - 就像 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 hook
useSpace
来启动一个新的 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)展开评论
展开评论
您可能感兴趣的博客