feat: scaffold element_ae_obj_field_editor_new — parallel-run rewrite of inline field editor

Creates the _new version of the field editor element alongside the working
original (which remains untouched). The _new file starts from the original's
hardened optimistic-update state machine and adds:

- Svelte 5 generics (T) on current_value/draft_value instead of any
- email / url / tel added to the field_type union with edit-mode branches
- object_reload prop removed (was declared, never implemented — on_success
  is and remains the caller's cache-refresh hook)
- to_input_value() / from_input_value() stubs at the two right call sites
  for datetime conversion (both directions, TODO #4 to implement)
- coerce_select_value() stub for select type-mismatch fix (TODO #5)
- Inline TODO mini how-to checked-list mirroring the project doc
- Styling still uses Skeleton classes — tagged for Tailwind/Flowbite swap
  (TODO #6) and a11y gaps marked at their exact markup locations (TODO #7)

Companion files:
- documentation/PROJECT__AE_Obj_Field_Editor_New.md — full plan, migration
  order for all 8 call sites, naming convention note
- documentation/TODO__Agents.md — new entry pointing to the project doc
- documentation/README__Docs_Index.md — project doc added to Active Projects

npx svelte-check: 0 errors, 1 expected benign warning (state_referenced_locally
on the field_type initializer, documented in-file).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-06-16 16:47:36 -04:00
parent 49e3fb18b2
commit 528fb9b33f
4 changed files with 743 additions and 0 deletions

View File

@@ -0,0 +1,534 @@
<!--
ELEMENT: AE Obj Field Editor — NEW VERSION (scaffold, not yet wired up)
========================================================================
Full plan / rationale: documentation/PROJECT__AE_Obj_Field_Editor_New.md
Original (still the live, working version — do not touch it yet):
src/lib/elements/element_ae_obj_field_editor.svelte
This file starts from the original's logic. The optimistic-update state
machine (is_editing / draft_value / has_optimistic / display_value) is
already hardened in production (see commit c3ec0f88e) — copy it forward
as-is. Do not redesign it unless you find an actual bug; it is NOT part
of this rewrite's scope.
MINI HOW-TO — work the TODOs below top to bottom. Each one maps to the
matching numbered item in the project doc's "New Component" section,
which has the full "why". Check them off here as you go so the next
person picking this up can see progress at a glance.
[ ] 1. Generics — `current_value`/`draft_value` are typed `T` already
below via the `generics="T"` script attribute. Confirm nothing
inside the script still needs an `any` cast once you fill in #4/#5.
[ ] 2. field_type — 'email' | 'url' | 'tel' are added to the union below.
Edit-mode branches for them are stubbed in the markup — they're
currently copy-pasted from the 'text' branch with the right
`type=` attribute, which is all they strictly need. Confirm that's
still true once you've picked real input styling (#6).
[ ] 3. object_reload — already removed from Props. If you copy any more
logic over from the original, double check it doesn't reference
object_reload anywhere (it was dead code — on_success is and
remains the real cache-refresh hook for every caller).
[ ] 4. Datetime conversion — THE BIG ONE. The original makes every caller
pre-convert current_value with a `to_datetime_local()` helper
before passing it in, and never converts anything back on save.
This component should own both directions instead. Fill in
`to_input_value()` / `from_input_value()` below — they're called
at the two right spots already (computing draft_value, and right
before the PATCH body is built in handle_patch). Once this works,
the `to_datetime_local(...)` call at each date/datetime call site
goes away during migration (see project doc migration plan).
[ ] 5. Select value coercion — fill in `coerce_select_value()` below and
wire it into the <select> onchange. Today `Object.entries()`
always produces string option values, so a select bound to a
non-string field (number/boolean enum) would get draft_value
stuck out of sync with current_value forever (strict `===` in the
optimistic-clear $effect never resolves). No real-world field hits
this today (current select usages are all string IDs), but fix it
here since it's a one-line landmine waiting for the next one.
[ ] 6. Styling — replace every Skeleton UI class in this file (search for
"btn-icon", "variant-", ".input"/".select"/".textarea"/".checkbox",
"badge") with Tailwind v4 + Flowbite equivalents. Check a recently
styled form (e.g. the Pres Mgmt Config page,
src/routes/events/[event_id]/(pres_mgmt)/pres_mgmt/config/+page.svelte)
for the current house style before inventing new classes.
[ ] 7. Accessibility polish:
- aria-label on the icon-only edit-trigger / Save / Cancel buttons
- tabindex="-1" on the edit-trigger button when it's invisible
(mirrors the existing class:invisible logic)
- visible inline error text when patch_status === 'error', not
just the title="" tooltip it has today
- Escape key cancels edit mode (mirrors the existing Enter-to-save
on text/number/email/url/tel)
- autofocus the input element when entering edit mode
When every box above is checked:
1. Add this component to the test playground
(src/routes/testing/ae_obj_field_editor/+page.svelte) alongside the
old one and exercise every field_type, including the 3 new ones.
2. Start the migration plan in the project doc — call sites, in order,
one at a time, verifying with `npx svelte-check` + a visual check
after each one.
3. Once everything is migrated: delete the old file (to
~/tmp/agents_trash, never rm), rename this file to drop "_new", fix
any leftover import paths, run `npx svelte-check` one last time, mark
the project doc complete.
-->
<script lang="ts" generics="T">
// import { browser } from '$app/environment';
import { untrack } from 'svelte';
import type { Snippet } from 'svelte';
import {
Check,
CircleAlert,
LoaderCircle,
Save,
SquarePen,
Trash2,
X
} 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/element_editor_tiptap.svelte';
interface Props {
// Core Identifiers
id?: string;
object_type: string;
object_id: string;
field_name: string;
// Value Handling
current_value: T;
field_type?:
| 'text'
| 'textarea'
| 'select'
| 'tiptap'
| 'checkbox'
| 'date'
| 'datetime'
| 'number'
| 'email' // TODO #2 — new
| 'url' // TODO #2 — new
| 'tel'; // TODO #2 — new
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;
// NOTE: object_reload was intentionally removed (TODO #3) — it was
// declared in the original component but never read anywhere. Every
// caller already does its own cache refresh via on_success; that stays
// the real mechanism here too.
// Behavior
log_lvl?: number;
// Callbacks
on_success?: (data: any) => void;
on_error?: (error: any) => void;
// Snippets
children?: Snippet;
}
let {
id,
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,
log_lvl = 0,
on_success,
on_error,
children
}: Props = $props();
// ---------------------------------------------------------------------
// TODO #4 — Datetime conversion (and any other future format-mismatch
// field types). Both directions live here so the component owns the
// contract instead of every call site re-implementing it.
// ---------------------------------------------------------------------
// to_input_value: stored value (T, e.g. whatever ae_obj.start_datetime
// looks like coming back from the API) -> what the native <input> wants
// (e.g. "YYYY-MM-DDTHH:mm" for type="datetime-local", "YYYY-MM-DD" for
// type="date"). Called wherever draft_value is set from current_value.
function to_input_value(value: T, type: typeof field_type): any {
// PLACEHOLDER — passes through unchanged. This is the same gap the
// original component has today (callers currently do this conversion
// themselves before passing current_value in — see
// session_view.svelte's to_datetime_local() calls). Implement real
// date/datetime conversion here; leave everything else passed through.
return value;
}
// from_input_value: reverse of the above — native <input> value -> the
// format the backend expects in the PATCH body. Called in handle_patch()
// right before building the `fields` payload.
function from_input_value(value: any, type: typeof field_type): any {
// PLACEHOLDER — see to_input_value() above.
return value;
}
// ---------------------------------------------------------------------
// TODO #5 — Select value coercion. Native <select> values are always
// strings. Coerce back to the real type (based on the shape of
// current_value) so equality checks against current_value still work
// once liveQuery/Dexie returns the saved (typed) value.
// ---------------------------------------------------------------------
function coerce_select_value(raw: string, reference: T): any {
// PLACEHOLDER — naive starting point, refine against real field types
// as they come up:
if (typeof reference === 'number') return Number(raw);
if (typeof reference === 'boolean') return raw === 'true';
return raw;
}
// Internal State
let is_editing = $state(false);
let patch_status = $state<'idle' | 'processing' | 'success' | 'error'>('idle');
let error_message = $state('');
// svelte-check warns "state_referenced_locally" here because field_type is
// only captured at mount time — that's intentional (field_type isn't meant
// to change on a live instance; the $effect below re-derives draft_value
// from current_value on every relevant change anyway). Benign, not a TODO.
let draft_value = $state(to_input_value(current_value, field_type));
// WHY: Optimistic display. After a successful PATCH, liveQuery may not fire
// immediately (or at all if on_success doesn't trigger a Dexie refresh). We
// show draft_value as the display until current_value catches up from
// liveQuery.
// KEEP — hardened in production (commit c3ec0f88e). Don't modify casually.
let has_optimistic = $state(false);
let display_value = $derived(has_optimistic ? draft_value : current_value);
// Sync draft with display_value when not editing.
// Suppress reset if optimistic is active — we already have the right value.
// KEEP — same note as above.
$effect(() => {
if (!is_editing) {
untrack(() => {
if (!has_optimistic) {
draft_value = to_input_value(current_value, field_type);
}
});
}
});
// Clear optimistic once liveQuery catches up (current_value matches what we
// saved). NOTE: with TODO #4 done, current_value and draft_value may be in
// different formats (stored vs. input format) for date/datetime — this
// comparison will need to go through from_input_value(draft_value) or
// to_input_value(current_value) to compare like-for-like. Revisit once #4
// is implemented; this is currently a straight copy of the original logic.
$effect(() => {
if (has_optimistic && current_value === draft_value) {
has_optimistic = false;
}
});
async function handle_patch() {
if (log_lvl)
console.log(
`AE Field Editor (new): Patching ${object_type}.${field_name}...`
);
patch_status = 'processing';
error_message = '';
try {
const result = await api.update_ae_obj({
api_cfg: $ae_api,
obj_type: object_type,
obj_id: object_id,
fields: {
// TODO #4 — should be from_input_value(draft_value, field_type)
// once that's implemented, so date/datetime values get
// converted back to the backend's expected format.
[field_name]: draft_value
},
log_lvl
});
if (result) {
patch_status = 'success';
has_optimistic = true; // show draft_value immediately; cleared when liveQuery catches up
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 (new): Patch failed.', error);
patch_status = 'error';
error_message = error?.message || 'Update failed.';
if (on_error) on_error(error);
}
}
function cancel_edit() {
has_optimistic = false;
draft_value = to_input_value(current_value, field_type);
is_editing = false;
patch_status = 'idle';
error_message = '';
}
function toggle_edit() {
if (is_editing) cancel_edit();
else {
has_optimistic = false; // clear optimistic so draft syncs from current prop
draft_value = to_input_value(current_value, field_type);
is_editing = true;
// TODO #7 — autofocus the input once it's mounted. Easiest path is
// probably a small `use:` action or an `$effect` that calls
// .focus() on a bound element reference once is_editing flips true.
}
}
// TODO #7 — Escape-to-cancel. Add an onkeydown handler (probably on the
// edit_wrapper div, capturing) that calls cancel_edit() on `Escape`,
// alongside the existing per-input Enter-to-save handlers below.
</script>
<!--
TODO #6 — every Skeleton UI class below (btn-icon, variant-*, badge,
.input/.select/.textarea/.checkbox) needs replacing with Tailwind v4 +
Flowbite equivalents. Markup structure/logic otherwise carried forward
from the original as-is.
-->
<div
class="ae_field_editor 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 {display_value
? 'variant-filled-success'
: 'variant-soft-surface'}">
{display_value ? 'True' : 'False'}
</span>
{:else if field_type === 'tiptap'}
<div class="prose dark:prose-invert max-w-none">
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
{@html display_value ||
'<span class="opacity-50 italic">Empty</span>'}
</div>
{:else}
<span class:opacity-50={!display_value}>
{display_value || 'Not set'}
</span>
{/if}
</div>
<!-- Edit Trigger (Visible on Hover or Edit Mode) -->
<!-- WHY: Always render to avoid layout shift when edit_mode toggles.
Use invisible (visibility:hidden, preserves space) when edit_mode is off. -->
<!-- TODO #7 — add aria-label, and tabindex="-1" when invisible. -->
<button
type="button"
class="btn-icon btn-icon-sm variant-soft-warning opacity-20 transition-opacity hover:opacity-100"
class:invisible={!$ae_loc.edit_mode}
class:pointer-events-none={!$ae_loc.edit_mode}
onclick={toggle_edit}
title="Edit {field_name}">
<SquarePen size="14" />
</button>
</div>
<!-- EDIT MODE -->
{#if is_editing}
<div
class="edit_wrapper border-warning-500/50 bg-surface-50-950 z-50 rounded-lg border-2 border-dashed p-3 shadow-xl"
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="mb-2 flex items-center justify-between">
<span
class="text-xs font-bold tracking-wider uppercase opacity-60"
>{edit_label || field_name}</span>
<div class="flex gap-1">
<!-- TODO #7 — aria-label="Cancel" -->
<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'}
<!-- TODO #5 — wire coerce_select_value() into an onchange
handler here instead of relying on bind:value alone. -->
<select bind:value={draft_value} class="select">
{#if allow_null}
<option value={null}>-- None --</option>
{/if}
{#each Object.entries(select_options) as [val, label] (val)}
<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 ? 'True' : 'False'}</span>
</label>
{:else if field_type === 'tiptap'}
<AE_Comp_Editor_TipTap
bind:content={draft_value}
{placeholder} />
{:else if field_type === 'date'}
<!-- TODO #4 — value here should already be in
"YYYY-MM-DD" form via to_input_value(). -->
<input type="date" bind:value={draft_value} class="input" />
{:else if field_type === 'datetime'}
<!-- TODO #4 — value here should already be in
"YYYY-MM-DDTHH:mm" form via to_input_value(). -->
<input
type="datetime-local"
bind:value={draft_value}
class="input" />
{:else if field_type === 'number'}
<input
type="number"
bind:value={draft_value}
class="input"
{placeholder}
onkeydown={(e) =>
e.key === 'Enter' && handle_patch()} />
{:else if field_type === 'email'}
<!-- TODO #2 — confirm this needs nothing more than the
type= swap once styling (#6) is done. -->
<input
type="email"
bind:value={draft_value}
class="input"
{placeholder}
onkeydown={(e) =>
e.key === 'Enter' && handle_patch()} />
{:else if field_type === 'url'}
<input
type="url"
bind:value={draft_value}
class="input"
{placeholder}
onkeydown={(e) =>
e.key === 'Enter' && handle_patch()} />
{:else if field_type === 'tel'}
<input
type="tel"
bind:value={draft_value}
class="input"
{placeholder}
onkeydown={(e) =>
e.key === 'Enter' && handle_patch()} />
{:else}
<input
type="text"
bind:value={draft_value}
class="input"
{placeholder}
onkeydown={(e) =>
e.key === 'Enter' && handle_patch()} />
{/if}
</div>
<footer class="flex items-center justify-between">
<div class="status_indicator text-xs">
{#if patch_status === 'processing'}
<span class="text-primary-500 flex items-center gap-1">
<LoaderCircle size="12" class="animate-spin" /> Saving...
</span>
{:else if patch_status === 'success'}
<span class="text-success-500 flex items-center gap-1">
<Check size="12" /> Saved
</span>
{:else if patch_status === 'error'}
<!-- TODO #7 — show error_message visibly here too,
not just via the title="" tooltip below. -->
<span
class="text-error-500 flex items-center gap-1"
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 === display_value}>
{#if patch_status === 'processing'}
<LoaderCircle size="14" class="mr-1 animate-spin" />
{: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 :global(.btn-icon-sm) {
width: 24px;
height: 24px;
}
</style>