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">
import { browser } from '$app/environment';
import { onMount, untrack } from 'svelte';
import { Modal } from 'flowbite-svelte';
import { liveQuery } from 'dexie';
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 { 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 {
Check,
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';
import { Database, Info, LoaderCircle, SquarePen } from '@lucide/svelte';
import AE_DataStore_Form from '$lib/elements/element_data_store_form.svelte';
interface Props {
log_lvl?: number;
@@ -71,28 +56,6 @@ let {
// Local reactive state
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
let lq__ds_obj = $derived(
@@ -130,24 +93,7 @@ let lq__ds_obj = $derived(
})
);
/**
* 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
// Sync bound props when the live data changes
$effect(() => {
const entry = $lq__ds_obj as ae_DataStore | null;
untrack(() => {
@@ -157,30 +103,10 @@ $effect(() => {
if (ds_type === 'sql') {
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
$effect(() => {
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>
<div
@@ -329,119 +196,14 @@ function handle_cancel() {
</div>
{/if}
<Modal
title="{draft_name || 'Unnamed'} - {draft_code}"
<AE_DataStore_Form
bind:open={show_edit}
autoclose={false}
outsideclose={!has_changes}
size="xl"
class="w-full max-w-6xl">
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); handle_submit_form(e); }}>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<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>
editing_obj={$lq__ds_obj as ae_DataStore}
show_account_field={false}
show_for_fields={false}
readonly_code={!$ae_loc.manager_access}
on_saved={() => { trigger = 'load__ds__code'; }}
on_deleted={() => { ds_loading_status = 'not found'; }} />
{#if show_view}
{#if $lq__ds_obj.type === 'html' && $lq__ds_obj.html}

View File

@@ -1,357 +1,468 @@
<script lang="ts">
/**
* Shared form fields for creating/editing a Data Store record.
* Does NOT include the modal wrapper or action buttons — the parent handles those.
* Self-contained Data Store edit modal.
* Handles form fields, change detection, save/delete API calls, and IDB cache update.
*
* Usage contexts:
* - /core/data_stores/ management page (full fields, show_for_fields=true)
* - element_data_store.svelte inline editor (show_for_fields=false, show_account_field=false)
* Usage:
* <AE_DataStore_Form bind:open editing_obj={obj_or_null} on_saved={...} on_deleted={...} />
*
* Change detection: pass original_obj (the unmodified record) to enable has_changes.
* For new records, pass original_obj=null — has_changes will always be true.
* The parent can read changes via bind:has_changes.
* - editing_obj = null → new record (Create mode)
* - editing_obj = obj → edit existing record
* - 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 { 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_TipTap from '$lib/elements/element_editor_tiptap.svelte';
interface Props {
// Core fields — always visible
draft_code: string;
draft_name: string;
draft_type: string;
draft_value: string; // content payload (text, json string, html, etc.)
// Context fields
draft_account_id?: string;
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;
open: boolean;
editing_obj?: ae_DataStore | null; // null/undefined = new record
show_account_field?: boolean; // show Account ID field; false for embedded widget
show_for_fields?: boolean; // show For Type + For ID; false for embedded widget
readonly_code?: boolean; // lock the code field
default_account_id?: string; // pre-fill account_id on new records
on_saved?: (result: Record<string, unknown>) => void; // called after a successful save or create
on_deleted?: () => void; // called after a successful delete
}
let {
draft_code = $bindable(''),
draft_name = $bindable(''),
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,
open = $bindable(false),
editing_obj = null,
show_account_field = true,
show_for_fields = true,
readonly_code = false,
original_obj = null,
has_changes = $bindable(false),
default_account_id = '',
on_saved,
on_deleted
}: 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 show_advanced = $state(false);
let submit_status = $state<'idle' | 'processing' | 'saved' | 'error'>('idle');
// Compare each draft field to the original to detect unsaved changes.
// new records (original_obj = null) are always considered dirty so Save is never blocked.
let _has_changes = $derived.by(() => {
if (!original_obj) return true;
const orig_value = original_obj.type === 'json'
? (typeof original_obj.json === 'string' ? original_obj.json : JSON.stringify(original_obj.json ?? '', null, 2))
: (original_obj.text || original_obj.html || '');
// Initialize all drafts whenever the modal opens
$effect(() => {
if (open) {
untrack(() => {
const obj = editing_obj;
draft_code = obj?.code ?? '';
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 (
draft_code !== (original_obj.code ?? '') ||
draft_name !== (original_obj.name ?? '') ||
draft_type !== (original_obj.type ?? 'text') ||
draft_value !== orig_value ||
draft_account_id !== (original_obj.account_id ?? '') ||
draft_enable !== (original_obj.enable !== false) ||
draft_hide !== !!original_obj.hide ||
draft_priority !== !!original_obj.priority ||
draft_sort !== String(original_obj.sort ?? '') ||
draft_group !== (original_obj.group ?? '') ||
draft_notes !== (original_obj.notes ?? '')
draft_code !== (obj.code ?? '') ||
draft_name !== (obj.name ?? '') ||
draft_type !== (obj.type ?? 'text') ||
draft_value !== orig_value ||
draft_account_id !== (obj.account_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 ?? '')
);
});
// Push computed value out to the parent via the bindable prop
$effect(() => { has_changes = _has_changes; });
async function handle_save() {
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>
<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 ───────────────────────────────────────────────────────── -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="label space-y-1">
<span class="text-xs font-bold opacity-70">
Code <span class="text-error-500">*</span>
<span class="font-normal opacity-60">— unique key, snake_case convention</span>
</span>
<input
type="text"
class="input font-mono text-sm"
bind:value={draft_code}
required
readonly={readonly_code}
placeholder="event__my_event__my_store" />
</label>
<label class="label space-y-1">
<span class="text-xs font-bold opacity-70">
Name <span class="text-error-500">*</span>
<span class="font-normal opacity-60">— human-readable label</span>
</span>
<input
type="text"
class="input"
bind:value={draft_name}
required
placeholder="My Data Store" />
</label>
</div>
<form class="flex flex-col gap-4" onsubmit={(e) => { e.preventDefault(); handle_save(); }}>
<!-- ── Type + Account ID ─────────────────────────────────────────────────── -->
<div class="grid grid-cols-1 gap-4 {show_account_field ? 'md:grid-cols-2' : ''}">
<label class="label space-y-1">
<span class="text-xs font-bold opacity-70">
Type
<span class="font-normal opacity-60">— determines how content is stored and rendered</span>
</span>
<select class="select" bind:value={draft_type}>
<option value="text">Text — plain text or string value</option>
<option value="html">HTML — rich markup (CodeMirror source / TipTap visual)</option>
<option value="json">JSON — structured data, parsed on save</option>
<option value="md">Markdown — rendered at display time</option>
<option value="sql">SQL — query string, stored as text</option>
</select>
</label>
{#if show_account_field}
<label class="label space-y-1">
<span class="text-xs font-bold opacity-70">
Account ID
<span class="font-normal opacity-60">— blank = global (shared across all accounts)</span>
</span>
<input
type="text"
class="input font-mono text-sm"
bind:value={draft_account_id}
placeholder="Leave blank for global" />
</label>
{/if}
</div>
<!-- ── For Type + For ID ─────────────────────────────────────────────────── -->
{#if show_for_fields}
<!-- ── Code + Name ─────────────────────────────────────────────────────── -->
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="label space-y-1">
<span class="text-xs font-bold opacity-70">
For Type
<span class="font-normal opacity-60">— polymorphic parent object type</span>
<label class="label">
<span class="text-xs font-bold opacity-70">Code <span class="text-error-500">*</span>
<span class="font-normal opacity-60">— unique key, snake_case convention</span>
</span>
<input
type="text"
class="input font-mono text-sm"
bind:value={draft_for_type}
placeholder="event, event_session, person, journal_entry…" />
bind:value={draft_code}
required
readonly={readonly_code}
placeholder="event__my_event__my_store" />
</label>
<div class="space-y-1">
<span class="text-xs font-bold opacity-70">
For ID
<span class="font-normal opacity-60">— random string ID of the parent object</span>
<label class="label">
<span class="text-xs font-bold opacity-70">Name <span class="text-error-500">*</span>
<span class="font-normal opacity-60">— human-readable label</span>
</span>
{#if is_new}
<input
type="text"
class="input font-mono text-sm"
bind:value={draft_for_id}
placeholder="random string ID" />
<p class="text-[10px] opacity-50">
The backend resolves this string to an integer FK on create.
Once set, For ID cannot be changed via the UI.
</p>
{:else}
<!-- for_id is stored as an integer FK in DB; backend resolves string→int on create
but rejects the string on PATCH. Read-only after creation. -->
<div class="input bg-surface-200-700-token flex items-center font-mono text-sm opacity-70">
{#if draft_for_id}
{draft_for_id}
{:else}
<span class="italic opacity-50">not set</span>
{/if}
</div>
<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.
</p>
{/if}
</div>
</div>
{/if}
<!-- ── Content editor ────────────────────────────────────────────────────── -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-bold opacity-70">
Content
{#if draft_type === 'json'}
<span class="font-normal opacity-60">— parsed as JSON on save; invalid JSON is stored as-is</span>
{:else if draft_type === 'sql'}
<span class="font-normal opacity-60">— stored as raw SQL string; not executed here</span>
{:else if draft_type === 'md'}
<span class="font-normal opacity-60">— Markdown; rendered by the display component</span>
{:else if draft_type === 'html'}
<span class="font-normal opacity-60">— HTML markup; rendered as raw HTML in display</span>
{/if}
</span>
{#if draft_type === 'html'}
<div class="flex items-center gap-1 rounded bg-surface-500/10 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="WYSIWYG visual editor">
<Eye size="10" /> Visual
</button>
</div>
{/if}
<input
type="text"
class="input"
bind:value={draft_name}
required
placeholder="My Data Store" />
</label>
</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>
<!-- ── Advanced section (collapsible) ────────────────────────────────────── -->
<div class="overflow-hidden rounded-lg border border-surface-500/20">
<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"
onclick={() => (show_advanced = !show_advanced)}>
<span>Advanced — Enable · Hide · Priority · Sort · Group · Notes</span>
{#if show_advanced}
<ChevronUp size={12} />
{:else}
<ChevronDown size={12} />
{/if}
</button>
{#if show_advanced}
<div class="space-y-4 p-4">
<!-- Flags -->
<div class="space-y-1">
<p class="text-[10px] font-bold uppercase tracking-widest opacity-40">Flags</p>
<div class="flex flex-wrap gap-x-6 gap-y-2">
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" class="checkbox" bind:checked={draft_enable} />
<span>
Enable
<span class="ml-1 text-[10px] font-normal opacity-50">— record is active and queryable</span>
</span>
</label>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" class="checkbox" bind:checked={draft_hide} />
<span>
Hide
<span class="ml-1 text-[10px] font-normal opacity-50">— suppress from standard display</span>
</span>
</label>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" class="checkbox" bind:checked={draft_priority} />
<span>
Priority
<span class="ml-1 text-[10px] font-normal opacity-50">— float above other records in sort</span>
</span>
</label>
</div>
</div>
<!-- Sort + Group -->
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">
Sort
<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 class="space-y-1">
<span class="text-xs font-bold opacity-70">
Group
<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>
</div>
<!-- Notes -->
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">
Notes
<span class="font-normal opacity-60">— internal reference only; not shown to end users</span>
<!-- ── Type + Account ID ───────────────────────────────────────────────── -->
<div class="grid grid-cols-1 gap-4 {show_account_field ? 'md:grid-cols-2' : ''}">
<label class="label">
<span class="text-xs font-bold opacity-70">Type
<span class="font-normal opacity-60">— determines how content is stored and rendered</span>
</span>
<select class="select" bind:value={draft_type}>
<option value="text">Text — plain text or string value</option>
<option value="html">HTML — rich markup (CodeMirror source / TipTap visual)</option>
<option value="json">JSON — structured data, parsed on save</option>
<option value="md">Markdown — rendered at display time</option>
<option value="sql">SQL — query string, stored as text</option>
</select>
</label>
{#if show_account_field}
<label class="label">
<span class="text-xs font-bold opacity-70">Account ID
<span class="font-normal opacity-60">— blank = global (shared across all accounts)</span>
</span>
<input
type="text"
class="input text-sm"
bind:value={draft_notes}
placeholder="Internal notes or context" />
class="input font-mono text-sm"
bind:value={draft_account_id}
placeholder="Leave blank for global" />
</label>
{/if}
</div>
<!-- ── For Type + For ID ───────────────────────────────────────────────── -->
{#if show_for_fields}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<label class="label">
<span class="text-xs font-bold opacity-70">For Type
<span class="font-normal opacity-60">— polymorphic parent object type</span>
</span>
<input
type="text"
class="input font-mono text-sm"
bind:value={draft_for_type}
placeholder="event, event_session, person, journal_entry…" />
</label>
<div class="space-y-1">
<span class="text-xs font-bold opacity-70">For ID
<span class="font-normal opacity-60">— random string ID of the parent object</span>
</span>
{#if is_new}
<input
type="text"
class="input font-mono text-sm"
bind:value={draft_for_id}
placeholder="random string ID" />
<p class="text-[10px] opacity-50">
Backend resolves this string to an integer FK on create. Once set, For ID cannot be changed via the UI.
</p>
{:else}
<!-- for_id is stored as an integer FK; backend resolves string→int on create
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">
{#if draft_for_id}
{draft_for_id}
{:else}
<span class="italic opacity-50">not set</span>
{/if}
</div>
<p class="text-[10px] opacity-50">
For ID is fixed at creation time. To change it, update directly in phpMyAdmin.
</p>
{/if}
</div>
</div>
{/if}
</div>
</div>
<!-- ── Content editor ─────────────────────────────────────────────────── -->
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-bold opacity-70">
Content
{#if draft_type === 'json'}
<span class="font-normal opacity-60">— parsed as JSON on save; invalid JSON is stored as-is</span>
{:else if draft_type === 'sql'}
<span class="font-normal opacity-60">— stored as raw SQL string; not executed here</span>
{:else if draft_type === 'md'}
<span class="font-normal opacity-60">— Markdown; rendered by the display component</span>
{:else if draft_type === 'html'}
<span class="font-normal opacity-60">— HTML markup; rendered as raw HTML in display</span>
{/if}
</span>
{#if draft_type === 'html'}
<div class="flex items-center gap-1 rounded bg-surface-500/10 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="WYSIWYG visual 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>
<!-- ── Advanced (collapsible) ──────────────────────────────────────────── -->
<div class="overflow-hidden rounded-lg border border-surface-500/20">
<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"
onclick={() => (show_advanced = !show_advanced)}>
<span>Advanced — Enable · Hide · Priority · Sort · Group · Notes</span>
{#if show_advanced}<ChevronUp size={12} />{:else}<ChevronDown size={12} />{/if}
</button>
{#if show_advanced}
<div class="space-y-4 p-4">
<div class="space-y-1">
<p class="text-[10px] font-bold uppercase tracking-widest opacity-40">Flags</p>
<div class="flex flex-wrap gap-x-6 gap-y-2">
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" class="checkbox" bind:checked={draft_enable} />
<span>Enable <span class="ml-1 text-[10px] font-normal opacity-50">— record is active and queryable</span></span>
</label>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" class="checkbox" bind:checked={draft_hide} />
<span>Hide <span class="ml-1 text-[10px] font-normal opacity-50">— suppress from standard display</span></span>
</label>
<label class="flex cursor-pointer items-center gap-2 text-sm">
<input type="checkbox" class="checkbox" bind:checked={draft_priority} />
<span>Priority <span class="ml-1 text-[10px] font-normal opacity-50">— float above other records in sort</span></span>
</label>
</div>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="space-y-1">
<span class="text-xs font-bold opacity-70">Sort <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 class="space-y-1">
<span class="text-xs font-bold opacity-70">Group <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>
</div>
<label class="space-y-1">
<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>
<input type="text" class="input text-sm" bind:value={draft_notes} placeholder="Internal notes or context…" />
</label>
</div>
{/if}
</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">
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)} />