feat: auto-save dirty form guard with field-error highlighting
This commit is contained in:
@@ -18,10 +18,14 @@
|
||||
// Unit form state
|
||||
let unitForm = $state<Partial<Unit>>({});
|
||||
let unitFormNew = $state(false);
|
||||
let unitFormSnapshot = $state<Partial<Unit>>({});
|
||||
let unitFieldError = $state<string | null>(null); // which field has error
|
||||
|
||||
// Group form state
|
||||
let groupForm = $state<Partial<Group>>({});
|
||||
let groupFormNew = $state(false);
|
||||
let groupFormSnapshot = $state<Partial<Group>>({});
|
||||
let groupFieldError = $state<string | null>(null); // which field has error
|
||||
|
||||
// ── Load data ────────────────────────────────────────────────────────────────
|
||||
async function loadData() {
|
||||
@@ -58,8 +62,11 @@
|
||||
function selectUnit(unit: Unit) {
|
||||
selectedItem = { type: 'unit', id: unit.id };
|
||||
unitForm = { ...unit };
|
||||
unitFormSnapshot = { ...unit };
|
||||
unitFormNew = false;
|
||||
groupFormNew = false;
|
||||
unitFieldError = null;
|
||||
groupFieldError = null;
|
||||
formError = null;
|
||||
formSuccess = null;
|
||||
}
|
||||
@@ -67,26 +74,147 @@
|
||||
function selectGroup(group: Group) {
|
||||
selectedItem = { type: 'group', id: group.id };
|
||||
groupForm = { ...group };
|
||||
groupFormSnapshot = { ...group };
|
||||
groupFormNew = false;
|
||||
unitFormNew = false;
|
||||
unitFieldError = null;
|
||||
groupFieldError = null;
|
||||
formError = 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;
|
||||
unitForm = { group: groupId ?? null, toBase: 1, symbol: '', label: '', labelPlural: '', id: '' };
|
||||
unitFormSnapshot = { group: groupId ?? null, toBase: 1, symbol: '', label: '', labelPlural: '', id: '' };
|
||||
unitFormNew = true;
|
||||
groupFormNew = false;
|
||||
unitFieldError = null;
|
||||
groupFieldError = null;
|
||||
formError = null;
|
||||
formSuccess = null;
|
||||
}
|
||||
|
||||
function startNewGroup() {
|
||||
async function startNewGroup() {
|
||||
const ok = await autoSaveCurrentForm();
|
||||
if (!ok) return;
|
||||
selectedItem = null;
|
||||
groupForm = { id: '', label: '', baseUnitId: '', toUniversal: 1 };
|
||||
groupFormSnapshot = { id: '', label: '', baseUnitId: '', toUniversal: 1 };
|
||||
groupFormNew = true;
|
||||
unitFormNew = false;
|
||||
unitFieldError = null;
|
||||
groupFieldError = null;
|
||||
formError = null;
|
||||
formSuccess = null;
|
||||
}
|
||||
@@ -149,6 +277,13 @@
|
||||
async function saveUnit() {
|
||||
formError = null;
|
||||
formSuccess = null;
|
||||
unitFieldError = null;
|
||||
const v = validateUnitForm();
|
||||
if (!v.valid) {
|
||||
formError = 'Please fix the highlighted field.';
|
||||
unitFieldError = v.firstError;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = unitFormNew
|
||||
? await fetch('/admin/api/units', {
|
||||
@@ -167,6 +302,10 @@
|
||||
const created = (await res.json()) as Unit;
|
||||
unitFormNew = false;
|
||||
selectedItem = { type: 'unit', id: created.id ?? '' };
|
||||
unitForm = { ...created };
|
||||
unitFormSnapshot = { ...created };
|
||||
} else {
|
||||
unitFormSnapshot = { ...unitForm };
|
||||
}
|
||||
await loadData();
|
||||
} catch { formError = 'Network error'; }
|
||||
@@ -188,6 +327,13 @@
|
||||
async function saveGroup() {
|
||||
formError = null;
|
||||
formSuccess = null;
|
||||
groupFieldError = null;
|
||||
const v = validateGroupForm();
|
||||
if (!v.valid) {
|
||||
formError = 'Please fix the highlighted field.';
|
||||
groupFieldError = v.firstError;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = groupFormNew
|
||||
? await fetch('/admin/api/groups', {
|
||||
@@ -206,6 +352,10 @@
|
||||
const created = (await res.json()) as Group;
|
||||
groupFormNew = false;
|
||||
selectedItem = { type: 'group', id: created.id ?? '' };
|
||||
groupForm = { ...created };
|
||||
groupFormSnapshot = { ...created };
|
||||
} else {
|
||||
groupFormSnapshot = { ...groupForm };
|
||||
}
|
||||
await loadData();
|
||||
} catch { formError = 'Network error'; }
|
||||
@@ -434,19 +584,23 @@
|
||||
<h3>{unitFormNew ? 'New Unit' : 'Edit Unit'}</h3>
|
||||
<label>
|
||||
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
|
||||
<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 (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>
|
||||
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>
|
||||
Group
|
||||
@@ -459,7 +613,8 @@
|
||||
</label>
|
||||
<label>
|
||||
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}
|
||||
<small class="tobase-hint">{toBaseHint}</small>
|
||||
{/if}
|
||||
@@ -479,11 +634,13 @@
|
||||
<h3>{groupFormNew ? 'New Group' : 'Edit Group'}</h3>
|
||||
<label>
|
||||
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
|
||||
<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>
|
||||
Base Unit
|
||||
@@ -792,6 +949,9 @@
|
||||
}
|
||||
.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); }
|
||||
.field-error {
|
||||
border-color: var(--pico-form-element-invalid-border-color) !important;
|
||||
}
|
||||
.tobase-hint {
|
||||
display: block;
|
||||
color: var(--pico-muted-color);
|
||||
|
||||
Reference in New Issue
Block a user