feat: auto-save dirty form guard with field-error highlighting
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user