Disable dataset Save until the form is valid (§05)

This commit is contained in:
2026-06-07 18:45:08 +03:00
parent c7306929cc
commit a62e2c6128
3 changed files with 108 additions and 23 deletions
+49 -20
View File
@@ -91,40 +91,59 @@ export function byModifiedDesc(a: Dataset, b: Dataset): number {
return b.modified.localeCompare(a.modified);
}
/**
* Cheap form validation: returns an error message, or `null` when the form is
* saveable. Deliberately does **not** `JSON.parse` the input — it only checks
* name/input presence, name uniqueness, and that a format is detectable — so it
* is safe to run on every render to drive the Save button's disabled state
* (spec §05). Error copy follows the council resolution (docs/architecture/10
* §error copy → GOV.UK error-message): action-oriented, specific, says how to fix.
*/
function validateForm(
form: DatasetForm,
datasets: Dataset[],
excludeId: number | undefined,
): string | null {
const name = form.name.trim();
if (name === '') return 'Enter a dataset name.';
if (isNameTaken(name, datasets, excludeId)) {
return `A dataset named "${name}" already exists. Choose a different name.`;
}
const input = form.input.trim();
if (input === '') {
return form.source === 'url' ? 'Enter a URL.' : 'Paste JSON, CSV, or TSV data to save.';
}
if (form.source === 'url') {
if (!/^https?:\/\//i.test(input)) return 'Enter a URL starting with http:// or https://.';
return null;
}
// Inline: the data must auto-detect to a known format (spec §05 → Auto-detection).
if (!detectFormat(form.input).format) return 'Enter valid JSON, CSV, or TSV data.';
return null;
}
/** Validate a form into a saveable shape, or return an error message. */
function resolveForm(
form: DatasetForm,
datasets: Dataset[],
excludeId: number | undefined,
): { name: string; data: unknown; format: DataFormat; source: DataSource } | { error: string } {
// Error copy follows the council resolution (docs/architecture/10 §error copy →
// GOV.UK error-message): action-oriented, specific, and says how to fix it.
const name = form.name.trim();
if (name === '') return { error: 'Enter a dataset name.' };
if (isNameTaken(name, datasets, excludeId)) {
return { error: `A dataset named "${name}" already exists. Choose a different name.` };
}
const error = validateForm(form, datasets, excludeId);
if (error) return { error };
const name = form.name.trim();
const input = form.input.trim();
if (input === '') {
return {
error: form.source === 'url' ? 'Enter a URL.' : 'Paste JSON, CSV, or TSV data to save.',
};
}
if (form.source === 'url') {
if (!/^https?:\/\//i.test(input)) {
return { error: 'Enter a URL starting with http:// or https://.' };
}
// Format is inferred from the extension; default to JSON when unknown (§05).
return { name, data: input, format: detectFormatFromUrl(input) ?? 'json', source: 'url' };
}
// Inline: the data must auto-detect to a known format (spec §05 → Auto-detection).
const { format } = detectFormat(form.input);
if (!format) {
return { error: 'Enter valid JSON, CSV, or TSV data.' };
}
// validateForm guaranteed a detectable format above.
const format = detectFormat(form.input).format as DataFormat;
// JSON/TopoJSON are stored parsed; CSV/TSV keep their raw text (data model §09B).
const data =
format === 'json' || format === 'topojson' ? (JSON.parse(form.input) as unknown) : form.input;
@@ -276,3 +295,13 @@ export const useDatasetStore = create<DatasetState>((set, get) => ({
/** Selector: the selected dataset record, or null. Derive — never store. */
export const selectSelectedDataset = (s: DatasetState): Dataset | null =>
s.datasets.find((d) => d.id === s.selectedId) ?? null;
/**
* Selector: whether the current form is saveable, powering the Save button's
* disabled state (spec §05 → Save is disabled until a name and valid data/URL
* are present). Mirrors the `save()` action's `excludeId` derivation so an edit
* can keep its own name.
*/
export const selectCanSave = (s: DatasetState): boolean =>
validateForm(s.form, s.datasets, s.view === 'edit' ? (s.selectedId ?? undefined) : undefined) ===
null;