feat(cms): recover drafts and failed imports with verified UI workflows

This commit is contained in:
Simo committed 2026-09-11 08:58:10 +02:00
1 parent 410a1e466c
commit 88790d1a0d
84 files changed
+3952 -288

No files matched your search

+29 -1
View File
@@ -3,6 +3,7 @@
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import { useFormRecovery } from "@/hooks/use-form-recovery";
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
import type { ArticleDraft } from "@/lib/article-draft";
import type { ArticleSaveResult } from "@/lib/article-input";
@@ -17,11 +18,13 @@ import { RichText } from "./rich-text";
export function ArticleForm({
action,
userId,
defaultValues,
articleKey = "new",
baseToken = "",
}: {
action: (formData: FormData) => Promise<ArticleSaveResult | undefined>;
userId?: string;
edit?: boolean;
articleKey?: string;
baseToken?: string;
@@ -45,6 +48,7 @@ export function ArticleForm({
const saving = useRef(false);
const editVersion = useRef(0);
function markDirty() {
localRecovery.changed();
editVersion.current++;
setDirty(true);
}
@@ -90,6 +94,24 @@ export function ArticleForm({
dirty,
restoreDraft,
);
const localRecovery = useFormRecovery({
serverDraft: recovery.draft,
userId,
kind: "article",
record: articleKey,
dirty,
snapshot: () => ({
title,
slug: slugTouched ? slug : slugify(title),
image,
shortStory: summary,
fullStory: body,
status,
publishAt,
}),
restore: (payload) =>
restoreDraft({ ...payload, baseToken } as ArticleDraft, false),
});
useFormErrorFocus(formRef, fieldErrors, pending);
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
const suggestedSlug = useMemo(() => slugify(title), [title]);
@@ -126,6 +148,7 @@ export function ArticleForm({
if (!Number.isNaN(date.getTime()))
data.set("publishAt", date.toISOString());
}
localRecovery.flush();
const submittedVersion = editVersion.current;
saving.current = true;
setSaveError(null);
@@ -139,7 +162,10 @@ export function ArticleForm({
setFieldErrors(result.fieldErrors ?? {});
return;
}
if (editVersion.current === submittedVersion) setDirty(false);
if (result?.ok && editVersion.current === submittedVersion) {
setDirty(false);
localRecovery.clear();
}
if (result?.ok) await recovery.clear().catch(() => {});
if (result?.ok && result.data?.redirectTo)
router.push(result.data.redirectTo);
@@ -164,6 +190,7 @@ export function ArticleForm({
>
<fieldset disabled={pending} className="contents">
<input type="hidden" name="baseToken" value={token} />
{localRecovery.panel}
{recovery.panel}
{saveError && (
<p role="alert" className="text-sm">
@@ -282,6 +309,7 @@ export function ArticleForm({
{t("body")}
</span>
<RichText
ariaLabel={t("body")}
key={bodyVersion}
onChange={() => {
markDirty();
@@ -82,6 +82,7 @@ export function useArticleRecovery(
};
}, [key, form, saving, reload]);
return {
draft: recovery?.draft?.payload,
wait: () => request.current ?? Promise.resolve(),
clear: () => clearArticleRecovery(key, version.current),
panel: (
+8 -2
View File
@@ -395,7 +395,10 @@ export function DataTable<T extends { id: number | string }>({
value={currentValue}
onValueChange={(v) => handleFilterChange(col, v)}
>
<SelectTrigger className="h-8 text-xs">
<SelectTrigger
aria-label={col.label}
className="h-8 text-xs"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -479,7 +482,10 @@ export function DataTable<T extends { id: number | string }>({
value={String(data.perPage)}
onValueChange={handlePerPageChange}
>
<SelectTrigger className="w-[100px]">
<SelectTrigger
aria-label={t("perPage", { count: data.perPage })}
className="w-[100px]"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
+5 -1
View File
@@ -56,12 +56,14 @@ export function RichText({
name,
defaultValue = "",
placeholder,
ariaLabel,
rows = 10,
onChange,
}: {
name: string;
defaultValue?: string;
placeholder?: string;
ariaLabel?: string;
rows?: number;
onChange?: () => void;
}) {
@@ -83,6 +85,7 @@ export function RichText({
.init({
selector,
menubar: false,
iframe_attrs: { title: ariaLabel || name },
height: Math.max(rows * 22, 240),
plugins: "link lists code autolink",
toolbar:
@@ -127,13 +130,14 @@ export function RichText({
/* noop */
}
};
}, [editorId, rows]);
}, [editorId, rows, ariaLabel, name]);
return (
<textarea
id={editorId}
ref={textareaRef}
name={name}
aria-label={ariaLabel || name}
defaultValue={defaultValue}
placeholder={placeholder}
rows={rows}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -4,6 +4,7 @@ import { useFormatter, useTranslations } from "next-intl";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { retryableJobItems } from "@/lib/furni/import-job-retry";
import { ImportAttachmentRecovery } from "./import-attachment-recovery";
import type { useFurnitureJobs } from "./use-furniture-jobs";
export function FurnitureJobHistory({
state,
@@ -98,6 +99,18 @@ export function FurnitureJobHistory({
{item.error}
</p>
)}
{!job.syncKind &&
item.state === "failed" &&
retryItems.some(
(retryItem) => retryItem.classname === item.classname,
) && (
<ImportAttachmentRecovery
jobId={job.id}
classname={item.classname}
disabled={state.busy}
onResumed={state.refresh}
/>
)}
{[...new Set(item.warnings ?? [])].map((warning) => (
<p
key={warning}
File diff suppressed because it is too large. Load diff
+14 -3
View File
@@ -17,6 +17,7 @@ import { adminFetch } from "@/lib/admin-fetch";
import { previewAutoCatalog } from "@/lib/furni/auto-catalog";
import { compareFurniture } from "@/lib/furni/studio-inspection";
import { FurnitureComparison } from "./furniture-inspector";
import { ImportSourceDiagnostics } from "./import-source-diagnostics";
import type { FurniItem } from "./studio-types";
import { useFurnitureInspection } from "./use-furniture-inspection";
import { useSourceAssetChecks } from "./use-source-asset-checks";
@@ -38,6 +39,7 @@ export function ImportReview({
busy?: boolean;
onConfirm: (items: FurniItem[]) => void;
}) {
const diagnosticsT = useTranslations("admin.studio.importDiagnostics");
const completenessT = useTranslations("admin.studio.completeness");
const [attachments, setAttachments] = useState<Record<string, string>>({});
const [uploading, setUploading] = useState<string | null>(null);
@@ -57,7 +59,7 @@ export function ImportReview({
...current,
[item.classname]: data.attachmentId,
}));
toast.success("Matching .nitro attached to this import");
toast.success(diagnosticsT("attached"));
} catch (error) {
toast.error(error instanceof Error ? error.message : "Upload failed");
} finally {
@@ -260,8 +262,8 @@ export function ImportReview({
{local.nitro.exists !== true && (
<label className="mt-3 block text-sm">
{attachments[item.classname]
? "Matching .nitro attached"
: "Attach the original .nitro"}
? diagnosticsT("attached")
: diagnosticsT("attach")}
<input
className="mt-2 block max-w-full"
type="file"
@@ -278,6 +280,15 @@ export function ImportReview({
)}
</label>
)}
{assets.items.find(
(check) => check.classname === item.classname,
) && (
<ImportSourceDiagnostics
check={assets.items.find(
(check) => check.classname === item.classname,
)}
/>
)}
<FurnitureComparison item={item} local={local} />
</div>
)}
@@ -0,0 +1,54 @@
import { renderToStaticMarkup } from "react-dom/server";
import { expect, it, vi } from "vitest";
import { ImportSourceDiagnostics } from "./import-source-diagnostics";
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, args?: Record<string, unknown>) =>
`${key}${args?.status ?? ""}`,
}));
it("shows filenames, attempted addresses and distinct reasons inline", () => {
const html = renderToStaticMarkup(
<ImportSourceDiagnostics
check={{
classname: "chair",
revision: 1,
state: "missing",
alternatives: [],
attempts: [
{
file: "chair.swf",
url: "https://source.test/1/chair.swf",
state: "missing",
reason: "http",
status: 404,
},
{
file: "chair.nitro",
url: "https://source.test/chair.nitro",
state: "unknown",
reason: "timeout",
},
],
}}
/>,
);
expect(html).toContain("chair.swf");
expect(html).toContain("http404");
expect(html).toContain("timeout");
expect(html).toContain("https://source.test/1/chair.swf");
expect(html).not.toContain("href=");
});
it("does not invent diagnostics for earlier responses", () => {
expect(
renderToStaticMarkup(
<ImportSourceDiagnostics
check={{
classname: "chair",
revision: 1,
state: "missing",
alternatives: [],
}}
/>,
),
).toBe("");
});
@@ -0,0 +1,32 @@
"use client";
import { useTranslations } from "next-intl";
import type { SourceAssetCheck } from "@/lib/furni/source-assets";
export function ImportSourceDiagnostics({
check,
}: {
check?: SourceAssetCheck;
}) {
const t = useTranslations("admin.studio.importDiagnostics");
if (!check?.attempts?.length) return null;
return (
<details className="mt-2 text-xs">
<summary className="cursor-pointer">{t("attempts")}</summary>
<p className="mt-2 text-[var(--admin-text-muted)]">{t("preliminary")}</p>
<ul className="space-y-2 py-2">
{[
...new Map(
check.attempts.map((attempt) => [attempt.url, attempt]),
).values(),
].map((attempt) => (
<li key={attempt.url}>
<strong>{attempt.file}</strong> ·{" "}
{attempt.reason === "http"
? t("http", { status: attempt.status ?? 0 })
: t(attempt.reason)}
<code className="block break-all">{attempt.url}</code>
</li>
))}
</ul>
</details>
);
}
File diff suppressed because it is too large. Load diff