feat(cms): recover drafts and failed imports with verified UI workflows
This commit is contained in:
1 parent
410a1e466c
commit
88790d1a0d
84 files changed
+3952
-288
No files matched your search
@@ -1,76 +1,87 @@
|
||||
"use client";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useVisiblePolling } from "@/hooks/use-visible-polling";
|
||||
import { adminFetch } from "@/lib/admin-fetch";
|
||||
import type { ImportJob, ImportJobItem } from "@/lib/furni/import-job";
|
||||
import type { ImportJobItem } from "@/lib/furni/import-job";
|
||||
import {
|
||||
createFurnitureJobsClient,
|
||||
FurnitureJobsHttpError,
|
||||
type FurnitureJobsPage,
|
||||
furnitureJobsQueryOptions,
|
||||
readFurnitureJobResponse,
|
||||
} from "./furniture-jobs-query";
|
||||
|
||||
export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const [client] = useState(createFurnitureJobsClient);
|
||||
const cursor = useRef<string | null>(null);
|
||||
const previous = useRef<Array<string | null>>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [pageCursor, setPageCursor] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const query = useQuery(
|
||||
{ ...furnitureJobsQueryOptions(paginated, pageCursor), enabled: false },
|
||||
client,
|
||||
);
|
||||
const unauthorized =
|
||||
query.error instanceof FurnitureJobsHttpError &&
|
||||
[401, 403].includes(query.error.status);
|
||||
const jobs = unauthorized ? [] : (query.data?.jobs ?? []);
|
||||
const nextCursor = unauthorized ? null : (query.data?.nextCursor ?? null);
|
||||
const loading = query.isFetching;
|
||||
const error = query.error?.message ?? "";
|
||||
const [cancelling, setCancelling] = useState<string | null>(null);
|
||||
const cancelInFlight = useRef(false);
|
||||
const [jobs, setJobs] = useState<ImportJob[]>([]),
|
||||
[busy, setBusy] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const completed = useRef(new Set<string>()),
|
||||
callback = useRef(onComplete);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const completed = useRef(new Set<string>());
|
||||
const callback = useRef(onComplete);
|
||||
callback.current = onComplete;
|
||||
const request = useRef<{ payload: string; id: string } | null>(null);
|
||||
const submitting = useRef(false);
|
||||
const refreshing = useRef(false);
|
||||
const refresh = useCallback(async () => {
|
||||
if (refreshing.current) return;
|
||||
refreshing.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await adminFetch(
|
||||
`/api/admin/studio/import-jobs${paginated ? `?page=1${cursor.current ? `&before=${encodeURIComponent(cursor.current)}` : ""}` : ""}`,
|
||||
);
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw Error(
|
||||
data.error || `Import history unavailable (${response.status})`,
|
||||
);
|
||||
setJobs(data.jobs);
|
||||
setNextCursor(data.nextCursor ?? null);
|
||||
setError("");
|
||||
let changed = false;
|
||||
for (const job of data.jobs as ImportJob[])
|
||||
if (
|
||||
(job.state === "completed" ||
|
||||
job.state === "interrupted" ||
|
||||
job.state === "cancelled") &&
|
||||
!completed.current.has(job.id)
|
||||
) {
|
||||
completed.current.add(job.id);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) callback.current();
|
||||
} catch (error) {
|
||||
setError(
|
||||
error instanceof Error ? error.message : "Cannot load import history",
|
||||
);
|
||||
} finally {
|
||||
refreshing.current = false;
|
||||
setLoading(false);
|
||||
useEffect(
|
||||
() => () => {
|
||||
client.clear();
|
||||
},
|
||||
[client],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!query.data || unauthorized) return;
|
||||
let changed = false;
|
||||
for (const job of query.data.jobs) {
|
||||
if (
|
||||
["completed", "interrupted", "cancelled"].includes(job.state) &&
|
||||
!completed.current.has(job.id)
|
||||
) {
|
||||
completed.current.add(job.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}, [paginated]);
|
||||
if (changed) callback.current();
|
||||
}, [query.data, unauthorized]);
|
||||
const refresh = useCallback(async () => {
|
||||
// fetchQuery shares an in-flight GET. Query owns data, errors and loading.
|
||||
await client
|
||||
.fetchQuery(furnitureJobsQueryOptions(paginated, cursor.current))
|
||||
.catch(() => undefined);
|
||||
}, [client, paginated]);
|
||||
const resetPage = () => {
|
||||
cursor.current = null;
|
||||
previous.current = [];
|
||||
setPageCursor(null);
|
||||
setPage(1);
|
||||
};
|
||||
const navigate = async (direction: "next" | "previous") => {
|
||||
if (refreshing.current) return;
|
||||
if (client.isFetching()) return;
|
||||
if (direction === "next" && nextCursor) {
|
||||
previous.current.push(cursor.current);
|
||||
cursor.current = nextCursor;
|
||||
} else if (direction === "previous" && previous.current.length)
|
||||
cursor.current = previous.current.pop() ?? null;
|
||||
else return;
|
||||
setPageCursor(cursor.current);
|
||||
setPage(previous.current.length + 1);
|
||||
setJobs([]);
|
||||
setNextCursor(null);
|
||||
await refresh();
|
||||
};
|
||||
const retry = async (id: string) => {
|
||||
@@ -83,11 +94,9 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id, action: "retry" }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(data.error || t("cancelFailed"));
|
||||
cursor.current = null;
|
||||
previous.current = [];
|
||||
setPage(1);
|
||||
await readFurnitureJobResponse(response, t("cancelFailed"));
|
||||
await client.cancelQueries();
|
||||
resetPage();
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
|
||||
@@ -99,6 +108,7 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
const active = jobs.some(
|
||||
(job) => job.state === "queued" || job.state === "running",
|
||||
);
|
||||
// Retain settle-then-poll timing and visibility behavior without global focus state.
|
||||
useVisiblePolling(refresh, active ? 5000 : 30000);
|
||||
const cancel = async (id: string) => {
|
||||
if (cancelInFlight.current) return;
|
||||
@@ -110,10 +120,17 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok) throw Error(data.error || t("cancelFailed"));
|
||||
setJobs((current) =>
|
||||
current.map((job) => (job.id === id ? data.job : job)),
|
||||
const job = await readFurnitureJobResponse(response, t("cancelFailed"));
|
||||
await client.cancelQueries();
|
||||
client.setQueriesData<FurnitureJobsPage>(
|
||||
{ queryKey: ["furniture-import-history"] },
|
||||
(current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
jobs: current.jobs.map((item) => (item.id === id ? job : item)),
|
||||
}
|
||||
: current,
|
||||
);
|
||||
await refresh();
|
||||
} catch (error) {
|
||||
@@ -145,12 +162,10 @@ export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...body, id: request.current.id }),
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!response.ok)
|
||||
throw Error(
|
||||
data.error || `Could not queue import (${response.status})`,
|
||||
);
|
||||
await readFurnitureJobResponse(response, "Could not queue import");
|
||||
request.current = null;
|
||||
await client.cancelQueries();
|
||||
resetPage();
|
||||
await refresh();
|
||||
toast.success(
|
||||
"Import queued. You can leave this page and return to its history.",
|
||||
|
||||
Reference in new issue
Block a user