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:
@@ -4,6 +4,7 @@ import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { Modal } from 'flowbite-svelte';
|
||||
import {
|
||||
ArrowRight,
|
||||
Check,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
Eye,
|
||||
Filter,
|
||||
LoaderCircle,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Save,
|
||||
@@ -69,6 +71,16 @@ let draft_notes = $state('');
|
||||
let html_edit_mode = $state<'source' | 'visual'>('source');
|
||||
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 ────────────────────────────────────────────────────────────────────
|
||||
async function do_search(reset = true) {
|
||||
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 ───────────────────────────────────────────────────────────────────
|
||||
function type_badge(type: string | null | undefined) {
|
||||
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 & Configuration Storage</p>
|
||||
</div>
|
||||
</div>
|
||||
<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 class="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { show_bulk_rename = !show_bulk_rename; }}
|
||||
class="btn preset-tonal-surface font-bold"
|
||||
title="Bulk rename data store codes">
|
||||
<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>
|
||||
|
||||
<!-- ── 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="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
|
||||
type="search"
|
||||
bind:value={qry_code}
|
||||
placeholder="event__pres_mgmt__*"
|
||||
placeholder="event__pres_mgmt__%"
|
||||
class="input input-sm w-full font-mono text-xs"
|
||||
onkeydown={(e) => e.key === 'Enter' && do_search()} />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-bold opacity-70">
|
||||
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>
|
||||
<input
|
||||
type="search"
|
||||
@@ -329,6 +414,7 @@ function content_preview(ds: ae_DataStore): string {
|
||||
placeholder="random ID or 'global'"
|
||||
class="input input-sm w-full font-mono text-xs"
|
||||
onkeydown={(e) => e.key === 'Enter' && do_search()} />
|
||||
<p class="text-[10px] opacity-40">Results are scoped to the active account by the API.</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs font-bold opacity-70">For Type</label>
|
||||
@@ -392,6 +478,104 @@ function content_preview(ds: ae_DataStore): string {
|
||||
</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 ─────────────────────────────────────────────────────────── -->
|
||||
{#if results.length > 0}
|
||||
<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>
|
||||
|
||||
<!-- 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">
|
||||
<input type="checkbox" class="checkbox" bind:checked={draft_enable} />
|
||||
Enable
|
||||
@@ -649,7 +833,7 @@ function content_preview(ds: ae_DataStore): string {
|
||||
<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">
|
||||
<div class="flex items-center gap-1 rounded bg-surface-500/10 p-0.5">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 rounded px-2 py-0.5 text-[10px] font-bold uppercase transition-all"
|
||||
|
||||
Reference in New Issue
Block a user