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:
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user