Files
OSIT-AE-App-Svelte/src/lib/elements/element_data_store.svelte

480 lines
19 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 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(
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];
})
);
/**
* 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(() => {
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 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;
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) {
$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);
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;
}
}
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
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="{draft_name || 'Unnamed'} - {draft_code}"
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>
{#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>