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:
+19
-1
@@ -141,7 +141,25 @@ export async function saveDatabases(production: string, development: string): Pr
|
|||||||
* a name that failed for a different reason, and reporting "created" about a database that is not
|
* a name that failed for a different reason, and reporting "created" about a database that is not
|
||||||
* there is the one outcome worth engineering against.
|
* there is the one outcome worth engineering against.
|
||||||
*/
|
*/
|
||||||
export async function createDatabases(): Promise<{ created: string[]; status: DatabasesStatus }> {
|
export async function createDatabases(names?: {
|
||||||
|
production?: string;
|
||||||
|
development?: string;
|
||||||
|
}): Promise<{ created: string[]; status: DatabasesStatus }> {
|
||||||
|
/*
|
||||||
|
Names may arrive with the request, and then they are SAVED before anything is created.
|
||||||
|
|
||||||
|
Because otherwise Create acts on stored names while the person is looking at typed ones, and the
|
||||||
|
two can differ. Making them press Save first was the version of this that shipped, and it left a
|
||||||
|
screen with two filled fields, no Create button and nothing explaining why — the button keyed off
|
||||||
|
the saved names, which were still empty.
|
||||||
|
|
||||||
|
Recording the name you just used to create a database is not a side effect worth hiding behind a
|
||||||
|
second click.
|
||||||
|
*/
|
||||||
|
if (names?.production !== undefined || names?.development !== undefined) {
|
||||||
|
await saveDatabases(names.production ?? '', names.development ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
const status = await readDatabases();
|
const status = await readDatabases();
|
||||||
const wanted = [status.production.name, status.development.name].filter((name): name is string => Boolean(name));
|
const wanted = [status.production.name, status.development.name].filter((name): name is string => Boolean(name));
|
||||||
if (!wanted.length) throw new Error('Name the databases before creating them.');
|
if (!wanted.length) throw new Error('Name the databases before creating them.');
|
||||||
|
|||||||
+8
-14
@@ -8,7 +8,7 @@ import { readMediaStatus, createMediaDir } from './media';
|
|||||||
import { readSync, startSync } from './sync';
|
import { readSync, startSync } from './sync';
|
||||||
import { readRuns } from './ledger';
|
import { readRuns } from './ledger';
|
||||||
import { reconcile } from './derive';
|
import { reconcile } from './derive';
|
||||||
import { createDatabases, readDatabases, saveDatabases } from './databases';
|
import { createDatabases, readDatabases } from './databases';
|
||||||
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
|
import { fofosRequest, FofosError, probe, type FofosResponse } from './fofos-client';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -287,24 +287,18 @@ router.get('/_config/media/sync', async (ctx) => {
|
|||||||
/**
|
/**
|
||||||
* The two databases the site reads. Owner-only, like everything else under `/_config`.
|
* The two databases the site reads. Owner-only, like everything else under `/_config`.
|
||||||
*
|
*
|
||||||
* Saving and creating are separate routes because they are separate decisions: a name can be stored
|
* ONE write route, not two. There was a `PUT` that only stored the names, on the reasoning that naming
|
||||||
* for a database that already exists and needs no creating, and pressing Create must never be the only
|
* and creating are separate decisions — but the panel has one button, so nothing called it, and a
|
||||||
* way to record what you typed.
|
* second way to write the same two columns is a second thing that can drift from the first.
|
||||||
*/
|
*/
|
||||||
router.get('/_config/databases', async (ctx) => ctx.json(await readDatabases()));
|
router.get('/_config/databases', async (ctx) => ctx.json(await readDatabases()));
|
||||||
|
|
||||||
router.put('/_config/databases', async (ctx) => {
|
router.post('/_config/databases/create', async (ctx) => {
|
||||||
|
// The names come with the request, so what gets created is what is on screen rather than what was
|
||||||
|
// stored at some earlier point. They are saved first, by the same validation as the PUT above.
|
||||||
const { production, development } = (ctx.get('body') ?? {}) as { production?: string; development?: string };
|
const { production, development } = (ctx.get('body') ?? {}) as { production?: string; development?: string };
|
||||||
try {
|
try {
|
||||||
return ctx.json(await saveDatabases(production ?? '', development ?? ''));
|
return ctx.json(await createDatabases({ production, development }));
|
||||||
} catch (error) {
|
|
||||||
return ctx.json({ error: (error as Error).message }, 400);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/_config/databases/create', async (ctx) => {
|
|
||||||
try {
|
|
||||||
return ctx.json(await createDatabases());
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return ctx.json({ error: (error as Error).message }, 400);
|
return ctx.json({ error: (error as Error).message }, 400);
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-68
@@ -10,22 +10,23 @@ import { useFlash } from './useFlash';
|
|||||||
/**
|
/**
|
||||||
* Where the fofos site's own data lives — two databases, named here.
|
* 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
|
* It shipped with two — Save, then Create — on the reasoning that naming and creating are different
|
||||||
* decisions and were worth two buttons: a database that already exists still needs its name recorded,
|
* decisions. They are not, from the outside: you type two names because you want two databases. Worse,
|
||||||
* and making Create the only way to save would mean nobody could point the site at something that was
|
* Create keyed off the SAVED names, so on a fresh screen with both fields filled it did not appear at
|
||||||
* already set up.
|
* 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
|
* ── It reports what is THERE, against what is TYPED ──
|
||||||
* 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.
|
|
||||||
*
|
*
|
||||||
* ── Create only offers itself when there is something to create ──
|
* 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
|
||||||
* Both present and the button goes. A button that does nothing is a thing to wonder about later.
|
* 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 };
|
type Named = { name: string | null; exists: boolean | null };
|
||||||
@@ -60,13 +61,16 @@ export const DatabaseBlock = () => {
|
|||||||
void load();
|
void load();
|
||||||
}, [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);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
setStatus(
|
const result = await clientRef.current.post<{ created: string[]; status: Status }>(
|
||||||
await clientRef.current.put<Status>('/fofos-admin/_config/databases', { production, development }),
|
'/fofos-admin/_config/databases/create',
|
||||||
|
{ production, development },
|
||||||
);
|
);
|
||||||
|
setStatus(result.status);
|
||||||
flash();
|
flash();
|
||||||
} catch (failure) {
|
} catch (failure) {
|
||||||
setError(readableError(failure));
|
setError(readableError(failure));
|
||||||
@@ -75,33 +79,11 @@ export const DatabaseBlock = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const create = async () => {
|
const field = (label: string, hint: string, value: string, onChange: (next: string) => void) => {
|
||||||
setBusy(true);
|
// Against the typed text and the real list from `pg_database`. Nothing to say about an empty box.
|
||||||
setError(null);
|
const typed = value.trim();
|
||||||
try {
|
const exists = typed ? (status?.available.includes(typed) ?? false) : null;
|
||||||
const result = await clientRef.current.post<{ created: string[]; status: Status }>(
|
const Icon = exists ? CircleCheck : CircleDashed;
|
||||||
'/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;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -119,12 +101,12 @@ export const DatabaseBlock = () => {
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
/>
|
/>
|
||||||
<span className="flex w-24 shrink-0 items-center gap-1 text-xs text-muted-foreground">
|
<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' : ''}`} />
|
<Icon className={`size-3.5 ${exists ? 'text-success' : ''}`} />
|
||||||
{settled.exists ? 'exists' : 'not yet'}
|
{exists ? 'exists' : 'not yet'}
|
||||||
</>
|
</>
|
||||||
) : null}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,8 +116,12 @@ export const DatabaseBlock = () => {
|
|||||||
const dirty =
|
const dirty =
|
||||||
production.trim() !== (status?.production.name ?? '') || development.trim() !== (status?.development.name ?? '');
|
production.trim() !== (status?.production.name ?? '') || development.trim() !== (status?.development.name ?? '');
|
||||||
|
|
||||||
// From the SAVED names, not the typed ones — Create acts on what is stored.
|
// Typed, non-empty, and not on the instance. What the one button is about to make.
|
||||||
const missing = [status?.production, status?.development].filter((entry) => entry?.name && entry.exists === false);
|
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 (
|
return (
|
||||||
<Block
|
<Block
|
||||||
@@ -153,30 +139,27 @@ export const DatabaseBlock = () => {
|
|||||||
>
|
>
|
||||||
{!status ? null : (
|
{!status ? null : (
|
||||||
<>
|
<>
|
||||||
{field('Production', 'what the live site reads', production, setProduction, status.production)}
|
{field('Production', 'what the live site reads', production, setProduction)}
|
||||||
{field('Development', 'what you work against', development, setDevelopment, status.development)}
|
{field('Development', 'what you work against', development, setDevelopment)}
|
||||||
|
|
||||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 border-t border-border pt-3">
|
<div className="flex justify-end border-t border-border pt-3">
|
||||||
<Button size="sm" onClick={() => void save()} disabled={busy || !dirty}>
|
{/* Nothing missing and nothing changed means there is genuinely nothing to press. */}
|
||||||
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Check className="size-3.5" />}
|
<Button
|
||||||
Save
|
size="sm"
|
||||||
</Button>
|
onClick={() => void apply()}
|
||||||
|
disabled={busy || (!missing.length && !dirty) || !production.trim() || !development.trim()}
|
||||||
{/* Only when a saved name has nothing behind it. Nothing to create, no button. */}
|
>
|
||||||
{missing.length > 0 && (
|
{busy ? (
|
||||||
<Button variant="outline" size="sm" onClick={() => void create()} disabled={busy || dirty}>
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
|
) : missing.length ? (
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
Create {missing.length === 1 ? missing[0]!.name : 'both'}
|
) : (
|
||||||
</Button>
|
<Check className="size-3.5" />
|
||||||
)}
|
)}
|
||||||
|
{label}
|
||||||
{/* Save first. Create acts on what is stored, and a button that quietly used a different
|
</Button>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user