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,29 +1,14 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { onMount, untrack } from 'svelte'; import { onMount, untrack } from 'svelte';
import { Modal } from 'flowbite-svelte';
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { api } from '$lib/api/api'; import { api } from '$lib/api/api';
import { ae_loc, ae_sess, ae_api, slct } from '$lib/stores/ae_stores'; import { ae_loc, ae_api, slct } from '$lib/stores/ae_stores';
import { db_core } from '$lib/ae_core/db_core'; import { db_core } from '$lib/ae_core/db_core';
import { ae_util } from '$lib/ae_utils/ae_utils';
import type { key_val } from '$lib/stores/ae_stores';
import type { ae_DataStore } from '$lib/types/ae_types'; import type { ae_DataStore } from '$lib/types/ae_types';
import { import { Database, Info, LoaderCircle, SquarePen } from '@lucide/svelte';
Check, import AE_DataStore_Form from '$lib/elements/element_data_store_form.svelte';
Code,
Eye,
LoaderCircle,
Save,
SquarePen,
Trash2,
X,
Info,
Database
} from '@lucide/svelte';
import AE_Comp_Editor_TipTap from '$lib/elements/element_editor_tiptap.svelte';
import AE_Comp_Editor_CodeMirror from '$lib/elements/element_editor_codemirror.svelte';
interface Props { interface Props {
log_lvl?: number; log_lvl?: number;
@@ -71,28 +56,6 @@ let {
// Local reactive state // Local reactive state
let trigger: null | string = $state(null); let trigger: null | string = $state(null);
let draft_value = $state('');
let draft_name = $state('');
let draft_code = $state('');
let draft_type = $state('');
let draft_use_account_id = $state(false);
let html_edit_mode = $state<'source' | 'visual'>('source');
// Change detection derived from draft vs current LiveQuery object
let has_changes = $derived.by(() => {
const entry = $lq__ds_obj as ae_DataStore | null;
if (!entry) return true; // Treat new record as having changes
const current_val = entry.type === 'json'
? (typeof entry.json === 'string' ? entry.json : JSON.stringify(entry.json, null, 2))
: (entry.text || entry.html || '');
return draft_value !== current_val ||
draft_name !== (entry.name || '') ||
draft_code !== (entry.code || '') ||
draft_type !== (entry.type || 'text') ||
draft_use_account_id !== (!!entry.account_id);
});
// Dexie LiveQuery for data store // Dexie LiveQuery for data store
let lq__ds_obj = $derived( let lq__ds_obj = $derived(
@@ -130,24 +93,7 @@ let lq__ds_obj = $derived(
}) })
); );
/** // Sync bound props when the live data changes
* reset_drafts: Resets all draft fields to the current object's values.
* Called when the live data changes OR when the editor is closed/cancelled.
*/
function reset_drafts() {
const entry = $lq__ds_obj as ae_DataStore | null;
if (!entry) return;
draft_name = entry.name || '';
draft_code = entry.code || '';
draft_type = entry.type || 'text';
draft_use_account_id = !!entry.account_id;
draft_value = entry.type === 'json'
? (typeof entry.json === 'string' ? entry.json : JSON.stringify(entry.json, null, 2))
: (entry.text || entry.html || '');
}
// Sync status and bound props when the live data changes
$effect(() => { $effect(() => {
const entry = $lq__ds_obj as ae_DataStore | null; const entry = $lq__ds_obj as ae_DataStore | null;
untrack(() => { untrack(() => {
@@ -157,30 +103,10 @@ $effect(() => {
if (ds_type === 'sql') { if (ds_type === 'sql') {
val_sql = entry.text || entry.html || null; val_sql = entry.text || entry.html || null;
} }
// Initialize draft values when not editing
if (!show_edit) {
reset_drafts();
}
} }
}); });
}); });
// Reset draft values when the editor is closed (discard changes)
$effect(() => {
if (!show_edit) {
untrack(() => reset_drafts());
}
});
// Reset submit status when opening the editor
$effect(() => {
if (show_edit) {
untrack(() => {
$ae_sess.ds.submit_status = 'idle';
});
}
});
// Context Change Guard // Context Change Guard
$effect(() => { $effect(() => {
void for_id; void for_type; void ds_code; void for_id; void for_type; void ds_code;
@@ -254,65 +180,6 @@ async function load_data_store() {
} }
} }
async function handle_submit_form(event: Event) {
$ae_sess.ds.submit_status = 'processing';
const data_store_do: key_val = {
code: draft_code,
name: draft_name,
type: draft_type,
for_type: for_type,
for_id: for_id,
enable: true,
account_id: draft_use_account_id ? ($ae_loc.account_id || $slct.account_id) : null
};
if (data_store_do.type === 'json') {
try {
data_store_do.json = JSON.parse(draft_value);
} catch (e) {
data_store_do.json = draft_value;
}
} else {
data_store_do.text = draft_value;
}
const api_cfg = untrack(() => $ae_api);
// for_type/for_id are creation-time context; never re-send on update.
// DB stores for_id as integer; backend resolves and returns the random string — frontend must not send the string back.
const update_do: key_val = { ...data_store_do };
delete update_do.for_type;
delete update_do.for_id;
const api_call = $lq__ds_obj?.id
? api.update_ae_obj({ api_cfg, obj_type: 'data_store', obj_id: $lq__ds_obj.id, fields: update_do })
: api.create_ae_obj({ api_cfg, obj_type: 'data_store', fields: data_store_do });
api_call.then((res) => {
if (res) {
$ae_sess.ds.submit_status = $lq__ds_obj?.id ? 'updated' : 'created';
trigger = 'load__ds__code';
show_edit = false;
}
return res;
});
}
async function handle_delete() {
if (!$lq__ds_obj?.id || !confirm('Are you sure you want to delete this data store?')) return;
const api_cfg = untrack(() => $ae_api);
const res = await api.delete_ae_obj({ api_cfg, obj_type: 'data_store', obj_id: $lq__ds_obj.id, method: 'delete' });
if (res) {
await db_core.data_store.delete($lq__ds_obj.id);
ds_loading_status = 'not found';
show_edit = false;
}
}
function handle_cancel() {
if (has_changes && !confirm('Discard unsaved changes?')) return;
show_edit = false;
// reset_drafts() will be called by the $effect watching show_edit
}
</script> </script>
<div <div
@@ -329,119 +196,14 @@ function handle_cancel() {
</div> </div>
{/if} {/if}
<Modal <AE_DataStore_Form
title="{draft_name || 'Unnamed'} - {draft_code}"
bind:open={show_edit} bind:open={show_edit}
autoclose={false} editing_obj={$lq__ds_obj as ae_DataStore}
outsideclose={!has_changes} show_account_field={false}
size="xl" show_for_fields={false}
class="w-full max-w-6xl"> readonly_code={!$ae_loc.manager_access}
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); handle_submit_form(e); }}> on_saved={() => { trigger = 'load__ds__code'; }}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> on_deleted={() => { ds_loading_status = 'not found'; }} />
<div class="space-y-2">
<label class="label">
<span class="text-xs font-bold opacity-70">Code</span>
<input type="text" name="ds_code" class="input font-mono" bind:value={draft_code} readonly={!$ae_loc.manager_access} required />
</label>
<label class="label">
<span class="text-xs font-bold opacity-70">Name</span>
<input type="text" name="ds_name" class="input" bind:value={draft_name} required />
</label>
</div>
<div class="space-y-2">
<label class="label">
<span class="text-xs font-bold opacity-70">Type</span>
<select name="ds_type" class="select" bind:value={draft_type}>
<option value="text">Text</option>
<option value="html">HTML</option>
<option value="json">JSON</option>
<option value="md">Markdown</option>
<option value="sql">SQL</option>
</select>
</label>
<div class="flex items-center gap-2 pt-6">
<input type="checkbox" name="ds_use_account_id" id="ds_use_account_id" class="checkbox" bind:checked={draft_use_account_id} />
<label for="ds_use_account_id" class="text-xs cursor-pointer">Account Specific</label>
</div>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-bold opacity-70">Content</span>
{#if draft_type === 'html'}
<div class="flex items-center gap-1 rounded bg-black/5 p-0.5">
<button
type="button"
class="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-bold uppercase transition-all"
class:bg-primary-500={html_edit_mode === 'source'}
class:text-white={html_edit_mode === 'source'}
class:opacity-50={html_edit_mode !== 'source'}
onclick={() => (html_edit_mode = 'source')}
title="Edit raw HTML source">
<Code size="10" /> Source
</button>
<button
type="button"
class="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-bold uppercase transition-all"
class:bg-primary-500={html_edit_mode === 'visual'}
class:text-white={html_edit_mode === 'visual'}
class:opacity-50={html_edit_mode !== 'visual'}
onclick={() => (html_edit_mode = 'visual')}
title="Visual / WYSIWYG editor">
<Eye size="10" /> Visual
</button>
</div>
{/if}
</div>
{#if draft_type === 'html'}
{#if html_edit_mode === 'source'}
<AE_Comp_Editor_CodeMirror bind:content={draft_value} placeholder="Enter HTML Source..." />
{:else}
<AE_Comp_Editor_TipTap bind:content={draft_value} placeholder="Enter HTML content..." />
{/if}
{:else if draft_type === 'json' || draft_type === 'sql' || draft_type === 'md'}
<AE_Comp_Editor_CodeMirror bind:content={draft_value} placeholder="Enter {draft_type.toUpperCase()} content..." />
{:else}
<textarea bind:value={draft_value} class="textarea font-mono text-sm" rows="15" placeholder="Enter text content..."></textarea>
{/if}
</div>
<div class="flex items-center justify-between pt-4">
<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>
<div class="flex gap-2">
<button
type="button"
class="btn preset-tonal-surface"
onclick={handle_cancel}
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 || $ae_sess.ds.submit_status === 'processing'}
title="Save changes to this data store">
{#if $ae_sess.ds.submit_status === 'processing'}
<LoaderCircle size="14" class="mr-2 animate-spin" />
{:else if $ae_sess.ds.submit_status === 'updated' || $ae_sess.ds.submit_status === 'created'}
<Check size="14" class="mr-2" />
{:else}
<Save size="14" class="mr-2" />
{/if}
Save
</button>
</div>
</div>
</form>
</Modal>
{#if show_view} {#if show_view}
{#if $lq__ds_obj.type === 'html' && $lq__ds_obj.html} {#if $lq__ds_obj.type === 'html' && $lq__ds_obj.html}

View File

@@ -1,112 +1,228 @@
<script lang="ts"> <script lang="ts">
/** /**
* Shared form fields for creating/editing a Data Store record. * Self-contained Data Store edit modal.
* Does NOT include the modal wrapper or action buttons — the parent handles those. * Handles form fields, change detection, save/delete API calls, and IDB cache update.
* *
* Usage contexts: * Usage:
* - /core/data_stores/ management page (full fields, show_for_fields=true) * <AE_DataStore_Form bind:open editing_obj={obj_or_null} on_saved={...} on_deleted={...} />
* - element_data_store.svelte inline editor (show_for_fields=false, show_account_field=false)
* *
* Change detection: pass original_obj (the unmodified record) to enable has_changes. * - editing_obj = null → new record (Create mode)
* For new records, pass original_obj=null — has_changes will always be true. * - editing_obj = obj → edit existing record
* The parent can read changes via bind:has_changes. * - show_account_field → show/hide the Account ID text field (hide in embedded widget context)
* - show_for_fields → show/hide For Type + For ID (hide in embedded widget context)
* - default_account_id → pre-fill account_id when creating a new record
*/ */
import { untrack } from 'svelte';
import { Modal } from 'flowbite-svelte';
import { Check, ChevronDown, ChevronUp, Code, Eye, LoaderCircle, Save, Trash2, X } from '@lucide/svelte';
import type { ae_DataStore } from '$lib/types/ae_types'; import type { ae_DataStore } from '$lib/types/ae_types';
import { ChevronDown, ChevronUp, Code, Eye } from '@lucide/svelte'; import { api } from '$lib/api/api';
import { ae_api } from '$lib/stores/ae_stores';
import { db_core } from '$lib/ae_core/db_core';
import AE_Comp_Editor_CodeMirror from '$lib/elements/element_editor_codemirror.svelte'; import AE_Comp_Editor_CodeMirror from '$lib/elements/element_editor_codemirror.svelte';
import AE_Comp_Editor_TipTap from '$lib/elements/element_editor_tiptap.svelte'; import AE_Comp_Editor_TipTap from '$lib/elements/element_editor_tiptap.svelte';
interface Props { interface Props {
// Core fields — always visible open: boolean;
draft_code: string; editing_obj?: ae_DataStore | null; // null/undefined = new record
draft_name: string; show_account_field?: boolean; // show Account ID field; false for embedded widget
draft_type: string; show_for_fields?: boolean; // show For Type + For ID; false for embedded widget
draft_value: string; // content payload (text, json string, html, etc.) readonly_code?: boolean; // lock the code field
default_account_id?: string; // pre-fill account_id on new records
// Context fields on_saved?: (result: Record<string, unknown>) => void; // called after a successful save or create
draft_account_id?: string; on_deleted?: () => void; // called after a successful delete
draft_for_type?: string;
draft_for_id?: string; // read-only when !is_new (DB stores integer FK; can't PATCH with string)
// Advanced / flag fields — shown inside the collapsible section
draft_enable?: boolean;
draft_hide?: boolean;
draft_priority?: boolean;
draft_sort?: string;
draft_group?: string;
draft_notes?: string;
// Display controls
is_new?: boolean; // true → for_id is editable; false → read-only
show_account_field?: boolean; // show account_id text input (false for embedded widget)
show_for_fields?: boolean; // show for_type + for_id (false for embedded widget)
readonly_code?: boolean; // lock the code field (non-manager users)
// Change detection — pass the unmodified record; null/undefined = new record (always dirty)
original_obj?: ae_DataStore | null;
// Bindable output: true when any draft field differs from original_obj (or always true for new records)
has_changes?: boolean;
} }
let { let {
draft_code = $bindable(''), open = $bindable(false),
draft_name = $bindable(''), editing_obj = null,
draft_type = $bindable('text'),
draft_value = $bindable(''),
draft_account_id = $bindable(''),
draft_for_type = $bindable(''),
draft_for_id = $bindable(''),
draft_enable = $bindable(true),
draft_hide = $bindable(false),
draft_priority = $bindable(false),
draft_sort = $bindable(''),
draft_group = $bindable(''),
draft_notes = $bindable(''),
is_new = false,
show_account_field = true, show_account_field = true,
show_for_fields = true, show_for_fields = true,
readonly_code = false, readonly_code = false,
original_obj = null, default_account_id = '',
has_changes = $bindable(false), on_saved,
on_deleted
}: Props = $props(); }: Props = $props();
// Internal — not bindable; resets automatically when parent uses {#key} on record change let is_new = $derived(!editing_obj?.id);
// Draft state — initialized from editing_obj each time the modal opens
let draft_code = $state('');
let draft_name = $state('');
let draft_type = $state('text');
let draft_value = $state('');
let draft_account_id = $state('');
let draft_for_type = $state('');
let draft_for_id = $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('');
// Internal UI state — reset each time the modal opens
let html_edit_mode = $state<'source' | 'visual'>('source'); let html_edit_mode = $state<'source' | 'visual'>('source');
let show_advanced = $state(false); let show_advanced = $state(false);
let submit_status = $state<'idle' | 'processing' | 'saved' | 'error'>('idle');
// Compare each draft field to the original to detect unsaved changes. // Initialize all drafts whenever the modal opens
// new records (original_obj = null) are always considered dirty so Save is never blocked. $effect(() => {
let _has_changes = $derived.by(() => { if (open) {
if (!original_obj) return true; untrack(() => {
const orig_value = original_obj.type === 'json' const obj = editing_obj;
? (typeof original_obj.json === 'string' ? original_obj.json : JSON.stringify(original_obj.json ?? '', null, 2)) draft_code = obj?.code ?? '';
: (original_obj.text || original_obj.html || ''); draft_name = obj?.name ?? '';
draft_type = obj?.type ?? 'text';
draft_value = obj
? (obj.type === 'json'
? (typeof obj.json === 'string' ? obj.json : JSON.stringify(obj.json ?? '', null, 2))
: (obj.text || obj.html || ''))
: '';
draft_account_id = obj?.account_id ?? (is_new ? default_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 ?? '';
html_edit_mode = 'source';
show_advanced = false;
submit_status = 'idle';
});
}
});
// Change detection — compare each draft against editing_obj; new records are always dirty
let has_changes = $derived.by(() => {
if (!editing_obj?.id) return true;
const obj = editing_obj;
const orig_value = obj.type === 'json'
? (typeof obj.json === 'string' ? obj.json : JSON.stringify(obj.json ?? '', null, 2))
: (obj.text || obj.html || '');
return ( return (
draft_code !== (original_obj.code ?? '') || draft_code !== (obj.code ?? '') ||
draft_name !== (original_obj.name ?? '') || draft_name !== (obj.name ?? '') ||
draft_type !== (original_obj.type ?? 'text') || draft_type !== (obj.type ?? 'text') ||
draft_value !== orig_value || draft_value !== orig_value ||
draft_account_id !== (original_obj.account_id ?? '') || draft_account_id !== (obj.account_id ?? '') ||
draft_enable !== (original_obj.enable !== false) || draft_enable !== (obj.enable !== false) ||
draft_hide !== !!original_obj.hide || draft_hide !== !!obj.hide ||
draft_priority !== !!original_obj.priority || draft_priority !== !!obj.priority ||
draft_sort !== String(original_obj.sort ?? '') || draft_sort !== String(obj.sort ?? '') ||
draft_group !== (original_obj.group ?? '') || draft_group !== (obj.group ?? '') ||
draft_notes !== (original_obj.notes ?? '') draft_notes !== (obj.notes ?? '')
); );
}); });
// Push computed value out to the parent via the bindable prop async function handle_save() {
$effect(() => { has_changes = _has_changes; }); submit_status = 'processing';
const api_cfg = untrack(() => $ae_api);
const fields: Record<string, unknown> = {
code: draft_code.trim(),
name: draft_name.trim(),
type: draft_type,
account_id: draft_account_id.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_type / for_id: only valid on create — backend stores for_id as an integer FK
// and rejects the random string on PATCH
if (is_new) {
if (draft_for_type.trim()) fields.for_type = draft_for_type.trim();
if (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: Record<string, unknown> | null | false;
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_data = result as unknown as ae_DataStore;
const ds_id = ds_data.data_store_id || ds_data.id;
if (ds_id) {
const text_val = ds_data.text ?? '';
await db_core.data_store.put({
...ds_data,
id: ds_id,
data_store_id: ds_id,
updated_on: ds_data.updated_on ?? new Date().toISOString(),
text: text_val,
html: text_val,
json: ds_data.json ?? null
});
}
submit_status = 'saved';
on_saved?.(result);
setTimeout(() => { open = false; }, 600);
} else {
submit_status = 'error';
setTimeout(() => { submit_status = 'idle'; }, 3000);
}
}
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);
on_deleted?.();
open = false;
}
}
function handle_cancel() {
if (has_changes && !confirm('Discard unsaved changes?')) return;
open = false;
}
</script> </script>
<div class="space-y-4"> <Modal
title={is_new ? 'New Data Store' : `${draft_name || 'Unnamed'} ${draft_code}`}
bind:open
autoclose={false}
outsideclose={!has_changes}
size="xl"
class="w-full max-w-5xl">
<!-- ── Code + Name ───────────────────────────────────────────────────────── --> <form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); handle_save(); }}>
<!-- ── Code + Name ─────────────────────────────────────────────────────── -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="label space-y-1"> <label class="label">
<span class="text-xs font-bold opacity-70"> <span class="text-xs font-bold opacity-70">Code <span class="text-error-500">*</span>
Code <span class="text-error-500">*</span>
<span class="font-normal opacity-60">— unique key, snake_case convention</span> <span class="font-normal opacity-60">— unique key, snake_case convention</span>
</span> </span>
<input <input
@@ -117,9 +233,8 @@ $effect(() => { has_changes = _has_changes; });
readonly={readonly_code} readonly={readonly_code}
placeholder="event__my_event__my_store" /> placeholder="event__my_event__my_store" />
</label> </label>
<label class="label space-y-1"> <label class="label">
<span class="text-xs font-bold opacity-70"> <span class="text-xs font-bold opacity-70">Name <span class="text-error-500">*</span>
Name <span class="text-error-500">*</span>
<span class="font-normal opacity-60">— human-readable label</span> <span class="font-normal opacity-60">— human-readable label</span>
</span> </span>
<input <input
@@ -131,11 +246,10 @@ $effect(() => { has_changes = _has_changes; });
</label> </label>
</div> </div>
<!-- ── Type + Account ID ─────────────────────────────────────────────────── --> <!-- ── Type + Account ID ───────────────────────────────────────────────── -->
<div class="grid grid-cols-1 gap-4 {show_account_field ? 'md:grid-cols-2' : ''}"> <div class="grid grid-cols-1 gap-4 {show_account_field ? 'md:grid-cols-2' : ''}">
<label class="label space-y-1"> <label class="label">
<span class="text-xs font-bold opacity-70"> <span class="text-xs font-bold opacity-70">Type
Type
<span class="font-normal opacity-60">— determines how content is stored and rendered</span> <span class="font-normal opacity-60">— determines how content is stored and rendered</span>
</span> </span>
<select class="select" bind:value={draft_type}> <select class="select" bind:value={draft_type}>
@@ -147,9 +261,8 @@ $effect(() => { has_changes = _has_changes; });
</select> </select>
</label> </label>
{#if show_account_field} {#if show_account_field}
<label class="label space-y-1"> <label class="label">
<span class="text-xs font-bold opacity-70"> <span class="text-xs font-bold opacity-70">Account ID
Account ID
<span class="font-normal opacity-60">— blank = global (shared across all accounts)</span> <span class="font-normal opacity-60">— blank = global (shared across all accounts)</span>
</span> </span>
<input <input
@@ -161,12 +274,11 @@ $effect(() => { has_changes = _has_changes; });
{/if} {/if}
</div> </div>
<!-- ── For Type + For ID ─────────────────────────────────────────────────── --> <!-- ── For Type + For ID ───────────────────────────────────────────────── -->
{#if show_for_fields} {#if show_for_fields}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2"> <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="label space-y-1"> <label class="label">
<span class="text-xs font-bold opacity-70"> <span class="text-xs font-bold opacity-70">For Type
For Type
<span class="font-normal opacity-60">— polymorphic parent object type</span> <span class="font-normal opacity-60">— polymorphic parent object type</span>
</span> </span>
<input <input
@@ -176,8 +288,7 @@ $effect(() => { has_changes = _has_changes; });
placeholder="event, event_session, person, journal_entry…" /> placeholder="event, event_session, person, journal_entry…" />
</label> </label>
<div class="space-y-1"> <div class="space-y-1">
<span class="text-xs font-bold opacity-70"> <span class="text-xs font-bold opacity-70">For ID
For ID
<span class="font-normal opacity-60">— random string ID of the parent object</span> <span class="font-normal opacity-60">— random string ID of the parent object</span>
</span> </span>
{#if is_new} {#if is_new}
@@ -187,12 +298,11 @@ $effect(() => { has_changes = _has_changes; });
bind:value={draft_for_id} bind:value={draft_for_id}
placeholder="random string ID" /> placeholder="random string ID" />
<p class="text-[10px] opacity-50"> <p class="text-[10px] opacity-50">
The backend resolves this string to an integer FK on create. Backend resolves this string to an integer FK on create. Once set, For ID cannot be changed via the UI.
Once set, For ID cannot be changed via the UI.
</p> </p>
{:else} {:else}
<!-- for_id is stored as an integer FK in DB; backend resolves string→int on create <!-- for_id is stored as an integer FK; backend resolves string→int on create
but rejects the string on PATCH. Read-only after creation. --> but rejects the string on PATCH — display only after creation -->
<div class="input bg-surface-200-700-token flex items-center font-mono text-sm opacity-70"> <div class="input bg-surface-200-700-token flex items-center font-mono text-sm opacity-70">
{#if draft_for_id} {#if draft_for_id}
{draft_for_id} {draft_for_id}
@@ -201,14 +311,14 @@ $effect(() => { has_changes = _has_changes; });
{/if} {/if}
</div> </div>
<p class="text-[10px] opacity-50"> <p class="text-[10px] opacity-50">
For ID is fixed at creation time (DB stores an integer FK). To change it, update the record directly in phpMyAdmin. For ID is fixed at creation time. To change it, update directly in phpMyAdmin.
</p> </p>
{/if} {/if}
</div> </div>
</div> </div>
{/if} {/if}
<!-- ── Content editor ────────────────────────────────────────────────────── --> <!-- ── Content editor ─────────────────────────────────────────────────── -->
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs font-bold opacity-70"> <span class="text-xs font-bold opacity-70">
@@ -266,92 +376,93 @@ $effect(() => { has_changes = _has_changes; });
{/if} {/if}
</div> </div>
<!-- ── Advanced section (collapsible) ────────────────────────────────────── --> <!-- ── Advanced (collapsible) ──────────────────────────────────────────── -->
<div class="overflow-hidden rounded-lg border border-surface-500/20"> <div class="overflow-hidden rounded-lg border border-surface-500/20">
<button <button
type="button" type="button"
class="flex w-full items-center justify-between bg-surface-500/5 px-4 py-2 text-[10px] font-bold uppercase tracking-widest opacity-50 transition-opacity hover:opacity-80" class="flex w-full items-center justify-between bg-surface-500/5 px-4 py-2 text-[10px] font-bold uppercase tracking-widest opacity-50 transition-opacity hover:opacity-80"
onclick={() => (show_advanced = !show_advanced)}> onclick={() => (show_advanced = !show_advanced)}>
<span>Advanced — Enable · Hide · Priority · Sort · Group · Notes</span> <span>Advanced — Enable · Hide · Priority · Sort · Group · Notes</span>
{#if show_advanced} {#if show_advanced}<ChevronUp size={12} />{:else}<ChevronDown size={12} />{/if}
<ChevronUp size={12} />
{:else}
<ChevronDown size={12} />
{/if}
</button> </button>
{#if show_advanced} {#if show_advanced}
<div class="space-y-4 p-4"> <div class="space-y-4 p-4">
<!-- Flags -->
<div class="space-y-1"> <div class="space-y-1">
<p class="text-[10px] font-bold uppercase tracking-widest opacity-40">Flags</p> <p class="text-[10px] font-bold uppercase tracking-widest opacity-40">Flags</p>
<div class="flex flex-wrap gap-x-6 gap-y-2"> <div class="flex flex-wrap gap-x-6 gap-y-2">
<label class="flex cursor-pointer items-center gap-2 text-sm"> <label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" class="checkbox" bind:checked={draft_enable} /> <input type="checkbox" class="checkbox" bind:checked={draft_enable} />
<span> <span>Enable <span class="ml-1 text-[10px] font-normal opacity-50">— record is active and queryable</span></span>
Enable
<span class="ml-1 text-[10px] font-normal opacity-50">— record is active and queryable</span>
</span>
</label> </label>
<label class="flex cursor-pointer items-center gap-2 text-sm"> <label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" class="checkbox" bind:checked={draft_hide} /> <input type="checkbox" class="checkbox" bind:checked={draft_hide} />
<span> <span>Hide <span class="ml-1 text-[10px] font-normal opacity-50">— suppress from standard display</span></span>
Hide
<span class="ml-1 text-[10px] font-normal opacity-50">— suppress from standard display</span>
</span>
</label> </label>
<label class="flex cursor-pointer items-center gap-2 text-sm"> <label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" class="checkbox" bind:checked={draft_priority} /> <input type="checkbox" class="checkbox" bind:checked={draft_priority} />
<span> <span>Priority <span class="ml-1 text-[10px] font-normal opacity-50">— float above other records in sort</span></span>
Priority
<span class="ml-1 text-[10px] font-normal opacity-50">— float above other records in sort</span>
</span>
</label> </label>
</div> </div>
</div> </div>
<!-- Sort + Group -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="space-y-1"> <label class="space-y-1">
<span class="text-xs font-bold opacity-70"> <span class="text-xs font-bold opacity-70">Sort <span class="font-normal opacity-60">— numeric ordering; lower = higher position</span></span>
Sort <input type="text" class="input input-sm font-mono text-xs" bind:value={draft_sort} placeholder="0" />
<span class="font-normal opacity-60">— numeric ordering; lower = higher position</span>
</span>
<input
type="text"
class="input input-sm font-mono text-xs"
bind:value={draft_sort}
placeholder="0" />
</label> </label>
<label class="space-y-1"> <label class="space-y-1">
<span class="text-xs font-bold opacity-70"> <span class="text-xs font-bold opacity-70">Group <span class="font-normal opacity-60">— optional grouping or category label</span></span>
Group <input type="text" class="input input-sm font-mono text-xs" bind:value={draft_group} placeholder="group_name" />
<span class="font-normal opacity-60">— optional grouping or category label</span>
</span>
<input
type="text"
class="input input-sm font-mono text-xs"
bind:value={draft_group}
placeholder="group_name" />
</label> </label>
</div> </div>
<!-- Notes -->
<label class="space-y-1"> <label class="space-y-1">
<span class="text-xs font-bold opacity-70"> <span class="text-xs font-bold opacity-70">Notes <span class="font-normal opacity-60">— internal reference only; not shown to end users</span></span>
Notes <input type="text" class="input text-sm" bind:value={draft_notes} placeholder="Internal notes or context…" />
<span class="font-normal opacity-60">— internal reference only; not shown to end users</span>
</span>
<input
type="text"
class="input text-sm"
bind:value={draft_notes}
placeholder="Internal notes or context…" />
</label> </label>
</div> </div>
{/if} {/if}
</div> </div>
</div> <!-- ── 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={handle_cancel}
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>

View File

@@ -1,8 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount, untrack } from 'svelte';
import { untrack } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { Modal } from 'flowbite-svelte';
import { import {
ArrowRight, ArrowRight,
ArrowUpDown, ArrowUpDown,
@@ -17,9 +15,7 @@ import {
Pencil, Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Save,
Search, Search,
Trash2,
X X
} from '@lucide/svelte'; } from '@lucide/svelte';
@@ -55,23 +51,6 @@ let searched = $state(false);
// ── Edit modal ──────────────────────────────────────────────────────────────── // ── Edit modal ────────────────────────────────────────────────────────────────
let show_edit = $state(false); let show_edit = $state(false);
let editing_obj: ae_DataStore | null = $state(null); 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 ───────────────────────────────────────────────────────── // ── Bulk rename state ─────────────────────────────────────────────────────────
let show_bulk_rename = $state(false); let show_bulk_rename = $state(false);
@@ -145,141 +124,17 @@ function toggle_sort(col: string) {
if (searched) do_search(false); if (searched) do_search(false);
} }
// ── Open edit ───────────────────────────────────────────────────────────────── // ── Open edit / new ───────────────────────────────────────────────────────────
function open_edit(obj: ae_DataStore) { function open_edit(obj: ae_DataStore) {
editing_obj = obj; 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; show_edit = true;
} }
function open_new() { function open_new() {
editing_obj = null; 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; 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 ─────────────────────────────────────────────────────────────── // ── Bulk rename ───────────────────────────────────────────────────────────────
async function do_rename_preview() { async function do_rename_preview() {
if (!rename_filter.trim()) return; if (!rename_filter.trim()) return;
@@ -401,69 +256,69 @@ function content_preview(ds: ae_DataStore): string {
</div> </div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4"> <div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<div class="space-y-1"> <label class="space-y-1">
<label class="text-xs font-bold opacity-70"> <span class="text-xs font-bold opacity-70">
Code Code <span class="font-normal opacity-60">— use <code>%</code> as wildcard</span>
<span class="font-normal opacity-60">— use <code>%</code> as wildcard</span> </span>
</label>
<input <input
type="search" type="search"
bind:value={qry_code} bind:value={qry_code}
placeholder="event__pres_mgmt__%" placeholder="event__pres_mgmt__%"
class="input input-sm w-full font-mono text-xs" class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_search()} /> onkeydown={(e) => e.key === 'Enter' && do_search()} />
</div>
<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> </label>
<div class="space-y-1">
<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 <input
type="search" type="search"
bind:value={qry_account_id} bind:value={qry_account_id}
placeholder="random ID or 'global'" placeholder="random ID or 'global'"
class="input input-sm w-full font-mono text-xs" class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_search()} /> onkeydown={(e) => e.key === 'Enter' && do_search()} />
</label>
<p class="text-[10px] opacity-40">Results are scoped to the active account by the API.</p> <p class="text-[10px] opacity-40">Results are scoped to the active account by the API.</p>
</div> </div>
<div class="space-y-1"> <label class="space-y-1">
<label class="text-xs font-bold opacity-70">For Type</label> <span class="text-xs font-bold opacity-70">For Type</span>
<input <input
type="search" type="search"
bind:value={qry_for_type} bind:value={qry_for_type}
placeholder="event, event_session, person…" placeholder="event, event_session, person…"
class="input input-sm w-full font-mono text-xs" class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_search()} /> onkeydown={(e) => e.key === 'Enter' && do_search()} />
</div> </label>
<div class="space-y-1"> <label class="space-y-1">
<label class="text-xs font-bold opacity-70">For ID</label> <span class="text-xs font-bold opacity-70">For ID</span>
<input <input
type="search" type="search"
bind:value={qry_for_id} bind:value={qry_for_id}
placeholder="random string ID" placeholder="random string ID"
class="input input-sm w-full font-mono text-xs" class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_search()} /> onkeydown={(e) => e.key === 'Enter' && do_search()} />
</div> </label>
</div> </div>
<div class="flex flex-wrap items-center gap-3 pt-1"> <div class="flex flex-wrap items-center gap-3 pt-1">
<div class="space-y-1"> <label class="space-y-1">
<label class="text-xs font-bold opacity-70">Status</label> <span class="text-xs font-bold opacity-70">Status</span>
<select class="select select-sm text-xs" bind:value={qry_enabled}> <select class="select select-sm text-xs" bind:value={qry_enabled}>
<option value="all">All</option> <option value="all">All</option>
<option value="enabled">Enabled</option> <option value="enabled">Enabled</option>
<option value="not_enabled">Disabled</option> <option value="not_enabled">Disabled</option>
</select> </select>
</div> </label>
<div class="space-y-1"> <label class="space-y-1">
<label class="text-xs font-bold opacity-70">Per page</label> <span class="text-xs font-bold opacity-70">Per page</span>
<select class="select select-sm text-xs" bind:value={page_limit}> <select class="select select-sm text-xs" bind:value={page_limit}>
<option value={25}>25</option> <option value={25}>25</option>
<option value={50}>50</option> <option value={50}>50</option>
<option value={100}>100</option> <option value={100}>100</option>
<option value={200}>200</option> <option value={200}>200</option>
</select> </select>
</div> </label>
<div class="flex flex-1 items-end justify-end gap-2"> <div class="flex flex-1 items-end justify-end gap-2">
<button <button
type="button" type="button"
@@ -496,34 +351,33 @@ function content_preview(ds: ae_DataStore): string {
</div> </div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-3"> <div class="grid grid-cols-1 gap-3 md:grid-cols-3">
<div class="space-y-1"> <label class="space-y-1">
<label class="text-xs font-bold opacity-70"> <span class="text-xs font-bold opacity-70">
Code filter Code filter <span class="font-normal opacity-60">— use <code>%</code> as wildcard</span>
<span class="font-normal opacity-60">— use <code>%</code> as wildcard</span> </span>
</label>
<input <input
type="text" type="text"
bind:value={rename_filter} bind:value={rename_filter}
placeholder="event__old_code__%" placeholder="event__old_code__%"
class="input input-sm w-full font-mono text-xs" class="input input-sm w-full font-mono text-xs"
onkeydown={(e) => e.key === 'Enter' && do_rename_preview()} /> onkeydown={(e) => e.key === 'Enter' && do_rename_preview()} />
</div> </label>
<div class="space-y-1"> <label class="space-y-1">
<label class="text-xs font-bold opacity-70">Find text in code</label> <span class="text-xs font-bold opacity-70">Find text in code</span>
<input <input
type="text" type="text"
bind:value={rename_find_text} bind:value={rename_find_text}
placeholder="old_code" placeholder="old_code"
class="input input-sm w-full font-mono text-xs" /> class="input input-sm w-full font-mono text-xs" />
</div> </label>
<div class="space-y-1"> <label class="space-y-1">
<label class="text-xs font-bold opacity-70">Replace with</label> <span class="text-xs font-bold opacity-70">Replace with</span>
<input <input
type="text" type="text"
bind:value={rename_replace_text} bind:value={rename_replace_text}
placeholder="new_code" placeholder="new_code"
class="input input-sm w-full font-mono text-xs" /> class="input input-sm w-full font-mono text-xs" />
</div> </label>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -731,78 +585,9 @@ function content_preview(ds: ae_DataStore): string {
</div> </div>
<!-- ── Edit / Create Modal ───────────────────────────────────────────────────── --> <!-- ── Edit / Create Modal ───────────────────────────────────────────────────── -->
<Modal <AE_DataStore_Form
title={is_new ? 'New Data Store' : `Edit: ${editing_obj?.code ?? ''}`}
bind:open={show_edit} bind:open={show_edit}
autoclose={false} editing_obj={editing_obj}
outsideclose={!has_changes} default_account_id={$ae_loc.account_id ?? ''}
size="xl" on_saved={() => do_search(false)}
class="w-full max-w-5xl"> on_deleted={() => do_search(false)} />
<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>