427 lines
17 KiB
TypeScript
427 lines
17 KiB
TypeScript
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { Check, Copy, LoaderCircle } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import type { TableField, TableFormFieldSetting, TableFormSettings, TableFormUpdateInput } from "@edgeever/shared";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { api, getConfiguredDesktopApiBaseUrl } from "@/lib/api";
|
|
import { copyTextToClipboard } from "@/lib/clipboard";
|
|
|
|
const TEXT_SAVE_MS = 400;
|
|
|
|
const formUrl = (token: string) => {
|
|
const baseUrl = getConfiguredDesktopApiBaseUrl() || window.location.origin;
|
|
return `${baseUrl.replace(/\/$/, "")}/form/${encodeURIComponent(token)}`;
|
|
};
|
|
|
|
const passwordKey = (memoId: string) => `edgeever.tableFormPassword.${memoId}`;
|
|
|
|
const readPassword = (memoId: string, token: string) => {
|
|
try {
|
|
const parsed = JSON.parse(window.sessionStorage.getItem(passwordKey(memoId)) ?? "") as { token?: string; password?: string };
|
|
return parsed.token === token && parsed.password ? parsed.password : "";
|
|
} catch {
|
|
return "";
|
|
}
|
|
};
|
|
|
|
const writePassword = (memoId: string, token: string, password: string) => {
|
|
try {
|
|
window.sessionStorage.setItem(passwordKey(memoId), JSON.stringify({ token, password }));
|
|
} catch {
|
|
// The generated password stays visible for this dialog session.
|
|
}
|
|
};
|
|
|
|
type FormDraft = {
|
|
enabled: boolean;
|
|
passwordProtected: boolean;
|
|
title: string;
|
|
description: string;
|
|
submitLabel: string;
|
|
selected: TableFormFieldSetting[];
|
|
};
|
|
|
|
const draftFromSaved = (saved: TableFormSettings | null, fields: TableField[], memoTitle: string): FormDraft => ({
|
|
enabled: saved?.enabled ?? false,
|
|
passwordProtected: saved?.passwordProtected ?? false,
|
|
title: saved?.title || memoTitle,
|
|
description: saved?.description ?? "",
|
|
submitLabel: saved?.submitLabel ?? "",
|
|
selected: saved?.fields.length
|
|
? saved.fields.filter((field) => fields.some((item) => item.id === field.fieldId))
|
|
: fields.map((field) => ({ fieldId: field.id, required: false })),
|
|
});
|
|
|
|
const toPayload = (draft: FormDraft, rotatePassword = false): TableFormUpdateInput => ({
|
|
enabled: draft.enabled,
|
|
passwordProtected: draft.passwordProtected,
|
|
rotatePassword,
|
|
title: draft.title.trim(),
|
|
description: draft.description.trim(),
|
|
submitLabel: draft.submitLabel.trim(),
|
|
fields: draft.selected,
|
|
});
|
|
|
|
export const TableFormDialog = ({
|
|
memoId,
|
|
memoTitle,
|
|
fields,
|
|
open,
|
|
onOpenChange,
|
|
}: {
|
|
memoId: string;
|
|
memoTitle: string;
|
|
fields: TableField[];
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}) => {
|
|
const { t } = useTranslation();
|
|
const queryClient = useQueryClient();
|
|
const queryKey = ["table-form", memoId] as const;
|
|
const formQuery = useQuery({
|
|
queryKey,
|
|
queryFn: () => api.getTableForm(memoId),
|
|
enabled: open,
|
|
retry: false,
|
|
refetchOnWindowFocus: false,
|
|
});
|
|
const saved = formQuery.data?.form ?? null;
|
|
const [draft, setDraft] = useState<FormDraft | null>(null);
|
|
const [password, setPassword] = useState("");
|
|
const [copied, setCopied] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [confirmed, setConfirmed] = useState(false);
|
|
const draftRef = useRef<FormDraft | null>(null);
|
|
const publishedRef = useRef(false);
|
|
const serverEnabledRef = useRef(false);
|
|
const serverPasswordRef = useRef(false);
|
|
const hydratedFor = useRef("");
|
|
const draftMemoIdRef = useRef(memoId);
|
|
const fieldsRef = useRef(fields);
|
|
const memoTitleRef = useRef(memoTitle);
|
|
const textTimer = useRef<number | null>(null);
|
|
const saveQueue = useRef(Promise.resolve());
|
|
const pendingSaves = useRef(0);
|
|
const saveGeneration = useRef(0);
|
|
const persistRef = useRef<(next: FormDraft, rotatePassword?: boolean) => void>(() => {});
|
|
fieldsRef.current = fields;
|
|
memoTitleRef.current = memoTitle;
|
|
|
|
const applyDraft = (next: FormDraft) => {
|
|
draftRef.current = next;
|
|
setDraft(next);
|
|
};
|
|
|
|
const persistNow = (next: FormDraft, rotatePassword = false) => {
|
|
if (next.enabled && next.selected.length === 0) {
|
|
setError(t("structuredTable.form.needField"));
|
|
const reverted = { ...next, enabled: false };
|
|
applyDraft(reverted);
|
|
return;
|
|
}
|
|
const targetMemoId = draftMemoIdRef.current;
|
|
const generation = ++saveGeneration.current;
|
|
const key = ["table-form", targetMemoId] as const;
|
|
pendingSaves.current += 1;
|
|
setSaving(true);
|
|
setError(null);
|
|
setConfirmed(false);
|
|
const job = saveQueue.current.then(async () => {
|
|
const data = await api.updateTableForm(targetMemoId, toPayload(next, rotatePassword));
|
|
queryClient.setQueryData(key, { form: { ...data.form, password: undefined } });
|
|
if (targetMemoId !== draftMemoIdRef.current) return;
|
|
publishedRef.current = true;
|
|
serverEnabledRef.current = data.form.enabled;
|
|
serverPasswordRef.current = data.form.passwordProtected;
|
|
if (data.form.password) {
|
|
writePassword(targetMemoId, data.form.token, data.form.password);
|
|
setPassword(data.form.password);
|
|
} else if (!data.form.passwordProtected && generation === saveGeneration.current) {
|
|
setPassword("");
|
|
}
|
|
if (generation === saveGeneration.current && textTimer.current === null) setConfirmed(true);
|
|
});
|
|
saveQueue.current = job.then(() => undefined, () => undefined);
|
|
void job.catch((reason: unknown) => {
|
|
if (generation !== saveGeneration.current || targetMemoId !== draftMemoIdRef.current) return;
|
|
const current = draftRef.current;
|
|
if (!current) return;
|
|
applyDraft({
|
|
...current,
|
|
enabled: serverEnabledRef.current,
|
|
passwordProtected: serverPasswordRef.current,
|
|
});
|
|
setConfirmed(false);
|
|
setError(reason instanceof Error ? reason.message : t("structuredTable.form.needField"));
|
|
}).finally(() => {
|
|
pendingSaves.current = Math.max(0, pendingSaves.current - 1);
|
|
if (pendingSaves.current === 0) setSaving(false);
|
|
});
|
|
};
|
|
persistRef.current = persistNow;
|
|
|
|
const cancelTextTimer = () => {
|
|
if (textTimer.current === null) return;
|
|
window.clearTimeout(textTimer.current);
|
|
textTimer.current = null;
|
|
};
|
|
|
|
const flushTextSave = () => {
|
|
if (textTimer.current === null) return;
|
|
cancelTextTimer();
|
|
const current = draftRef.current;
|
|
if (current && (publishedRef.current || current.enabled)) persistRef.current(current);
|
|
};
|
|
|
|
const scheduleTextSave = () => {
|
|
cancelTextTimer();
|
|
textTimer.current = window.setTimeout(() => {
|
|
textTimer.current = null;
|
|
const current = draftRef.current;
|
|
if (current && (publishedRef.current || current.enabled)) persistRef.current(current);
|
|
}, TEXT_SAVE_MS);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
flushTextSave();
|
|
return;
|
|
}
|
|
if (hydratedFor.current && hydratedFor.current !== memoId) {
|
|
flushTextSave();
|
|
hydratedFor.current = "";
|
|
draftRef.current = null;
|
|
setDraft(null);
|
|
publishedRef.current = false;
|
|
}
|
|
if (!formQuery.isFetched || formQuery.isFetching || hydratedFor.current === memoId) return;
|
|
hydratedFor.current = memoId;
|
|
draftMemoIdRef.current = memoId;
|
|
const nextSaved = formQuery.data?.form ?? null;
|
|
const next = draftFromSaved(nextSaved, fieldsRef.current, memoTitleRef.current);
|
|
// Opening collection is the publish action for a new form. Keep an existing
|
|
// form's state, and never publish when reading its settings failed.
|
|
if (!nextSaved && !formQuery.isError) next.enabled = true;
|
|
publishedRef.current = Boolean(nextSaved);
|
|
serverEnabledRef.current = nextSaved?.enabled ?? false;
|
|
serverPasswordRef.current = nextSaved?.passwordProtected ?? false;
|
|
applyDraft(next);
|
|
setPassword(nextSaved?.token ? readPassword(memoId, nextSaved.token) : "");
|
|
setError(formQuery.error instanceof Error ? formQuery.error.message : null);
|
|
setSaving(pendingSaves.current > 0);
|
|
setConfirmed(false);
|
|
setCopied(false);
|
|
if (!nextSaved && !formQuery.isError) persistRef.current(next);
|
|
}, [open, formQuery.isFetched, formQuery.isFetching, formQuery.isError, formQuery.data, formQuery.error, memoId]);
|
|
|
|
useEffect(() => () => {
|
|
if (textTimer.current === null) return;
|
|
window.clearTimeout(textTimer.current);
|
|
textTimer.current = null;
|
|
const current = draftRef.current;
|
|
if (current && (publishedRef.current || current.enabled)) persistRef.current(current);
|
|
}, []);
|
|
|
|
const changeEnabled = (checked: boolean) => {
|
|
const current = draftRef.current;
|
|
if (!current) return;
|
|
if (checked && current.selected.length === 0) {
|
|
setError(t("structuredTable.form.needField"));
|
|
return;
|
|
}
|
|
const next = { ...current, enabled: checked };
|
|
applyDraft(next);
|
|
setError(null);
|
|
if (!checked && !publishedRef.current) return;
|
|
cancelTextTimer();
|
|
persistNow(next);
|
|
};
|
|
|
|
const changePassword = (checked: boolean) => {
|
|
const current = draftRef.current;
|
|
if (!current) return;
|
|
const next = { ...current, passwordProtected: checked };
|
|
applyDraft(next);
|
|
if (!publishedRef.current && !next.enabled) return;
|
|
cancelTextTimer();
|
|
persistNow(next);
|
|
};
|
|
|
|
const changeText = (patch: Partial<Pick<FormDraft, "title" | "description" | "submitLabel">>) => {
|
|
const current = draftRef.current;
|
|
if (!current) return;
|
|
const next = { ...current, ...patch };
|
|
applyDraft(next);
|
|
setConfirmed(false);
|
|
if (publishedRef.current || next.enabled) scheduleTextSave();
|
|
};
|
|
|
|
const toggleField = (fieldId: string, included: boolean) => {
|
|
const current = draftRef.current;
|
|
if (!current) return;
|
|
const selected = included
|
|
? [...current.selected.filter((field) => field.fieldId !== fieldId), { fieldId, required: false }]
|
|
: current.selected.filter((field) => field.fieldId !== fieldId);
|
|
if (!included && selected.length === 0 && current.enabled) {
|
|
setError(t("structuredTable.form.needField"));
|
|
return;
|
|
}
|
|
const next = { ...current, selected };
|
|
applyDraft(next);
|
|
setError(null);
|
|
if (!publishedRef.current && !next.enabled) return;
|
|
cancelTextTimer();
|
|
persistNow(next);
|
|
};
|
|
|
|
const toggleRequired = (fieldId: string, required: boolean) => {
|
|
const current = draftRef.current;
|
|
if (!current) return;
|
|
const next = {
|
|
...current,
|
|
selected: current.selected.map((field) => field.fieldId === fieldId ? { ...field, required } : field),
|
|
};
|
|
applyDraft(next);
|
|
if (!publishedRef.current && !next.enabled) return;
|
|
cancelTextTimer();
|
|
persistNow(next);
|
|
};
|
|
|
|
const link = saved?.token && saved.enabled && draft?.enabled ? formUrl(saved.token) : "";
|
|
const showCreating = Boolean(draft?.enabled && !link && saving);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-h-[85vh] overflow-y-auto sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("structuredTable.form.title")}</DialogTitle>
|
|
<DialogDescription>{t("structuredTable.form.description")}</DialogDescription>
|
|
</DialogHeader>
|
|
{!draft ? (
|
|
<div className="flex min-h-20 items-center justify-center text-slate-500" role="status">
|
|
<LoaderCircle className="h-5 w-5 animate-spin" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
<label className="flex items-center justify-between gap-3 text-xs font-normal leading-5">
|
|
<span>{t("structuredTable.form.enabled")}</span>
|
|
<Switch checked={draft.enabled} onCheckedChange={changeEnabled} aria-label={t("structuredTable.form.enabled")} />
|
|
</label>
|
|
{link ? (
|
|
<div className="space-y-2">
|
|
<span className="text-xs text-slate-500">{t("structuredTable.form.link")}</span>
|
|
<div className="flex gap-2">
|
|
<Input readOnly value={link} aria-label={t("structuredTable.form.link")} />
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
void copyTextToClipboard(link).then(() => {
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1500);
|
|
});
|
|
}}
|
|
>
|
|
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
|
|
{copied ? t("structuredTable.form.copied") : t("structuredTable.form.copy")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : showCreating ? (
|
|
<p className="flex items-center gap-2 text-xs text-slate-500" role="status">
|
|
<LoaderCircle className="h-3.5 w-3.5 animate-spin" />
|
|
{t("structuredTable.form.saving")}
|
|
</p>
|
|
) : null}
|
|
<label className="flex items-center justify-between gap-3 text-xs font-normal leading-5">
|
|
<span>{t("structuredTable.form.password")}</span>
|
|
<Switch checked={draft.passwordProtected} onCheckedChange={changePassword} aria-label={t("structuredTable.form.password")} />
|
|
</label>
|
|
<p className="text-xs text-slate-500">{t("structuredTable.form.passwordHint")}</p>
|
|
{draft.passwordProtected && password ? <Input readOnly value={password} aria-label={t("structuredTable.form.password")} /> : null}
|
|
{draft.passwordProtected && saved?.passwordProtected ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
cancelTextTimer();
|
|
const current = draftRef.current;
|
|
if (current) persistNow(current, true);
|
|
}}
|
|
disabled={saving}
|
|
>
|
|
{t("structuredTable.form.regenerate")}
|
|
</Button>
|
|
) : null}
|
|
<label className="block space-y-1 text-xs font-normal leading-5">
|
|
<span>{t("structuredTable.form.formTitle")}</span>
|
|
<Input
|
|
value={draft.title}
|
|
onChange={(event) => changeText({ title: event.target.value })}
|
|
aria-label={t("structuredTable.form.formTitle")}
|
|
/>
|
|
</label>
|
|
<label className="block space-y-1 text-xs font-normal leading-5">
|
|
<span>{t("structuredTable.form.formDescription")}</span>
|
|
<textarea
|
|
className="min-h-20 w-full rounded-md border border-slate-200 px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-slate-900/20"
|
|
value={draft.description}
|
|
onChange={(event) => changeText({ description: event.target.value })}
|
|
aria-label={t("structuredTable.form.formDescription")}
|
|
/>
|
|
</label>
|
|
<label className="block space-y-1 text-xs font-normal leading-5">
|
|
<span>{t("structuredTable.form.submitLabel")}</span>
|
|
<Input
|
|
value={draft.submitLabel}
|
|
placeholder={t("structuredTable.form.submitDefault")}
|
|
onChange={(event) => changeText({ submitLabel: event.target.value })}
|
|
aria-label={t("structuredTable.form.submitLabel")}
|
|
/>
|
|
</label>
|
|
<div className="space-y-2">
|
|
<p className="text-xs leading-5">{t("structuredTable.form.fields")}</p>
|
|
{fields.map((field) => {
|
|
const setting = draft.selected.find((item) => item.fieldId === field.id);
|
|
return (
|
|
<div key={field.id} className="flex items-center justify-between gap-3 text-xs font-normal leading-5">
|
|
<label className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(setting)}
|
|
aria-label={field.name}
|
|
onChange={(event) => toggleField(field.id, event.target.checked)}
|
|
/>
|
|
<span>{field.name}</span>
|
|
<span className="text-xs text-slate-400">{t(`structuredTable.types.${field.type}`)}</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs text-slate-500">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(setting?.required)}
|
|
disabled={!setting}
|
|
aria-label={`${field.name} ${t("structuredTable.form.required")}`}
|
|
onChange={(event) => toggleRequired(field.id, event.target.checked)}
|
|
/>
|
|
{t("structuredTable.form.required")}
|
|
</label>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{error ? <p className="text-xs leading-5 text-rose-600" role="alert">{error}</p> : null}
|
|
{!error && saving && link ? <p className="text-xs leading-5 text-slate-500" role="status">{t("structuredTable.form.saving")}</p> : null}
|
|
{!error && !saving && confirmed ? <p className="text-xs leading-5 text-emerald-700" role="status">{t("structuredTable.form.saved")}</p> : null}
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|