one button, and it appears when there is something to press
Two buttons was the wrong model, and it failed in the worst way: Create keyed off the SAVED names, so on a fresh screen with both fields filled it did not render at all — and the hint explaining why was behind the same condition. Two filled fields, one Save, and nothing saying what came next. Naming and creating are not separate decisions from the outside. You type two names because you want two databases. So one button that records them and creates whatever is missing, labelled with whichever of those it is about to do, and disabled only when there is genuinely nothing to press. The tick beside each field now asks pg_database about the text in the BOX rather than the text last saved. That is the question somebody typing a name has, and answering it about a different name was worse than silence. The PUT that only stored names goes with the second button. Nothing called it, and a second way to write the same two columns is a second thing that can drift. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+51
-68
@@ -10,22 +10,23 @@ import { useFlash } from './useFlash';
|
||||
/**
|
||||
* Where the fofos site's own data lives — two databases, named here.
|
||||
*
|
||||
* ── Naming and creating are separate ──
|
||||
* ── ONE button ──
|
||||
*
|
||||
* Save stores what you typed; Create makes whichever of the two is not there yet. They are different
|
||||
* decisions and were worth two buttons: a database that already exists still needs its name recorded,
|
||||
* and making Create the only way to save would mean nobody could point the site at something that was
|
||||
* already set up.
|
||||
* It shipped with two — Save, then Create — on the reasoning that naming and creating are different
|
||||
* decisions. They are not, from the outside: you type two names because you want two databases. Worse,
|
||||
* Create keyed off the SAVED names, so on a fresh screen with both fields filled it did not appear at
|
||||
* all, and the hint explaining why was behind the same condition. Two filled fields, one Save button,
|
||||
* and no way to tell what came next.
|
||||
*
|
||||
* ── It reports what is THERE, per name ──
|
||||
* So one button that records the names and creates whatever is not there, labelled with whichever of
|
||||
* those it is actually about to do. Recording the name you just used to make a database is not a side
|
||||
* effect worth a second click.
|
||||
*
|
||||
* A tick or a dashed circle beside each field, from `pg_database` rather than from what was saved. The
|
||||
* useful question about a database name is not whether somebody typed it — it is whether the thing it
|
||||
* names exists, and those two came apart the moment saving stopped creating.
|
||||
* ── It reports what is THERE, against what is TYPED ──
|
||||
*
|
||||
* ── Create only offers itself when there is something to create ──
|
||||
*
|
||||
* Both present and the button goes. A button that does nothing is a thing to wonder about later.
|
||||
* The tick beside each field asks `pg_database` whether a database of that name exists, and it asks
|
||||
* about the text in the box rather than the text that was last saved. That is the question somebody
|
||||
* typing a name actually has, and answering it about a different name would be worse than silence.
|
||||
*/
|
||||
|
||||
type Named = { name: string | null; exists: boolean | null };
|
||||
@@ -60,13 +61,16 @@ export const DatabaseBlock = () => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const save = async () => {
|
||||
/** Records the names and creates whichever is missing. The server does both, in that order. */
|
||||
const apply = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
setStatus(
|
||||
await clientRef.current.put<Status>('/fofos-admin/_config/databases', { production, development }),
|
||||
const result = await clientRef.current.post<{ created: string[]; status: Status }>(
|
||||
'/fofos-admin/_config/databases/create',
|
||||
{ production, development },
|
||||
);
|
||||
setStatus(result.status);
|
||||
flash();
|
||||
} catch (failure) {
|
||||
setError(readableError(failure));
|
||||
@@ -75,33 +79,11 @@ export const DatabaseBlock = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const create = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await clientRef.current.post<{ created: string[]; status: Status }>(
|
||||
'/fofos-admin/_config/databases/create',
|
||||
{},
|
||||
);
|
||||
setStatus(result.status);
|
||||
} catch (failure) {
|
||||
setError(readableError(failure));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const field = (
|
||||
label: string,
|
||||
hint: string,
|
||||
value: string,
|
||||
onChange: (next: string) => void,
|
||||
state: Named | undefined,
|
||||
) => {
|
||||
// Only about what is SAVED. A tick next to a name somebody is still typing would be about a
|
||||
// different database from the one on screen.
|
||||
const settled = state?.name === value.trim() ? state : undefined;
|
||||
const Icon = settled?.exists ? CircleCheck : CircleDashed;
|
||||
const field = (label: string, hint: string, value: string, onChange: (next: string) => void) => {
|
||||
// Against the typed text and the real list from `pg_database`. Nothing to say about an empty box.
|
||||
const typed = value.trim();
|
||||
const exists = typed ? (status?.available.includes(typed) ?? false) : null;
|
||||
const Icon = exists ? CircleCheck : CircleDashed;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
@@ -119,12 +101,12 @@ export const DatabaseBlock = () => {
|
||||
spellCheck={false}
|
||||
/>
|
||||
<span className="flex w-24 shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
||||
{settled ? (
|
||||
{exists === null ? null : (
|
||||
<>
|
||||
<Icon className={`size-3.5 ${settled.exists ? 'text-success' : ''}`} />
|
||||
{settled.exists ? 'exists' : 'not yet'}
|
||||
<Icon className={`size-3.5 ${exists ? 'text-success' : ''}`} />
|
||||
{exists ? 'exists' : 'not yet'}
|
||||
</>
|
||||
) : null}
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,8 +116,12 @@ export const DatabaseBlock = () => {
|
||||
const dirty =
|
||||
production.trim() !== (status?.production.name ?? '') || development.trim() !== (status?.development.name ?? '');
|
||||
|
||||
// From the SAVED names, not the typed ones — Create acts on what is stored.
|
||||
const missing = [status?.production, status?.development].filter((entry) => entry?.name && entry.exists === false);
|
||||
// Typed, non-empty, and not on the instance. What the one button is about to make.
|
||||
const missing = [production.trim(), development.trim()].filter(
|
||||
(name) => name && !(status?.available.includes(name) ?? false),
|
||||
);
|
||||
|
||||
const label = missing.length ? `Create ${missing.length === 1 ? missing[0] : 'databases'}` : 'Save names';
|
||||
|
||||
return (
|
||||
<Block
|
||||
@@ -153,30 +139,27 @@ export const DatabaseBlock = () => {
|
||||
>
|
||||
{!status ? null : (
|
||||
<>
|
||||
{field('Production', 'what the live site reads', production, setProduction, status.production)}
|
||||
{field('Development', 'what you work against', development, setDevelopment, status.development)}
|
||||
{field('Production', 'what the live site reads', production, setProduction)}
|
||||
{field('Development', 'what you work against', development, setDevelopment)}
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
|
||||
<Button size="sm" onClick={() => void save()} disabled={busy || !dirty}>
|
||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
||||
Save
|
||||
</Button>
|
||||
|
||||
{/* Only when a saved name has nothing behind it. Nothing to create, no button. */}
|
||||
{missing.length > 0 && (
|
||||
<Button variant="outline" size="sm" onClick={() => void create()} disabled={busy || dirty}>
|
||||
<div className="flex justify-end border-t border-border pt-3">
|
||||
{/* Nothing missing and nothing changed means there is genuinely nothing to press. */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void apply()}
|
||||
disabled={busy || (!missing.length && !dirty) || !production.trim() || !development.trim()}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : missing.length ? (
|
||||
<Plus className="size-3.5" />
|
||||
Create {missing.length === 1 ? missing[0]!.name : 'both'}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Save first. Create acts on what is stored, and a button that quietly used a different
|
||||
value from the one in the field would be the worst kind of surprise here. */}
|
||||
{missing.length > 0 && dirty && (
|
||||
<span className="text-xs text-muted-foreground/70">Save the names first.</span>
|
||||
)}
|
||||
) : (
|
||||
<Check className="size-3.5" />
|
||||
)}
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user