a control that saved and changed nothing, and saves that failed silently

Two defects found by reviewing the panel rather than the site.

── The lead photograph had a cropper that did nothing ──

It wrote `content.crop` on a subject-cover block. The site stopped reading that
when the crop moved to the row - one rectangle framed against one photograph and
applied to every row a template draws goes through the face of the next one - and
the control stayed behind. It saved, the API allowlisted it, and nothing rendered
it, under help text promising it applied to every row.

Gone, with the lead fetch that fed it. Shape stays: one decision for the whole
section, and it is read. The per-row cropper in each section's settings is the
one that works.

── A failed save said nothing at all ──

SubjectFields drives Collections, Pieces and Collabs. Neither of its save paths
had a failure branch, so a rejected PATCH was an unhandled rejection and a person
watching their own change had no way to know it was not written. There is no Save
button here by design, which makes reporting the failure the only signal there
is.

It matters most for the cover, which does not go through the draft: typing
survives, because the draft is only cleared on success and the next keystroke
retries, but a crop that fails is simply gone while the picture goes on showing
the rectangle that was dragged.

Also: the shape is computed once instead of three times from a fabricated
`{ url: 'x.png' }`, and the SHAPES import moved to the top of the file with the
others rather than sitting after a top-level const.
This commit is contained in:
2026-09-05 19:38:05 +01:00
parent 74c6776c75
commit 732a584dfb
2 changed files with 64 additions and 76 deletions
+38 -4
View File
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { useClient } from 'hooks/useClient';
import { readableError } from '../errors';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { RichText } from '../RichText';
@@ -41,6 +42,20 @@ export const SubjectFields = <T extends SubjectRecord>({
const client = useClient();
const [draft, setDraft] = useState<Partial<Record<string, string>>>({});
/*
What went wrong, because otherwise nothing says.
Every save here is silent by design - there is no button to report back to, which is the whole
point of useAutosave. That is fine while it works and indefensible when it does not: the two paths
below had no failure branch at all, so a rejected PATCH was an unhandled promise and a person
watching their own change on screen had no way to know it had not been written.
It matters most for the cover, which does not go through the draft. Typing survives a failure -
the draft is only cleared on success, so the next keystroke tries again with a new value. A crop
that fails is simply gone, and the picture goes on showing the rectangle that was dragged.
*/
const [error, setError] = useState<string | null>(null);
// Keyed on the ID, not the values: keyed on the values it would fight the typing, pulling every
// keystroke back to the last saved one until the save landed.
useEffect(() => setDraft({}), [record.id]);
@@ -51,9 +66,16 @@ export const SubjectFields = <T extends SubjectRecord>({
const dirty = Object.keys(draft).length > 0;
useAutosave(dirty ? JSON.stringify(draft) : null, false, async () => {
await client.patch(`${section.endpoint}/${record.id}`, draft);
setDraft({});
onSaved();
try {
await client.patch(`${section.endpoint}/${record.id}`, draft);
setError(null);
// Only on success: what is still here is what has not been written, and clearing it would throw
// away the typing along with the news that it was lost.
setDraft({});
onSaved();
} catch (failure) {
setError(readableError(failure));
}
});
const set = (key: string) => (next: string) => setDraft((was) => ({ ...was, [key]: next }));
@@ -67,7 +89,13 @@ export const SubjectFields = <T extends SubjectRecord>({
discarded by the `setDraft({})` that follows every save.
*/
const save = (values: Record<string, unknown>) => {
void client.patch(`${section.endpoint}/${record.id}`, values).then(onSaved);
void client
.patch(`${section.endpoint}/${record.id}`, values)
.then(() => {
setError(null);
onSaved();
})
.catch((failure: unknown) => setError(readableError(failure)));
};
/* Null means the carousel's first item, which the panel already knows — see SubjectPanel. */
@@ -82,6 +110,12 @@ export const SubjectFields = <T extends SubjectRecord>({
return (
<div className="space-y-4">
{error && (
<p className="rounded-md border border-destructive/40 bg-destructive/10 px-2 py-1.5 text-xs text-destructive">
{error}
</p>
)}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">Name</label>
<Input value={value('title')} onChange={(event) => set('title')(event.target.value)} />