feat(web): disable submit while saving + Save & create another + Cmd/Ctrl+Enter (#46)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-07 23:15:21 +02:00
parent ed0c13907c
commit 3900bc362c
7 changed files with 122 additions and 25 deletions
+3 -2
View File
@@ -61,7 +61,7 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str
const defaults = { core, fields: object.fields };
const onSubmit = async (values: ObjectFormValues) => {
const onSubmit = async (values: ObjectFormValues): Promise<boolean> => {
setError(null);
setFieldErrorKey(null);
@@ -76,10 +76,11 @@ function ObjectEditFormLoaded({ object, id }: { object: AdminObjectView; id: str
setError(t("form.rejected"));
}
return;
return false;
}
navigate(`/objects/${id}`);
return true;
};
return (
+15 -1
View File
@@ -1,5 +1,5 @@
import { expect, test, vi } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderApp } from "../test/render";
import { ObjectForm } from "./object-form";
@@ -25,6 +25,20 @@ test("create mode: shows visibility (draft/internal only) and submits assembled
expect(values.fields.inscription).toBe("To the gods");
});
test("Cmd/Ctrl+Enter submits the form", async () => {
const onSubmit = vi.fn();
renderApp(<ObjectForm mode="create" onSubmit={onSubmit} onCancel={() => {}} />);
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
const numberInput = screen.getByLabelText(/object number/i);
fireEvent.keyDown(numberInput, { key: "Enter", metaKey: true });
await waitFor(() => expect(onSubmit).toHaveBeenCalledOnce());
});
test("required core + required flexible field block submit", async () => {
const onSubmit = vi.fn();
renderApp(<ObjectForm mode="create" onSubmit={onSubmit} onCancel={() => {}} />);
+40 -14
View File
@@ -54,7 +54,7 @@ export function ObjectForm({
}: {
mode: "create" | "edit";
defaults?: { core: ObjectCore; fields: Record<string, unknown> };
onSubmit: (values: ObjectFormValues) => void;
onSubmit: (values: ObjectFormValues, opts?: { createAnother?: boolean }) => Promise<boolean> | boolean;
onCancel: () => void;
formError?: string | null;
fieldErrorKey?: string | null;
@@ -76,7 +76,7 @@ export function ObjectForm({
},
});
const { register, handleSubmit, formState: { errors } } = form;
const { register, handleSubmit, formState: { errors, isSubmitting } } = form;
useEffect(() => {
if (fieldErrorKey) {
@@ -87,15 +87,21 @@ export function ObjectForm({
}
}, [fieldErrorKey, form, t]);
const submit = handleSubmit((data) => {
const fields = pruneFields(data.fields, localizedTextKeys, default_language);
const runSubmit = (createAnother: boolean) =>
handleSubmit(async (data) => {
const fields = pruneFields(data.fields, localizedTextKeys, default_language);
const values =
mode === "create"
? { core: data.core, visibility: data.visibility, fields }
: { core: data.core, fields };
const ok = await onSubmit(values, { createAnother });
if (ok && createAnother) {
form.reset({ core: EMPTY_CORE, visibility: "draft", fields: {} });
document.getElementById("object_number")?.focus();
}
});
onSubmit(
mode === "create"
? { core: data.core, visibility: data.visibility, fields }
: { core: data.core, fields },
);
});
const submit = runSubmit(false);
const coreField = (
key: keyof ObjectCore,
@@ -125,7 +131,16 @@ export function ObjectForm({
);
return (
<form onSubmit={submit} className="space-y-4 overflow-auto p-4">
<form
onSubmit={submit}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
e.preventDefault();
void submit();
}
}}
className="space-y-4 overflow-auto p-4"
>
{formError && (
<p role="alert" className="text-sm text-destructive">
{formError}
@@ -177,11 +192,22 @@ export function ObjectForm({
)}
<div className="flex gap-2 pt-2">
<Button type="submit">
{mode === "create" ? t("form.create") : t("form.save")}
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
</Button>
<Button type="button" variant="ghost" onClick={onCancel}>
{mode === "create" && (
<Button
type="button"
variant="secondary"
disabled={isSubmitting}
onClick={() => void runSubmit(true)()}
>
{t("form.createAnother")}
</Button>
)}
<Button type="button" variant="ghost" disabled={isSubmitting} onClick={onCancel}>
{t("form.cancel")}
</Button>
</div>
+51 -2
View File
@@ -1,7 +1,7 @@
import { expect, test } from "vitest";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { delay, http, HttpResponse } from "msw";
import { Routes, Route, useLocation } from "react-router-dom";
import { server } from "../test/server";
import { renderApp } from "../test/render";
@@ -9,7 +9,7 @@ import { ObjectNewPage } from "./object-new-page";
function EditStub() {
const location = useLocation();
const flagged = (location.state as { fieldsError?: boolean } | null)?.fieldsError === true;
const flagged = (location.state as { created?: boolean } | null)?.created === true;
return <div>edit page{flagged ? " (fields error)" : ""}</div>;
}
@@ -69,3 +69,52 @@ test("partial create: fields PUT fails -> navigate to edit with an error banner"
await waitFor(() => expect(screen.getByText(/edit page \(fields error\)/i)).toBeInTheDocument());
});
test("in-flight submit: button disabled + shows Saving…, create fires exactly once on double-click", async () => {
let postCount = 0;
server.use(
http.post("/api/admin/objects", async () => {
postCount += 1;
await delay(50);
return HttpResponse.json({ id: "new-id-3" }, { status: 201 });
}),
);
renderApp(tree(), { route: "/objects/new" });
await userEvent.type(await screen.findByLabelText(/object number/i), "A-9");
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
const button = screen.getByRole("button", { name: /create object/i });
await userEvent.click(button);
await userEvent.click(button);
await waitFor(() => expect(screen.getByText(/saving…/i)).toBeInTheDocument());
expect(screen.getByRole("button", { name: /saving…/i })).toBeDisabled();
await waitFor(() => expect(screen.getByText("detail view")).toBeInTheDocument());
expect(postCount).toBe(1);
});
test("Save & create another: resets the form and stays on /objects/new", async () => {
server.use(
http.post("/api/admin/objects", () =>
HttpResponse.json({ id: "new-id-4" }, { status: 201 }),
),
);
renderApp(tree(), { route: "/objects/new" });
const numberInput = (await screen.findByLabelText(/object number/i)) as HTMLInputElement;
await userEvent.type(numberInput, "A-9");
await userEvent.type(screen.getByLabelText(/^name/i), "Amphora");
await userEvent.type(screen.getByLabelText(/inscription/i), "To the gods");
await userEvent.click(screen.getByRole("button", { name: /save & create another/i }));
await waitFor(() => expect(numberInput.value).toBe(""));
expect(screen.queryByText("detail view")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: /save & create another/i })).toBeInTheDocument();
});
+11 -4
View File
@@ -18,7 +18,10 @@ export function ObjectNewPage() {
useDocumentTitle(t("objects.new"));
useBreadcrumb([{ label: t("nav.objects"), to: "/objects" }, { label: t("objects.new") }]);
const onSubmit = async (values: ObjectFormValues) => {
const onSubmit = async (
values: ObjectFormValues,
opts?: { createAnother?: boolean },
): Promise<boolean> => {
setError(null);
let id: string;
@@ -32,7 +35,7 @@ export function ObjectNewPage() {
id = created.id;
} catch {
setError(t("form.rejected"));
return;
return false;
}
if (Object.keys(values.fields).length > 0) {
@@ -40,12 +43,16 @@ export function ObjectNewPage() {
await setFields.mutateAsync({ id, fields: values.fields });
} catch (e) {
const fieldErrorKey = e instanceof FieldRejection ? e.field : undefined;
navigate(`/objects/${id}/edit`, { state: { fieldsError: true, fieldErrorKey } });
return;
const fieldErrorCode = e instanceof FieldRejection ? e.code : undefined;
navigate(`/objects/${id}/edit`, { state: { created: true, fieldErrorKey, fieldErrorCode } });
return true;
}
}
if (opts?.createAnother) return true;
navigate(`/objects/${id}`);
return true;
};
return (