feat(framework): implement AE Obj Field Editor v3 and test playground

- Created consolidated AE_Obj_Field_Editor_V3 component using Svelte 5 Runes.
- Standardized on V3 CRUD API (PATCH /v3/crud/{obj_type}/{obj_id}).
- Implemented local state guards to prevent reactivity loops.
- Added support for multiple field types (text, textarea, select, checkbox, tiptap).
- Created a dedicated testing playground with real Demo account data.
- Updated the PROJECT_AE_OBJECT_FIELD_EDITOR_V3_UPGRADE.md plan.
This commit is contained in:
Scott Idem
2026-02-13 16:07:21 -05:00
parent 6bfd13f52c
commit 3e83890932
3 changed files with 542 additions and 38 deletions

View File

@@ -0,0 +1,285 @@
<script lang="ts">
// import { browser } from '$app/environment';
import { untrack } from 'svelte';
import { LoaderCircle, SquarePen, Save, X, Trash2, Check, CircleAlert } from 'lucide-svelte';
import type { key_val } from '$lib/stores/ae_stores';
import { ae_api, ae_loc } from '$lib/stores/ae_stores';
import { api } from '$lib/api/api';
import AE_Comp_Editor_TipTap from '$lib/elements/AE_Comp_Editor_TipTap.svelte';
interface Props {
// Core Identifiers
object_type: string;
object_id: string;
field_name: string;
// Value Handling
current_value: any;
field_type?: 'text' | 'textarea' | 'select' | 'tiptap' | 'checkbox' | 'date' | 'datetime';
allow_null?: boolean;
// Select Options
select_options?: key_val; // { value: label }
// UI Configuration
edit_label?: string;
display_block?: boolean;
display_absolute_edit?: boolean;
placeholder?: string;
class_li?: string;
textarea_rows?: number;
// Behavior
object_reload?: boolean; // SWR pattern
log_lvl?: number;
// Callbacks
on_success?: (data: any) => void;
on_error?: (error: any) => void;
// Snippets
children?: import('svelte').Snippet;
}
let {
object_type,
object_id,
field_name,
current_value = $bindable(),
field_type = 'text',
allow_null = false,
select_options = {},
edit_label = 'Edit Field',
display_block = false,
display_absolute_edit = false,
placeholder = 'Enter value...',
class_li = '',
textarea_rows = 4,
object_reload = true,
log_lvl = 0,
on_success,
on_error,
children
}: Props = $props();
// Internal State
let is_editing = $state(false);
let patch_status = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
let error_message = $state('');
let draft_value = $state(current_value);
// Sync draft with current_value when not editing
$effect(() => {
if (!is_editing) {
untrack(() => {
draft_value = current_value;
});
}
});
async function handle_patch() {
if (log_lvl) console.log(`AE Field Editor V3: Patching ${object_type}.${field_name}...`);
patch_status = 'processing';
error_message = '';
try {
const result = await api.update_ae_obj_v3({
api_cfg: $ae_api,
obj_type: object_type,
obj_id: object_id,
fields: {
[field_name]: draft_value
},
log_lvl
});
if (result) {
patch_status = 'success';
current_value = draft_value;
if (on_success) on_success(result);
// Close edit mode after a brief success indicator
setTimeout(() => {
if (patch_status === 'success') {
patch_status = 'idle';
is_editing = false;
}
}, 1000);
} else {
throw new Error('No data returned from update.');
}
} catch (error: any) {
console.error('AE Field Editor V3: Patch failed.', error);
patch_status = 'error';
error_message = error?.message || 'Update failed.';
if (on_error) on_error(error);
}
}
function cancel_edit() {
draft_value = current_value;
is_editing = false;
patch_status = 'idle';
error_message = '';
}
function toggle_edit() {
if (is_editing) cancel_edit();
else is_editing = true;
}
</script>
<div
class="ae_field_editor_v3 group relative {class_li}"
class:block={display_block}
class:inline-block={!display_block}
>
<!-- VIEW MODE -->
<div class="view_wrapper flex items-center gap-2" class:hidden={is_editing}>
<div class="content_render grow">
{#if children}
{@render children()}
{:else if field_type === 'checkbox'}
<span class="badge {current_value ? 'variant-filled-success' : 'variant-soft-surface'}">
{current_value ? 'Enabled' : 'Disabled'}
</span>
{:else if field_type === 'tiptap'}
<div class="prose dark:prose-invert max-w-none">
{@html current_value || '<span class="opacity-50 italic">Empty</span>'}
</div>
{:else}
<span class:opacity-50={!current_value}>
{current_value || 'Not set'}
</span>
{/if}
</div>
<!-- Edit Trigger (Visible on Hover or Edit Mode) -->
{#if $ae_loc.edit_mode}
<button
type="button"
class="btn-icon btn-icon-sm variant-soft-warning opacity-0 group-hover:opacity-100 transition-opacity"
onclick={toggle_edit}
title="Edit {field_name}"
>
<SquarePen size="14" />
</button>
{/if}
</div>
<!-- EDIT MODE -->
{#if is_editing}
<div
class="edit_wrapper p-3 border-2 border-dashed border-warning-500/50 rounded-lg bg-surface-50-950 shadow-xl z-50"
class:absolute={display_absolute_edit}
class:top-0={display_absolute_edit}
class:left-0={display_absolute_edit}
class:w-full={display_absolute_edit}
>
<header class="flex justify-between items-center mb-2">
<span class="text-xs font-bold uppercase tracking-wider opacity-60">{edit_label || field_name}</span>
<div class="flex gap-1">
<button
type="button"
class="btn-icon btn-icon-sm variant-soft-surface"
onclick={cancel_edit}
disabled={patch_status === 'processing'}
>
<X size="14" />
</button>
</div>
</header>
<div class="input_container mb-3">
{#if field_type === 'textarea'}
<textarea
bind:value={draft_value}
rows={textarea_rows}
class="textarea"
{placeholder}
></textarea>
{:else if field_type === 'select'}
<select bind:value={draft_value} class="select">
{#if allow_null}
<option value={null}>-- None --</option>
{/if}
{#each Object.entries(select_options) as [val, label]}
<option value={val}>{label}</option>
{/each}
</select>
{:else if field_type === 'checkbox'}
<label class="flex items-center space-x-2">
<input type="checkbox" bind:checked={draft_value} class="checkbox" />
<span>{draft_value ? 'Enabled' : 'Disabled'}</span>
</label>
{:else if field_type === 'tiptap'}
<AE_Comp_Editor_TipTap bind:content={draft_value} {placeholder} />
{:else if field_type === 'date'}
<input type="date" bind:value={draft_value} class="input" />
{:else if field_type === 'datetime'}
<input type="datetime-local" bind:value={draft_value} class="input" />
{:else}
<input
type="text"
bind:value={draft_value}
class="input"
{placeholder}
onkeydown={(e) => e.key === 'Enter' && handle_patch()}
/>
{/if}
</div>
<footer class="flex justify-between items-center">
<div class="status_indicator text-xs">
{#if patch_status === 'processing'}
<span class="flex items-center gap-1 text-primary-500">
<LoaderCircle size="12" class="animate-spin" /> Saving...
</span>
{:else if patch_status === 'success'}
<span class="flex items-center gap-1 text-success-500">
<Check size="12" /> Saved
</span>
{:else if patch_status === 'error'}
<span class="flex items-center gap-1 text-error-500" title={error_message}>
<CircleAlert size="12" /> Error
</span>
{/if}
</div>
<div class="actions flex gap-2">
{#if allow_null && draft_value !== null}
<button
type="button"
class="btn btn-sm variant-soft-error"
onclick={() => draft_value = null}
>
Set Null
</button>
{/if}
<button
type="button"
class="btn btn-sm variant-filled-primary"
onclick={handle_patch}
disabled={patch_status === 'processing' || draft_value === current_value}
>
{#if patch_status === 'processing'}
<LoaderCircle size="14" class="animate-spin mr-1" />
{:else}
<Save size="14" class="mr-1" />
{/if}
Save
</button>
</div>
</footer>
</div>
{/if}
</div>
<style>
/* Add any specialized transitions if needed */
.ae_field_editor_v3 :global(.btn-icon-sm) {
width: 24px;
height: 24px;
}
</style>