feat: auto-save dirty form guard with field-error highlighting

This commit is contained in:
Falkan
2026-03-17 19:56:39 -04:00
parent 15b763e062
commit c1279c3757

View File

@@ -18,10 +18,14 @@
// Unit form state // Unit form state
let unitForm = $state<Partial<Unit>>({}); let unitForm = $state<Partial<Unit>>({});
let unitFormNew = $state(false); let unitFormNew = $state(false);
let unitFormSnapshot = $state<Partial<Unit>>({});
let unitFieldError = $state<string | null>(null); // which field has error
// Group form state // Group form state
let groupForm = $state<Partial<Group>>({}); let groupForm = $state<Partial<Group>>({});
let groupFormNew = $state(false); let groupFormNew = $state(false);
let groupFormSnapshot = $state<Partial<Group>>({});
let groupFieldError = $state<string | null>(null); // which field has error
// ── Load data ──────────────────────────────────────────────────────────────── // ── Load data ────────────────────────────────────────────────────────────────
async function loadData() { async function loadData() {
@@ -58,8 +62,11 @@
function selectUnit(unit: Unit) { function selectUnit(unit: Unit) {
selectedItem = { type: 'unit', id: unit.id }; selectedItem = { type: 'unit', id: unit.id };
unitForm = { ...unit }; unitForm = { ...unit };
unitFormSnapshot = { ...unit };
unitFormNew = false; unitFormNew = false;
groupFormNew = false; groupFormNew = false;
unitFieldError = null;
groupFieldError = null;
formError = null; formError = null;
formSuccess = null; formSuccess = null;
} }
@@ -67,26 +74,147 @@
function selectGroup(group: Group) { function selectGroup(group: Group) {
selectedItem = { type: 'group', id: group.id }; selectedItem = { type: 'group', id: group.id };
groupForm = { ...group }; groupForm = { ...group };
groupFormSnapshot = { ...group };
groupFormNew = false; groupFormNew = false;
unitFormNew = false; unitFormNew = false;
unitFieldError = null;
groupFieldError = null;
formError = null; formError = null;
formSuccess = null; formSuccess = null;
} }
function startNewUnit(groupId?: string) { // ── Dirty checks ─────────────────────────────────────────────────────────────
function isUnitDirty(): boolean {
if (!unitFormNew && selectedItem === null) return false;
const s = unitFormSnapshot;
return (
unitForm.label !== s.label ||
unitForm.labelPlural !== s.labelPlural ||
unitForm.symbol !== s.symbol ||
unitForm.toBase !== s.toBase ||
unitForm.group !== s.group ||
(unitForm.description ?? '') !== (s.description ?? '')
);
}
function isGroupDirty(): boolean {
if (!groupFormNew && selectedItem === null) return false;
const s = groupFormSnapshot;
return (
groupForm.label !== s.label ||
groupForm.id !== s.id
);
}
// ── Validate unit form ───────────────────────────────────────────────────────
function validateUnitForm(): { valid: boolean; firstError: string | null } {
const f = unitForm;
if (!f.id || !/^[a-z][a-z0-9-]*$/.test(f.id)) return { valid: false, firstError: 'unit-id' };
if (!f.label) return { valid: false, firstError: 'unit-label' };
if (!f.labelPlural) return { valid: false, firstError: 'unit-labelPlural' };
if (!f.symbol) return { valid: false, firstError: 'unit-symbol' };
if (typeof f.toBase !== 'number' || f.toBase <= 0) return { valid: false, firstError: 'unit-toBase' };
return { valid: true, firstError: null };
}
function validateGroupForm(): { valid: boolean; firstError: string | null } {
const f = groupForm;
if (!f.id || !/^[a-z][a-z0-9-]*$/.test(f.id)) return { valid: false, firstError: 'group-id' };
if (!f.label) return { valid: false, firstError: 'group-label' };
return { valid: true, firstError: null };
}
// ── Auto-save current form if dirty ──────────────────────────────────────────
async function autoSaveCurrentForm(): Promise<boolean> {
if (unitFormNew || (selectedItem?.type === 'unit')) {
if (!isUnitDirty()) return true;
const v = validateUnitForm();
if (!v.valid) {
formError = 'Please complete the current form before opening a new one.';
unitFieldError = v.firstError;
return false;
}
try {
const res = unitFormNew
? await fetch('/admin/api/units', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(unitForm)
})
: await fetch(`/admin/api/units/${selectedItem?.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(unitForm)
});
if (!res.ok) {
formError = await extractError(res, 'Auto-save failed');
return false;
}
await loadData();
return true;
} catch {
formError = 'Network error during auto-save';
return false;
}
}
if (groupFormNew || (selectedItem?.type === 'group')) {
if (!isGroupDirty()) return true;
const v = validateGroupForm();
if (!v.valid) {
formError = 'Please complete the current form before opening a new one.';
groupFieldError = v.firstError;
return false;
}
try {
const res = groupFormNew
? await fetch('/admin/api/groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(groupForm)
})
: await fetch(`/admin/api/groups/${selectedItem?.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(groupForm)
});
if (!res.ok) {
formError = await extractError(res, 'Auto-save failed');
return false;
}
await loadData();
return true;
} catch {
formError = 'Network error during auto-save';
return false;
}
}
return true;
}
async function startNewUnit(groupId?: string) {
const ok = await autoSaveCurrentForm();
if (!ok) return;
selectedItem = null; selectedItem = null;
unitForm = { group: groupId ?? null, toBase: 1, symbol: '', label: '', labelPlural: '', id: '' }; unitForm = { group: groupId ?? null, toBase: 1, symbol: '', label: '', labelPlural: '', id: '' };
unitFormSnapshot = { group: groupId ?? null, toBase: 1, symbol: '', label: '', labelPlural: '', id: '' };
unitFormNew = true; unitFormNew = true;
groupFormNew = false; groupFormNew = false;
unitFieldError = null;
groupFieldError = null;
formError = null; formError = null;
formSuccess = null; formSuccess = null;
} }
function startNewGroup() { async function startNewGroup() {
const ok = await autoSaveCurrentForm();
if (!ok) return;
selectedItem = null; selectedItem = null;
groupForm = { id: '', label: '', baseUnitId: '', toUniversal: 1 }; groupForm = { id: '', label: '', baseUnitId: '', toUniversal: 1 };
groupFormSnapshot = { id: '', label: '', baseUnitId: '', toUniversal: 1 };
groupFormNew = true; groupFormNew = true;
unitFormNew = false; unitFormNew = false;
unitFieldError = null;
groupFieldError = null;
formError = null; formError = null;
formSuccess = null; formSuccess = null;
} }
@@ -149,6 +277,13 @@
async function saveUnit() { async function saveUnit() {
formError = null; formError = null;
formSuccess = null; formSuccess = null;
unitFieldError = null;
const v = validateUnitForm();
if (!v.valid) {
formError = 'Please fix the highlighted field.';
unitFieldError = v.firstError;
return;
}
try { try {
const res = unitFormNew const res = unitFormNew
? await fetch('/admin/api/units', { ? await fetch('/admin/api/units', {
@@ -167,6 +302,10 @@
const created = (await res.json()) as Unit; const created = (await res.json()) as Unit;
unitFormNew = false; unitFormNew = false;
selectedItem = { type: 'unit', id: created.id ?? '' }; selectedItem = { type: 'unit', id: created.id ?? '' };
unitForm = { ...created };
unitFormSnapshot = { ...created };
} else {
unitFormSnapshot = { ...unitForm };
} }
await loadData(); await loadData();
} catch { formError = 'Network error'; } } catch { formError = 'Network error'; }
@@ -188,6 +327,13 @@
async function saveGroup() { async function saveGroup() {
formError = null; formError = null;
formSuccess = null; formSuccess = null;
groupFieldError = null;
const v = validateGroupForm();
if (!v.valid) {
formError = 'Please fix the highlighted field.';
groupFieldError = v.firstError;
return;
}
try { try {
const res = groupFormNew const res = groupFormNew
? await fetch('/admin/api/groups', { ? await fetch('/admin/api/groups', {
@@ -206,6 +352,10 @@
const created = (await res.json()) as Group; const created = (await res.json()) as Group;
groupFormNew = false; groupFormNew = false;
selectedItem = { type: 'group', id: created.id ?? '' }; selectedItem = { type: 'group', id: created.id ?? '' };
groupForm = { ...created };
groupFormSnapshot = { ...created };
} else {
groupFormSnapshot = { ...groupForm };
} }
await loadData(); await loadData();
} catch { formError = 'Network error'; } } catch { formError = 'Network error'; }
@@ -434,19 +584,23 @@
<h3>{unitFormNew ? 'New Unit' : 'Edit Unit'}</h3> <h3>{unitFormNew ? 'New Unit' : 'Edit Unit'}</h3>
<label> <label>
ID {#if !unitFormNew}<small class="muted">(readonly)</small>{/if} ID {#if !unitFormNew}<small class="muted">(readonly)</small>{/if}
<input type="text" bind:value={unitForm.id} readonly={!unitFormNew} placeholder="e.g. my-unit" /> <input type="text" bind:value={unitForm.id} readonly={!unitFormNew} placeholder="e.g. my-unit"
class:field-error={unitFieldError === 'unit-id'} />
</label> </label>
<label> <label>
Label Label
<input type="text" bind:value={unitForm.label} placeholder="e.g. My Unit" /> <input type="text" bind:value={unitForm.label} placeholder="e.g. My Unit"
class:field-error={unitFieldError === 'unit-label'} />
</label> </label>
<label> <label>
Label (plural) Label (plural)
<input type="text" bind:value={unitForm.labelPlural} placeholder="e.g. My Units" /> <input type="text" bind:value={unitForm.labelPlural} placeholder="e.g. My Units"
class:field-error={unitFieldError === 'unit-labelPlural'} />
</label> </label>
<label> <label>
Symbol Symbol
<input type="text" bind:value={unitForm.symbol} placeholder="e.g. mu" /> <input type="text" bind:value={unitForm.symbol} placeholder="e.g. mu"
class:field-error={unitFieldError === 'unit-symbol'} />
</label> </label>
<label> <label>
Group Group
@@ -459,7 +613,8 @@
</label> </label>
<label> <label>
To Base To Base
<input type="number" bind:value={unitForm.toBase} min="0.000001" step="any" /> <input type="number" bind:value={unitForm.toBase} min="0.000001" step="any"
class:field-error={unitFieldError === 'unit-toBase'} />
{#if toBaseHint} {#if toBaseHint}
<small class="tobase-hint">{toBaseHint}</small> <small class="tobase-hint">{toBaseHint}</small>
{/if} {/if}
@@ -479,11 +634,13 @@
<h3>{groupFormNew ? 'New Group' : 'Edit Group'}</h3> <h3>{groupFormNew ? 'New Group' : 'Edit Group'}</h3>
<label> <label>
ID {#if !groupFormNew}<small class="muted">(readonly)</small>{/if} ID {#if !groupFormNew}<small class="muted">(readonly)</small>{/if}
<input type="text" bind:value={groupForm.id} readonly={!groupFormNew} placeholder="e.g. my-group" /> <input type="text" bind:value={groupForm.id} readonly={!groupFormNew} placeholder="e.g. my-group"
class:field-error={groupFieldError === 'group-id'} />
</label> </label>
<label> <label>
Label Label
<input type="text" bind:value={groupForm.label} placeholder="e.g. My Group" /> <input type="text" bind:value={groupForm.label} placeholder="e.g. My Group"
class:field-error={groupFieldError === 'group-label'} />
</label> </label>
<label> <label>
Base Unit Base Unit
@@ -792,6 +949,9 @@
} }
.form-error { color: var(--pico-del-color, #e74c3c); background: rgba(231,76,60,0.1); } .form-error { color: var(--pico-del-color, #e74c3c); background: rgba(231,76,60,0.1); }
.form-success { color: var(--pico-ins-color, #2ecc71); background: rgba(46,204,113,0.1); } .form-success { color: var(--pico-ins-color, #2ecc71); background: rgba(46,204,113,0.1); }
.field-error {
border-color: var(--pico-form-element-invalid-border-color) !important;
}
.tobase-hint { .tobase-hint {
display: block; display: block;
color: var(--pico-muted-color); color: var(--pico-muted-color);