feat(cms): add recovery history and operational reliability tools
This commit is contained in:
1 parent
89526af344
commit
1ef405be0a
92 files changed
+6384
-267
No files matched your search
@@ -9,6 +9,7 @@ import type { ArticleSaveResult } from "@/lib/article-input";
|
||||
import { slugify } from "@/lib/format";
|
||||
import { ArticlePreview, type ArticlePreviewData } from "./article-preview";
|
||||
import { useArticleRecovery } from "./article-recovery";
|
||||
import { FormFieldError, useFormErrorFocus } from "./form-feedback";
|
||||
import { MediaPicker } from "./media-picker";
|
||||
import { RichText } from "./rich-text";
|
||||
|
||||
@@ -36,6 +37,7 @@ export function ArticleForm({
|
||||
const tAction = useTranslations("pages.admin.actions");
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string[]>>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
const saving = useRef(false);
|
||||
@@ -86,6 +88,7 @@ export function ArticleForm({
|
||||
dirty,
|
||||
restoreDraft,
|
||||
);
|
||||
useFormErrorFocus(formRef, fieldErrors, pending);
|
||||
const [preview, setPreview] = useState<ArticlePreviewData | null>(null);
|
||||
const suggestedSlug = useMemo(() => slugify(title), [title]);
|
||||
|
||||
@@ -105,12 +108,14 @@ export function ArticleForm({
|
||||
const submittedVersion = editVersion.current;
|
||||
saving.current = true;
|
||||
setSaveError(null);
|
||||
setFieldErrors({});
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await recovery.wait();
|
||||
const result = await action(data);
|
||||
if (result && !result.ok) {
|
||||
setSaveError(result.error);
|
||||
setFieldErrors(result.fieldErrors ?? {});
|
||||
return;
|
||||
}
|
||||
if (editVersion.current === submittedVersion) setDirty(false);
|
||||
@@ -151,6 +156,10 @@ export function ArticleForm({
|
||||
</span>
|
||||
<input
|
||||
name="title"
|
||||
aria-invalid={!!fieldErrors.title?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.title?.length ? "error-title" : undefined
|
||||
}
|
||||
maxLength={255}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
@@ -158,6 +167,7 @@ export function ArticleForm({
|
||||
required
|
||||
className="input input-bordered mt-1 w-full"
|
||||
/>
|
||||
<FormFieldError name="title" errors={fieldErrors} />
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
@@ -186,6 +196,10 @@ export function ArticleForm({
|
||||
<input
|
||||
aria-label={t("image")}
|
||||
name="image"
|
||||
aria-invalid={!!fieldErrors.image?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.image?.length ? "error-image" : undefined
|
||||
}
|
||||
maxLength={255}
|
||||
value={image}
|
||||
onChange={(e) => {
|
||||
@@ -195,6 +209,7 @@ export function ArticleForm({
|
||||
placeholder={t("imagePlaceholder")}
|
||||
className="input input-bordered min-w-0 flex-1"
|
||||
/>
|
||||
<FormFieldError name="image" errors={fieldErrors} />
|
||||
<MediaPicker
|
||||
onSelect={(url) => {
|
||||
setImage(url);
|
||||
@@ -227,6 +242,10 @@ export function ArticleForm({
|
||||
</span>
|
||||
<textarea
|
||||
name="shortStory"
|
||||
aria-invalid={!!fieldErrors.shortStory?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.shortStory?.length ? "error-shortStory" : undefined
|
||||
}
|
||||
maxLength={255}
|
||||
value={summary}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
@@ -234,6 +253,7 @@ export function ArticleForm({
|
||||
rows={2}
|
||||
className="input input-bordered mt-1 w-full resize-y"
|
||||
/>
|
||||
<FormFieldError name="shortStory" errors={fieldErrors} />
|
||||
</label>
|
||||
|
||||
<div className="block">
|
||||
@@ -257,6 +277,10 @@ export function ArticleForm({
|
||||
</span>
|
||||
<select
|
||||
name="status"
|
||||
aria-invalid={!!fieldErrors.status?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.status?.length ? "error-status" : undefined
|
||||
}
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
className="input input-bordered mt-1 w-full"
|
||||
@@ -265,6 +289,7 @@ export function ArticleForm({
|
||||
<option value="scheduled">{t("statusScheduled")}</option>
|
||||
<option value="published">{t("statusPublished")}</option>
|
||||
</select>
|
||||
<FormFieldError name="status" errors={fieldErrors} />
|
||||
</label>
|
||||
|
||||
{status === "scheduled" ? (
|
||||
@@ -275,11 +300,16 @@ export function ArticleForm({
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="publishAt"
|
||||
aria-invalid={!!fieldErrors.publishAt?.length}
|
||||
aria-describedby={
|
||||
fieldErrors.publishAt?.length ? "error-publishAt" : undefined
|
||||
}
|
||||
value={publishAt}
|
||||
onChange={(e) => setPublishAt(e.target.value)}
|
||||
required
|
||||
className="input input-bordered mt-1 w-full"
|
||||
/>
|
||||
<FormFieldError name="publishAt" errors={fieldErrors} />
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
applyCatalogIntegrityRepairAction,
|
||||
inspectCatalogIntegrityAction,
|
||||
previewCatalogIntegrityRepairAction,
|
||||
} from "@/actions/catalog-integrity";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import type {
|
||||
IntegrityCatalog,
|
||||
inspectCatalogIntegrity,
|
||||
} from "@/lib/services/catalog-integrity";
|
||||
import type { CatalogParentRepairPreview } from "@/lib/services/catalog-repair";
|
||||
|
||||
type Report = ReturnType<typeof inspectCatalogIntegrity>;
|
||||
export function CatalogIntegrityPanel({ canRepair }: { canRepair: boolean }) {
|
||||
const t = useTranslations("pages.admin.catalog.integrity");
|
||||
const [catalog, setCatalog] = useState<IntegrityCatalog>("normal");
|
||||
const [report, setReport] = useState<Report | null>(null);
|
||||
const [preview, setPreview] = useState<CatalogParentRepairPreview | null>(
|
||||
null,
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<"failed" | "stalePreview" | null>(null);
|
||||
const [applied, setApplied] = useState<number | null>(null);
|
||||
const [refresh, setRefresh] = useState(0);
|
||||
const request = useRef(0);
|
||||
const applying = useRef(false);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: A requested refresh must start a new read-only scan.
|
||||
useEffect(() => {
|
||||
const version = ++request.current;
|
||||
setBusy(true);
|
||||
setReport(null);
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
void inspectCatalogIntegrityAction(catalog)
|
||||
.then((result) => {
|
||||
if (version !== request.current) return;
|
||||
if (result.ok && result.data) setReport(result.data);
|
||||
else setError("failed");
|
||||
})
|
||||
.catch(() => {
|
||||
if (version === request.current) setError("failed");
|
||||
})
|
||||
.finally(() => {
|
||||
if (version === request.current) setBusy(false);
|
||||
});
|
||||
return () => {
|
||||
request.current++;
|
||||
};
|
||||
}, [catalog, refresh]);
|
||||
async function prepare() {
|
||||
setBusy(true);
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
setApplied(null);
|
||||
try {
|
||||
const result = await previewCatalogIntegrityRepairAction(catalog);
|
||||
if (result.ok && result.data) setPreview(result.data);
|
||||
else setError("failed");
|
||||
} catch {
|
||||
setError("failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function apply() {
|
||||
if (!preview || applying.current) return;
|
||||
applying.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await applyCatalogIntegrityRepairAction(
|
||||
preview.catalog,
|
||||
preview.fingerprint,
|
||||
);
|
||||
setPreview(null);
|
||||
if (result.ok && result.data) {
|
||||
setApplied(result.data.applied);
|
||||
setRefresh((value) => value + 1);
|
||||
} else if (!result.ok)
|
||||
setError(result.error === "stalePreview" ? "stalePreview" : "failed");
|
||||
} catch {
|
||||
setError("failed");
|
||||
setPreview(null);
|
||||
} finally {
|
||||
applying.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("title")}</CardTitle>
|
||||
<CardDescription>{t("description")}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-sm" htmlFor="integrity-catalog">
|
||||
{t("catalog")}
|
||||
</label>
|
||||
<select
|
||||
id="integrity-catalog"
|
||||
value={catalog}
|
||||
disabled={busy}
|
||||
onChange={(event) => {
|
||||
setCatalog(event.target.value as IntegrityCatalog);
|
||||
setApplied(null);
|
||||
}}
|
||||
className="rounded-md border bg-[var(--admin-surface)] p-2 text-sm"
|
||||
>
|
||||
<option value="normal">{t("normal")}</option>
|
||||
<option value="bc">{t("bc")}</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy}
|
||||
onClick={() => setRefresh((value) => value + 1)}
|
||||
>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
{busy && <p role="status">{t("working")}</p>}
|
||||
{error && (
|
||||
<p role="alert" className="text-destructive">
|
||||
{t(error)}
|
||||
</p>
|
||||
)}
|
||||
{applied !== null && (
|
||||
<p role="status">{t("applied", { count: applied })}</p>
|
||||
)}
|
||||
{report && (
|
||||
<>
|
||||
{!report.iconsAvailable && (
|
||||
<p role="status" className="text-sm">
|
||||
{t("iconsUnavailable")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm">
|
||||
{report.total === 0
|
||||
? t("healthy")
|
||||
: t("total", {
|
||||
count: report.total,
|
||||
shown: report.issues.length,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(report.counts).map(([code, count]) => (
|
||||
<span
|
||||
key={code}
|
||||
className="rounded-md border px-2 py-1 text-xs"
|
||||
>
|
||||
{t(`issues.${code}`)}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{report.issues.length > 0 && (
|
||||
<ul className="m-0 max-h-80 list-none space-y-2 overflow-y-auto p-0">
|
||||
{report.issues.map((issue) => (
|
||||
<li
|
||||
key={`${issue.code}-${issue.id}`}
|
||||
className="rounded-md border p-3 text-sm"
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-between gap-2">
|
||||
<span className="min-w-0 break-all font-medium">
|
||||
{t(`issues.${issue.code}`)} · #{issue.id} {issue.name}
|
||||
</span>
|
||||
<Link href={issue.href}>{t("open")}</Link>
|
||||
</div>
|
||||
<p className="m-0 break-all text-xs text-muted-foreground">
|
||||
{t("reference", { value: issue.detail })}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{canRepair && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<p className="m-0 text-sm">{t("repairHint")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={busy || !report}
|
||||
onClick={() => void prepare()}
|
||||
>
|
||||
{t("preview")}
|
||||
</Button>
|
||||
{preview && (
|
||||
<>
|
||||
<p className="m-0 text-sm">
|
||||
{t("planned", { count: preview.changes.length })}
|
||||
</p>
|
||||
<ul className="m-0 max-h-60 overflow-y-auto pl-5 text-sm">
|
||||
{preview.changes.map((change) => (
|
||||
<li key={change.id} className="break-all">
|
||||
#{change.id} {change.caption}:{" "}
|
||||
{t("parentChange", { from: change.from })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
disabled={busy || preview.changes.length === 0}
|
||||
onClick={() => void apply()}
|
||||
>
|
||||
{t("apply")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
import { type RefObject, useEffect } from "react";
|
||||
export function useFormErrorFocus(
|
||||
form: RefObject<HTMLFormElement | null>,
|
||||
errors: Record<string, string[]>,
|
||||
pending: boolean,
|
||||
) {
|
||||
useEffect(() => {
|
||||
if (pending) return;
|
||||
for (const field of Object.keys(errors)) {
|
||||
const control = form.current?.elements.namedItem(field);
|
||||
if (control instanceof HTMLElement && !control.hasAttribute("disabled")) {
|
||||
control.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [form, errors, pending]);
|
||||
}
|
||||
export function FormFieldError({
|
||||
name,
|
||||
errors,
|
||||
}: {
|
||||
name: string;
|
||||
errors: Record<string, string[]>;
|
||||
}) {
|
||||
const messages = errors[name];
|
||||
return messages?.length ? (
|
||||
<p id={`error-${name}`} role="alert" className="text-sm text-destructive">
|
||||
{messages.join(" · ")}
|
||||
</p>
|
||||
) : null;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CatalogExportClient } from "@/app/admin/studio/sync/catalog-export-client";
|
||||
import Link from "@/components/link";
|
||||
import { FurnitureJobHistory } from "./studio/furniture-jobs";
|
||||
import { useFurnitureJobs } from "./studio/use-furniture-jobs";
|
||||
export function OperationsCenter() {
|
||||
const t = useTranslations("pages.admin.operationsCenter");
|
||||
const jobs = useFurnitureJobs(() => {}, true);
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">{t("title")}</h1>
|
||||
<p className="text-[var(--admin-text-muted)]">{t("hint")}</p>
|
||||
<FurnitureJobHistory state={jobs} expanded />
|
||||
<CatalogExportClient monitorOnly />
|
||||
<section className="admin-card space-y-3">
|
||||
<h2 className="text-lg font-semibold">{t("syncTitle")}</h2>
|
||||
<p>{t("syncHint")}</p>
|
||||
<Link href="/admin/studio/sync" className="btn btn-outline">
|
||||
{t("openSync")}
|
||||
</Link>
|
||||
<Link href="/admin/studio/furni" className="btn btn-outline">
|
||||
{t("openImports")}
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -58,7 +58,7 @@ export async function OperationsInbox() {
|
||||
source: "imports",
|
||||
count: total,
|
||||
severity: 2,
|
||||
href: "/admin/studio/furni",
|
||||
href: "/admin/operations",
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
@@ -7,14 +7,19 @@ import { retryableJobItems } from "@/lib/furni/import-job-retry";
|
||||
import type { useFurnitureJobs } from "./use-furniture-jobs";
|
||||
export function FurnitureJobHistory({
|
||||
state,
|
||||
expanded = false,
|
||||
}: {
|
||||
state: ReturnType<typeof useFurnitureJobs>;
|
||||
expanded?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const format = useFormatter();
|
||||
const [limits, setLimits] = useState<Record<string, number>>({});
|
||||
return (
|
||||
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm">
|
||||
<details
|
||||
open={expanded || undefined}
|
||||
className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2 text-sm"
|
||||
>
|
||||
<summary className="cursor-pointer">
|
||||
{t("summary", {
|
||||
count: state.jobs.filter(
|
||||
@@ -22,15 +27,40 @@ export function FurnitureJobHistory({
|
||||
).length,
|
||||
})}
|
||||
</summary>
|
||||
<div className="max-h-72 space-y-2 overflow-auto py-2">
|
||||
<div
|
||||
className={
|
||||
expanded ? "space-y-2 py-2" : "max-h-72 space-y-2 overflow-auto py-2"
|
||||
}
|
||||
>
|
||||
{state.error && <p role="alert">{state.error}</p>}
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t("historyLimit")}
|
||||
{state.paginated ? t("ownerHistory") : t("historyLimit")}
|
||||
</p>
|
||||
<Button variant="outline" onClick={() => void state.refresh()}>
|
||||
{t("refreshHistory")}
|
||||
</Button>
|
||||
{!state.jobs.length && <p>{t("empty")}</p>}
|
||||
{state.paginated && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.loading || state.page === 1}
|
||||
onClick={() => void state.navigate("previous")}
|
||||
>
|
||||
{t("previousPage")}
|
||||
</Button>
|
||||
<span>{t("page", { page: state.page })}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.loading || !state.nextCursor}
|
||||
onClick={() => void state.navigate("next")}
|
||||
>
|
||||
{t("nextPage")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{!state.jobs.length && !state.loading && !state.error && (
|
||||
<p>{t("empty")}</p>
|
||||
)}
|
||||
{state.jobs.map((job) => {
|
||||
const retryItems = retryableJobItems(job);
|
||||
const done = job.items.filter((i) => i.state === "done"),
|
||||
@@ -123,14 +153,7 @@ export function FurnitureJobHistory({
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={state.busy}
|
||||
onClick={() =>
|
||||
void state.submit(retryItems, {
|
||||
sourceId: job.sourceId,
|
||||
mode: job.mode,
|
||||
translate: job.translate,
|
||||
langs: job.langs,
|
||||
})
|
||||
}
|
||||
onClick={() => void state.retry(job.id)}
|
||||
>
|
||||
{t(
|
||||
job.state === "interrupted" || job.state === "cancelled"
|
||||
|
||||
@@ -5,8 +5,13 @@ 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";
|
||||
export function useFurnitureJobs(onComplete: () => void) {
|
||||
export function useFurnitureJobs(onComplete: () => void, paginated = false) {
|
||||
const t = useTranslations("pages.admin.importHistory");
|
||||
const cursor = useRef<string | null>(null);
|
||||
const previous = useRef<Array<string | null>>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<string | null>(null);
|
||||
const cancelInFlight = useRef(false);
|
||||
const [jobs, setJobs] = useState<ImportJob[]>([]),
|
||||
@@ -21,14 +26,18 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
const refresh = useCallback(async () => {
|
||||
if (refreshing.current) return;
|
||||
refreshing.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs");
|
||||
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[])
|
||||
@@ -48,8 +57,45 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
);
|
||||
} finally {
|
||||
refreshing.current = false;
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [paginated]);
|
||||
const navigate = async (direction: "next" | "previous") => {
|
||||
if (refreshing.current) 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;
|
||||
setPage(previous.current.length + 1);
|
||||
setJobs([]);
|
||||
setNextCursor(null);
|
||||
await refresh();
|
||||
};
|
||||
const retry = async (id: string) => {
|
||||
if (submitting.current) return;
|
||||
submitting.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await adminFetch("/api/admin/studio/import-jobs", {
|
||||
method: "PATCH",
|
||||
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 refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t("cancelFailed"));
|
||||
} finally {
|
||||
submitting.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const active = jobs.some(
|
||||
(job) => job.state === "queued" || job.state === "running",
|
||||
);
|
||||
@@ -120,5 +166,19 @@ export function useFurnitureJobs(onComplete: () => void) {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return { jobs, busy, error, submit, refresh, cancel, cancelling };
|
||||
return {
|
||||
jobs,
|
||||
busy,
|
||||
error,
|
||||
submit,
|
||||
refresh,
|
||||
cancel,
|
||||
cancelling,
|
||||
retry,
|
||||
navigate,
|
||||
page,
|
||||
nextCursor,
|
||||
loading,
|
||||
paginated,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user