Files
OSIT-AE-App-Svelte/src/lib/elements/element_data_store.svelte
Scott Idem 8146316aaf fix(elements): button icons, titles, and dead code cleanup in data_store + field_editor_new
element_data_store.svelte:
- Source/Visual HTML mode toggles: add Code/Eye icons and title attributes
- Cancel button: add X icon and title="Discard changes and close"
- Delete button: add title="Permanently delete this data store — cannot be undone"
- Save button: add title, and Check icon for updated/created success state
  (Check was imported but unused; now put to work matching field editor pattern)
- Remove Eraser import (unused)
- Remove ds_submit_results $state (declared but never read in template —
  submit status already tracked via $ae_sess.ds.submit_status; drop the
  assignment in handle_submit_form too)

element_ae_obj_field_editor_new.svelte:
- Fix duplicate is_editing declaration: user promoted it to a $bindable prop;
  remove the now-conflicting local $state declaration

npx svelte-check: 0 errors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 18:05:59 -04:00

430 lines
17 KiB
Svelte

<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 { 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';
interface Props {
log_lvl?: number;
expire_minutes?: number;
mount_reload_sec?: number;
ds_code: string;
ds_name?: null | string;
ds_type?: string;
for_type?: null | string;
for_id?: null | string;
class_li?: string;
display?: string;
try_cache?: boolean;
hide?: boolean;
show_edit?: boolean;
show_edit_btn?: boolean;
show_view?: boolean;
ds_loaded?: boolean;
debug?: boolean;
ds_loading_status?: string;
val_sql?: null | any;
}
let {
log_lvl = 0,
expire_minutes = 15,
mount_reload_sec = 0,
ds_code,
ds_name = null,
ds_type = 'text',
for_type = null,
for_id = null,
class_li = '',
display = undefined as string | undefined,
try_cache = true,
hide = false,
show_edit = $bindable(false),
show_edit_btn = false,
show_view = $bindable(true),
ds_loaded = $bindable(false),
debug = false,
ds_loading_status = $bindable('starting'),
val_sql = $bindable(null)
}: Props = $props();
// Local reactive state
let trigger: null | string = $state(null);
let draft_value = $state('');
let html_edit_mode = $state<'source' | 'visual'>('source');
// Dexie LiveQuery for data store
let lq__ds_obj = $derived(
liveQuery(async () => {
const current_code = ds_code;
const account_id = $ae_loc.account_id;
const current_for_type = for_type;
const current_for_id = for_id;
if (!current_code) return null;
const results = await db_core.data_store
.where('code')
.equals(current_code)
.toArray();
if (!results || results.length === 0) return null;
// Sort by specificity
results.sort((a, b) => {
const a_context = current_for_id && a.for_id === current_for_id && a.for_type === current_for_type ? 1 : 0;
const b_context = current_for_id && b.for_id === current_for_id && b.for_type === current_for_type ? 1 : 0;
if (a_context !== b_context) return b_context - a_context;
const a_account = account_id && a.account_id === account_id ? 1 : 0;
const b_account = account_id && b.account_id === account_id ? 1 : 0;
if (a_account !== b_account) return b_account - a_account;
const a_time = new Date(a.updated_on || a.created_on || 0).getTime();
const b_time = new Date(b.updated_on || b.created_on || 0).getTime();
return b_time - a_time;
});
return results[0];
})
);
// Sync status and bound props when the live data changes
$effect(() => {
const entry = $lq__ds_obj as ae_DataStore | null;
untrack(() => {
ds_loaded = !!entry;
if (entry) {
ds_loading_status = 'loaded';
if (ds_type === 'sql') {
val_sql = entry.text || entry.html || null;
}
// Initialize draft_value when not editing
if (!show_edit) {
draft_value = entry.type === 'json'
? (typeof entry.json === 'string' ? entry.json : JSON.stringify(entry.json, null, 2))
: (entry.text || entry.html || '');
}
}
});
});
// Context Change Guard
$effect(() => {
void for_id; void for_type; void ds_code;
untrack(() => { ds_loading_status = 'starting'; });
});
$effect(() => {
const account_id = $slct.account_id;
const api_ready = !!$ae_api?.base_url;
const entry = $lq__ds_obj as ae_DataStore | null | undefined;
if (!browser || !account_id || !api_ready || ds_loading_status !== 'starting') return;
const entry_is_stale_account = entry?.account_id !== null && entry?.account_id !== account_id;
if (!entry || entry_is_stale_account) {
trigger = 'load__ds__code';
}
});
$effect(() => {
if (trigger === 'load__ds__code') {
untrack(() => {
trigger = null;
load_data_store();
});
}
});
onMount(() => {
if (mount_reload_sec > 0) {
setTimeout(() => { trigger = 'load__ds__code'; }, Math.floor(Math.random() * mount_reload_sec * 1000));
}
});
async function load_data_store() {
if (ds_loading_status === 'loading') return;
ds_loading_status = 'loading';
const api_cfg = untrack(() => $ae_api);
try {
let ds_results = await api.get_data_store({ api_cfg, code: ds_code, for_type, for_id, log_lvl });
const is_error = ds_results?.meta?.success === false;
const status_code = ds_results?.meta?.status_code || (ds_results === false ? 500 : 200);
if (!ds_results || is_error || [404, 403, 401].includes(status_code)) {
ds_results = await api.get_data_store({ api_cfg, code: ds_code, no_account_id: true, log_lvl });
}
const ds_id = ds_results?.data_store_id || ds_results?.id;
if (ds_results && ds_id) {
const text_val = ds_results.text || '';
const json_val = ds_results.json || (ds_results.json_str ? JSON.parse(ds_results.json_str) : null);
const ds_to_save: ae_DataStore = {
...ds_results,
id: ds_id,
data_store_id: ds_results.data_store_id || ds_id,
account_id: ds_results.account_id || null,
updated_on: ds_results.updated_on || new Date().toISOString(),
text: text_val,
html: text_val,
json: json_val
};
await db_core.data_store.put(ds_to_save);
} else {
ds_loading_status = 'not found';
}
} catch (err) {
console.error(`ae_e_data_store [${ds_code}]: Fetch failed.`, err);
ds_loading_status = 'error';
}
}
async function handle_submit_form(event: Event) {
const target = event.target as HTMLFormElement;
$ae_sess.ds.submit_status = 'processing';
const form_data = new FormData(target);
const data_store_di = ae_util.extract_prefixed_form_data({
prefix: null,
form_data,
trim_values: true,
bool_tf_str: true
});
const data_store_do: key_val = {
code: data_store_di.ds_code ?? ds_code,
name: data_store_di.ds_name ?? ds_name,
type: data_store_di.ds_type ?? ds_type,
for_type: data_store_di.ds_for_type ?? null,
for_id: data_store_di.ds_for_id ?? null,
enable: data_store_di.ds_enable ?? true,
account_id: data_store_di.ds_use_account_id ? (data_store_di.ds_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);
const api_call = $lq__ds_obj?.id
? api.update_ae_obj({ api_cfg, obj_type: 'data_store', obj_id: $lq__ds_obj.id, fields: data_store_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;
}
}
</script>
<div
class="ae__elem__data_store relative {class_li}"
class:hidden={hide}
style={display ? `display: ${display}` : undefined}>
{#if $lq__ds_obj}
{#if debug || $ae_loc.debug === 'debug'}
<div class="preset-tonal-surface mb-2 rounded-lg p-2 text-[10px]">
<div class="flex items-center gap-1 font-bold uppercase opacity-50 mb-1">
<Database size="12" /> Debug Info
</div>
<pre class="overflow-x-auto">ID: {$lq__ds_obj.id} | Code: {$lq__ds_obj.code} | Type: {$lq__ds_obj.type} | Account: {$lq__ds_obj.account_id || 'Global'}</pre>
</div>
{/if}
<Modal
title="{$lq__ds_obj.name || 'Unnamed'} - {$lq__ds_obj.code}"
bind:open={show_edit}
autoclose={false}
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" value={$lq__ds_obj.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" value={$lq__ds_obj.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={$lq__ds_obj.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" checked={!!$lq__ds_obj.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 $lq__ds_obj.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 $lq__ds_obj.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 $lq__ds_obj.type === 'json' || $lq__ds_obj.type === 'sql' || $lq__ds_obj.type === 'md'}
<AE_Comp_Editor_CodeMirror bind:content={draft_value} placeholder="Enter {$lq__ds_obj.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={() => (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"
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 $lq__ds_obj.type === 'html' && $lq__ds_obj.html}
{@html $lq__ds_obj.html}
{:else if $lq__ds_obj.type === 'text' && $lq__ds_obj.text}
<div class="whitespace-pre-wrap">{$lq__ds_obj.text}</div>
{:else if $lq__ds_obj.type === 'sql' && $lq__ds_obj.text}
{#if debug}<div class="preset-tonal-surface rounded p-2 font-mono text-xs opacity-50"><span class="font-bold">SQL:</span> {$lq__ds_obj.text}</div>{/if}
{/if}
{/if}
{#if $ae_loc.edit_mode && ($ae_loc.manager_access || (show_edit_btn && $ae_loc.administrator_access))}
<button
type="button"
class="btn-icon btn-icon-sm preset-tonal-warning absolute top-0 right-0 z-10 opacity-20 transition-opacity hover:opacity-100"
ondblclick={() => { show_edit = true; }}
title="Edit Data Store: {ds_code}">
<SquarePen size="14" />
</button>
{/if}
{:else if ds_loading_status === 'not found'}
{#if $ae_loc.administrator_access || ($ae_loc.trusted_access && $ae_loc.edit_mode)}
<div class="preset-tonal-surface flex items-center gap-2 rounded border-2 border-dashed p-3 text-xs opacity-60">
<Info size="14" class="text-warning-500" />
<span class="font-bold">Data Store not found:</span>
<code class="font-mono text-primary-500">{ds_code}</code>
</div>
{/if}
{/if}
{#if ds_loading_status === 'loading'}
<div class="absolute bottom-0 left-0 p-1 opacity-50">
<LoaderCircle size="14" class="animate-spin text-primary-500" />
</div>
{/if}
</div>
<style>
.ae__elem__data_store :global(.btn-icon-sm) { width: 24px; height: 24px; display: flex; align-items: center; justify-content: center; padding: 0; }
</style>