refactor(data-store): consolidate into one self-contained edit modal

element_data_store_form.svelte is now the single source of truth for
editing data stores — owns the modal, all form fields, change detection,
save/delete API calls, and IDB cache update.

- element_data_store.svelte: remove ~120 lines of duplicated form/handler
  code; now delegates to AE_DataStore_Form via bind:open + callbacks
- data_stores/+page.svelte: open_edit/open_new are now 2 lines each;
  remove all draft state, submit_status, handle_save/delete; fix label
  a11y (wrapping labels on all filter + bulk-rename inputs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-17 15:54:58 -04:00
parent e321a2c4c5
commit c0c896d87b
3 changed files with 478 additions and 820 deletions

View File

@@ -1,8 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { untrack } from 'svelte';
import { onMount, untrack } from 'svelte';
import { goto } from '$app/navigation';
import { Modal } from 'flowbite-svelte';
import {
ArrowRight,
ArrowUpDown,
@@ -17,9 +15,7 @@ import {
Pencil,
Plus,
RefreshCw,
Save,
Search,
Trash2,
X
} from '@lucide/svelte';
@@ -55,23 +51,6 @@ let searched = $state(false);
// ── Edit modal ────────────────────────────────────────────────────────────────
let show_edit = $state(false);
let editing_obj: ae_DataStore | null = $state(null);
let is_new = $state(false);
let draft_code = $state('');
let draft_name = $state('');
let draft_type = $state('text');
let draft_account_id = $state('');
let draft_for_type = $state('');
let draft_for_id = $state('');
let draft_value = $state('');
let draft_enable = $state(true);
let draft_hide = $state(false);
let draft_priority = $state(false);
let draft_sort = $state('');
let draft_group = $state('');
let draft_notes = $state('');
let submit_status = $state<'idle' | 'processing' | 'saved' | 'error'>('idle');
let has_changes = $state(false); // bound from form component — true when any draft differs from editing_obj
// ── Bulk rename state ─────────────────────────────────────────────────────────
let show_bulk_rename = $state(false);
@@ -145,141 +124,17 @@ function toggle_sort(col: string) {
if (searched) do_search(false);
}
// ── Open edit ─────────────────────────────────────────────────────────────────
// ── Open edit / new ───────────────────────────────────────────────────────────
function open_edit(obj: ae_DataStore) {
editing_obj = obj;
is_new = false;
draft_code = obj.code ?? '';
draft_name = obj.name ?? '';
draft_type = obj.type ?? 'text';
draft_account_id = obj.account_id ?? '';
draft_for_type = obj.for_type ?? '';
draft_for_id = obj.for_id ?? '';
draft_enable = obj.enable !== false;
draft_hide = !!obj.hide;
draft_priority = !!obj.priority;
draft_sort = String(obj.sort ?? '');
draft_group = obj.group ?? '';
draft_notes = obj.notes ?? '';
draft_value =
obj.type === 'json'
? typeof obj.json === 'string'
? obj.json
: JSON.stringify(obj.json ?? '', null, 2)
: (obj.text ?? obj.html ?? '');
submit_status = 'idle';
show_edit = true;
}
function open_new() {
editing_obj = null;
is_new = true;
draft_code = '';
draft_name = '';
draft_type = 'text';
draft_account_id = $ae_loc.account_id ?? '';
draft_for_type = '';
draft_for_id = '';
draft_value = '';
draft_enable = true;
draft_hide = false;
draft_priority = false;
draft_sort = '';
draft_group = '';
draft_notes = '';
submit_status = 'idle';
show_edit = true;
}
// ── Save ──────────────────────────────────────────────────────────────────────
async function handle_save() {
submit_status = 'processing';
const api_cfg = untrack(() => $ae_api);
const fields: Record<string, any> = {
code: draft_code.trim(),
name: draft_name.trim(),
type: draft_type,
account_id: draft_account_id.trim() || null,
for_type: draft_for_type.trim() || null,
enable: draft_enable,
hide: draft_hide,
priority: draft_priority,
sort: draft_sort.trim() || null,
group: draft_group.trim() || null,
notes: draft_notes.trim() || null
};
// for_id can only be meaningfully set on create — backend stores it as an integer FK
// and doesn't accept the random string on PATCH.
if (is_new && draft_for_id.trim()) {
fields.for_id = draft_for_id.trim();
}
if (draft_type === 'json') {
try { fields.json = JSON.parse(draft_value); }
catch { fields.json = draft_value; }
} else {
fields.text = draft_value;
}
let result: any;
if (is_new) {
result = await api.create_ae_obj({ api_cfg, obj_type: 'data_store', fields });
} else {
result = await api.update_ae_obj({
api_cfg,
obj_type: 'data_store',
obj_id: editing_obj!.id,
fields
});
}
if (result) {
const ds_id = result.data_store_id || result.id;
if (ds_id) {
const text_val = result.text ?? '';
await db_core.data_store.put({
...result,
id: ds_id,
data_store_id: ds_id,
updated_on: result.updated_on ?? new Date().toISOString(),
text: text_val,
html: text_val,
json: result.json ?? null
});
}
submit_status = 'saved';
setTimeout(() => {
show_edit = false;
do_search(false);
}, 600);
} else {
submit_status = 'error';
}
}
// ── Delete ────────────────────────────────────────────────────────────────────
async function handle_delete() {
if (
!editing_obj?.id ||
!confirm(`Delete data store "${editing_obj.code}"?\n\nThis cannot be undone.`)
)
return;
const api_cfg = untrack(() => $ae_api);
const result = await api.delete_ae_obj({
api_cfg,
obj_type: 'data_store',
obj_id: editing_obj.id,
method: 'delete'
});
if (result) {
await db_core.data_store.delete(editing_obj.id);
show_edit = false;
do_search(false);
}
}
// ── Bulk rename ───────────────────────────────────────────────────────────────
async function do_rename_preview() {
if (!rename_filter.trim()) return;
@@ -401,69 +256,69 @@ function content_preview(ds: ae_DataStore): string {
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="space-y-1">
<label class="text-xs font-bold opacity-70">
Code
<span class="font-normal opacity-60">— use <code>%</code> as wildcard</span>
</label>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">
Code <span class="font-normal opacity-60">— use <code>%</code> as wildcard</span>
</span>
<input
type="search"
bind:value={qry_code}
placeholder="event__pres_mgmt__%"
class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_search()} />
</div>
</label>
<div class="space-y-1">
<label class="text-xs font-bold opacity-70">
Account ID
<span class="font-normal opacity-60"><code>global</code> for null</span>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">
Account ID <span class="font-normal opacity-60"><code>global</code> for null</span>
</span>
<input
type="search"
bind:value={qry_account_id}
placeholder="random ID or 'global'"
class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_search()} />
</label>
<input
type="search"
bind:value={qry_account_id}
placeholder="random ID or 'global'"
class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_search()} />
<p class="text-[10px] opacity-40">Results are scoped to the active account by the API.</p>
</div>
<div class="space-y-1">
<label class="text-xs font-bold opacity-70">For Type</label>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">For Type</span>
<input
type="search"
bind:value={qry_for_type}
placeholder="event, event_session, person…"
class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_search()} />
</div>
<div class="space-y-1">
<label class="text-xs font-bold opacity-70">For ID</label>
</label>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">For ID</span>
<input
type="search"
bind:value={qry_for_id}
placeholder="random string ID"
class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_search()} />
</div>
</label>
</div>
<div class="flex flex-wrap items-center gap-3 pt-1">
<div class="space-y-1">
<label class="text-xs font-bold opacity-70">Status</label>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">Status</span>
<select class="select select-sm text-xs" bind:value={qry_enabled}>
<option value="all">All</option>
<option value="enabled">Enabled</option>
<option value="not_enabled">Disabled</option>
</select>
</div>
<div class="space-y-1">
<label class="text-xs font-bold opacity-70">Per page</label>
</label>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">Per page</span>
<select class="select select-sm text-xs" bind:value={page_limit}>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</div>
</label>
<div class="flex flex-1 items-end justify-end gap-2">
<button
type="button"
@@ -496,34 +351,33 @@ function content_preview(ds: ae_DataStore): string {
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div class="space-y-1">
<label class="text-xs font-bold opacity-70">
Code filter
<span class="font-normal opacity-60">— use <code>%</code> as wildcard</span>
</label>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">
Code filter <span class="font-normal opacity-60">— use <code>%</code> as wildcard</span>
</span>
<input
type="text"
bind:value={rename_filter}
placeholder="event__old_code__%"
class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_rename_preview()} />
</div>
<div class="space-y-1">
<label class="text-xs font-bold opacity-70">Find text in code</label>
</label>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">Find text in code</span>
<input
type="text"
bind:value={rename_find_text}
placeholder="old_code"
class="input input-sm w-full font-mono text-xs" />
</div>
<div class="space-y-1">
<label class="text-xs font-bold opacity-70">Replace with</label>
</label>
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">Replace with</span>
<input
type="text"
bind:value={rename_replace_text}
placeholder="new_code"
class="input input-sm w-full font-mono text-xs" />
</div>
</label>
</div>
<div class="flex items-center gap-2">
@@ -731,78 +585,9 @@ function content_preview(ds: ae_DataStore): string {
</div>
<!-- ── Edit / Create Modal ───────────────────────────────────────────────────── -->
<Modal
title={is_new ? 'New Data Store' : `Edit: ${editing_obj?.code ?? ''}`}
<AE_DataStore_Form
bind:open={show_edit}
autoclose={false}
outsideclose={!has_changes}
size="xl"
class="w-full max-w-5xl">
<form
class="space-y-4"
onsubmit={(e) => { e.preventDefault(); handle_save(); }}>
<!-- {#key} recreates the form on each new record, resetting internal state (html_edit_mode, show_advanced) -->
{#key editing_obj?.id ?? 'new'}
<AE_DataStore_Form
bind:draft_code
bind:draft_name
bind:draft_type
bind:draft_value
bind:draft_account_id
bind:draft_for_type
bind:draft_for_id
bind:draft_enable
bind:draft_hide
bind:draft_priority
bind:draft_sort
bind:draft_group
bind:draft_notes
{is_new}
original_obj={is_new ? null : editing_obj}
bind:has_changes />
{/key}
<!-- Footer actions -->
<div class="flex items-center justify-between border-t border-surface-500/20 pt-4">
{#if !is_new}
<button
type="button"
class="btn preset-tonal-error"
onclick={handle_delete}
title="Permanently delete this data store cannot be undone">
<Trash2 size={14} class="mr-2" /> Delete
</button>
{:else}
<div></div>
{/if}
<div class="flex gap-2">
<button
type="button"
class="btn preset-tonal-surface"
onclick={() => (show_edit = false)}
title="Discard changes and close">
<X size={14} class="mr-2" /> Cancel
</button>
<button
type="submit"
class="btn preset-filled-primary-500"
disabled={!has_changes || submit_status === 'processing'}
title={is_new ? 'Create new data store' : 'Save changes'}>
{#if submit_status === 'processing'}
<LoaderCircle size={14} class="mr-2 animate-spin" />
{:else if submit_status === 'saved'}
<Check size={14} class="mr-2" />
{:else if submit_status === 'error'}
<X size={14} class="mr-2 text-error-500" />
{:else}
<Save size={14} class="mr-2" />
{/if}
{is_new ? 'Create' : 'Save'}
</button>
</div>
</div>
</form>
</Modal>
editing_obj={editing_obj}
default_account_id={$ae_loc.account_id ?? ''}
on_saved={() => do_search(false)}
on_deleted={() => do_search(false)} />