feat(data-stores): bulk rename, wildcard hint, theme fixes

- Add Bulk Rename panel: code filter (% wildcard), find/replace text,
  preview table before apply, sequential PATCH with IDB cache update
- Fix Code filter label/placeholder to show % wildcard syntax
- Add note that API results are scoped to the active account (backend behavior)
- Replace bg-black/5 with bg-surface-500/10 for light/dark compatibility

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-17 14:11:19 -04:00
parent 34736c05a0
commit 752504e173

View File

@@ -4,6 +4,7 @@ import { untrack } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { Modal } from 'flowbite-svelte'; import { Modal } from 'flowbite-svelte';
import { import {
ArrowRight,
Check, Check,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
@@ -12,6 +13,7 @@ import {
Eye, Eye,
Filter, Filter,
LoaderCircle, LoaderCircle,
Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Save, Save,
@@ -69,6 +71,16 @@ let draft_notes = $state('');
let html_edit_mode = $state<'source' | 'visual'>('source'); let html_edit_mode = $state<'source' | 'visual'>('source');
let submit_status = $state<'idle' | 'processing' | 'saved' | 'error'>('idle'); let submit_status = $state<'idle' | 'processing' | 'saved' | 'error'>('idle');
// ── Bulk rename state ─────────────────────────────────────────────────────────
let show_bulk_rename = $state(false);
let rename_filter = $state(''); // code LIKE filter — use % as wildcard
let rename_find_text = $state(''); // literal substring to find within matching codes
let rename_replace_text = $state(''); // literal replacement for find_text
let rename_preview: { id: string; old_code: string; new_code: string }[] = $state([]);
let rename_loading = $state(false);
let rename_apply_status = $state<'idle' | 'applying' | 'done' | 'error'>('idle');
let rename_applied = $state(0);
// ── Search ──────────────────────────────────────────────────────────────────── // ── Search ────────────────────────────────────────────────────────────────────
async function do_search(reset = true) { async function do_search(reset = true) {
if (reset) page_offset = 0; if (reset) page_offset = 0;
@@ -258,6 +270,67 @@ async function handle_delete() {
} }
} }
// ── Bulk rename ───────────────────────────────────────────────────────────────
async function do_rename_preview() {
if (!rename_filter.trim()) return;
rename_loading = true;
rename_preview = [];
rename_apply_status = 'idle';
rename_applied = 0;
const result_li = await api.search_ae_obj({
api_cfg: $ae_api,
obj_type: 'data_store',
search_query: { and: [{ field: 'code', op: 'like', value: rename_filter.trim() }] },
order_by_li: [{ code: 'ASC' }],
limit: 200,
offset: 0,
log_lvl: 0
});
if (result_li) {
const find = rename_find_text.trim();
const repl = rename_replace_text.trim();
rename_preview = result_li
.map((ds: ae_DataStore) => {
const old_code = ds.code ?? '';
const new_code = find ? old_code.split(find).join(repl) : old_code;
return { id: (ds.id ?? (ds as any).data_store_id) as string, old_code, new_code };
})
.filter((r: { id: string; old_code: string; new_code: string }) => r.old_code !== r.new_code);
}
rename_loading = false;
}
async function do_rename_apply() {
if (!rename_preview.length) return;
if (!confirm(`Rename ${rename_preview.length} data store code(s)?\n\nThis cannot be undone.`)) return;
rename_apply_status = 'applying';
rename_applied = 0;
const api_cfg = untrack(() => $ae_api);
for (const item of rename_preview) {
const result = await api.update_ae_obj({
api_cfg,
obj_type: 'data_store',
obj_id: item.id,
fields: { code: item.new_code }
});
if (result) {
rename_applied++;
await db_core.data_store.update(item.id, { code: item.new_code });
}
}
rename_apply_status = rename_applied === rename_preview.length ? 'done' : 'error';
if (rename_apply_status === 'done') {
rename_preview = [];
do_search(false);
}
}
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
function type_badge(type: string | null | undefined) { function type_badge(type: string | null | undefined) {
switch (type) { switch (type) {
@@ -293,13 +366,22 @@ function content_preview(ds: ae_DataStore): string {
<p class="text-xs font-bold tracking-widest uppercase opacity-50">Content &amp; Configuration Storage</p> <p class="text-xs font-bold tracking-widest uppercase opacity-50">Content &amp; Configuration Storage</p>
</div> </div>
</div> </div>
<button <div class="flex gap-2">
type="button" <button
onclick={open_new} type="button"
class="btn preset-filled-primary font-bold shadow-lg" onclick={() => { show_bulk_rename = !show_bulk_rename; }}
class:hidden={!$ae_loc.manager_access}> class="btn preset-tonal-surface font-bold"
<Plus size={16} class="mr-2" /> New Data Store title="Bulk rename data store codes">
</button> <Pencil size={14} class="mr-1" /> Bulk Rename
</button>
<button
type="button"
onclick={open_new}
class="btn preset-filled-primary font-bold shadow-lg"
class:hidden={!$ae_loc.manager_access}>
<Plus size={16} class="mr-2" /> New Data Store
</button>
</div>
</header> </header>
<!-- ── Filter bar ──────────────────────────────────────────────────────── --> <!-- ── Filter bar ──────────────────────────────────────────────────────── -->
@@ -310,18 +392,21 @@ function content_preview(ds: ae_DataStore): string {
<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"> <div class="space-y-1">
<label class="text-xs font-bold opacity-70">Code</label> <label class="text-xs font-bold opacity-70">
Code
<span class="font-normal opacity-60">— use <code>%</code> as wildcard</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>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs font-bold opacity-70"> <label class="text-xs font-bold opacity-70">
Account ID Account ID
<span class="font-normal opacity-60"> type <code>global</code> for null</span> <span class="font-normal opacity-60"><code>global</code> for null</span>
</label> </label>
<input <input
type="search" type="search"
@@ -329,6 +414,7 @@ function content_preview(ds: ae_DataStore): string {
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()} />
<p class="text-[10px] opacity-40">Results are scoped to the active account by the API.</p>
</div> </div>
<div class="space-y-1"> <div class="space-y-1">
<label class="text-xs font-bold opacity-70">For Type</label> <label class="text-xs font-bold opacity-70">For Type</label>
@@ -392,6 +478,104 @@ function content_preview(ds: ae_DataStore): string {
</div> </div>
</div> </div>
<!-- ── Bulk Rename Panel ─────────────────────────────────────────────────── -->
{#if show_bulk_rename}
<div class="card border-surface-500/10 space-y-4 border p-4 shadow-xl preset-tonal-surface">
<div class="flex items-center gap-2 text-[10px] font-bold tracking-widest uppercase opacity-50">
<Pencil size={11} /> Bulk Rename Codes
</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>
<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>
<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>
<input
type="text"
bind:value={rename_replace_text}
placeholder="new_code"
class="input input-sm w-full font-mono text-xs" />
</div>
</div>
<div class="flex items-center gap-2">
<button
type="button"
onclick={do_rename_preview}
class="btn btn-sm preset-tonal-primary"
disabled={rename_loading || !rename_filter.trim()}>
{#if rename_loading}
<LoaderCircle size={13} class="mr-1 animate-spin" />
{:else}
<Search size={13} class="mr-1" />
{/if}
Preview Changes
</button>
{#if rename_preview.length > 0}
<button
type="button"
onclick={do_rename_apply}
class="btn btn-sm preset-filled-warning font-bold"
disabled={rename_apply_status === 'applying'}>
{#if rename_apply_status === 'applying'}
<LoaderCircle size={13} class="mr-1 animate-spin" /> Applying {rename_applied}/{rename_preview.length}
{:else if rename_apply_status === 'done'}
<Check size={13} class="mr-1" /> Done
{:else}
<Pencil size={13} class="mr-1" /> Apply to {rename_preview.length} record{rename_preview.length !== 1 ? 's' : ''}
{/if}
</button>
{/if}
{#if rename_apply_status === 'error'}
<span class="text-xs text-error-500">Applied {rename_applied}/{rename_preview.length} — check console for errors.</span>
{/if}
</div>
{#if rename_preview.length > 0}
<div class="overflow-x-auto rounded-lg border border-surface-500/20">
<table class="w-full text-xs">
<thead>
<tr class="border-b border-surface-500/20 text-left text-[10px] font-bold uppercase tracking-wider opacity-50">
<th class="px-3 py-2">Current Code</th>
<th class="px-3 py-2"></th>
<th class="px-3 py-2">New Code</th>
</tr>
</thead>
<tbody>
{#each rename_preview as item (item.id)}
<tr class="border-b border-surface-500/10">
<td class="px-3 py-1.5 font-mono opacity-60">{item.old_code}</td>
<td class="px-2 py-1.5 opacity-40"><ArrowRight size={12} /></td>
<td class="px-3 py-1.5 font-mono font-bold text-success-600 dark:text-success-400">{item.new_code}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else if !rename_loading && rename_filter.trim()}
<p class="text-xs opacity-40">No changes to preview — either no records matched or find/replace text produced no differences.</p>
{/if}
</div>
{/if}
<!-- ── Results ─────────────────────────────────────────────────────────── --> <!-- ── Results ─────────────────────────────────────────────────────────── -->
{#if results.length > 0} {#if results.length > 0}
<div class="card preset-tonal-surface border-surface-500/10 border shadow-xl"> <div class="card preset-tonal-surface border-surface-500/10 border shadow-xl">
@@ -611,7 +795,7 @@ function content_preview(ds: ae_DataStore): string {
</div> </div>
<!-- Flags row --> <!-- Flags row -->
<div class="border-surface-500/20 flex flex-wrap items-center gap-x-6 gap-y-2 rounded-lg border bg-black/5 px-4 py-3"> <div class="border-surface-500/20 flex flex-wrap items-center gap-x-6 gap-y-2 rounded-lg border bg-surface-500/10 px-4 py-3">
<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} />
Enable Enable
@@ -649,7 +833,7 @@ function content_preview(ds: ae_DataStore): string {
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-xs font-bold opacity-70">Content</span> <span class="text-xs font-bold opacity-70">Content</span>
{#if draft_type === 'html'} {#if draft_type === 'html'}
<div class="flex items-center gap-1 rounded bg-black/5 p-0.5"> <div class="flex items-center gap-1 rounded bg-surface-500/10 p-0.5">
<button <button
type="button" type="button"
class="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-bold uppercase transition-all" class="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-bold uppercase transition-all"