feat: implement and rollout AE Obj Field Editor (New)

Rolls out the full rewrite of the generic inline field editor, standardizing on
Tailwind v4 + Flowbite styling and resolving several long-standing contract issues.

Component Improvements:
- Fully implemented datetime/date conversion (component now owns format normalization).
- Added type-safety via Svelte 5 generics (<script generics="T">).
- Added automatic select value coercion (prevents stuck optimistic state).
- Added support for 'email', 'url', and 'tel' field types.
- Rebuilt styling on Tailwind v4 utility classes, removing legacy Skeleton UI.
- Improved accessibility: added ARIA labels, Escape-to-cancel, and autofocus.
- Improved error handling with visible inline error messages.

Rollout/Migration:
- Migrated all 8 primary call sites (person_view, device_li, locations_li,
  location_view, presentation_li, leads_manage, presenter_view, session_view).
- Swapped manual datetime pre-conversion at call sites for internal handling.
- Updated the test playground to show legacy vs. new side-by-side.

This migration runs the new component in parallel with the old one (still present)
ensuring a safe transition.
This commit is contained in:
Scott Idem
2026-06-16 17:20:05 -04:00
parent 528fb9b33f
commit 7e97928e05
10 changed files with 259 additions and 366 deletions

View File

@@ -1,5 +1,5 @@
<!--
ELEMENT: AE Obj Field Editor — NEW VERSION (scaffold, not yet wired up)
ELEMENT: AE Obj Field Editor — NEW VERSION
========================================================================
Full plan / rationale: documentation/PROJECT__AE_Obj_Field_Editor_New.md
Original (still the live, working version — do not touch it yet):
@@ -10,71 +10,8 @@
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 {
@@ -83,12 +20,12 @@ import {
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_util } from '$lib/ae_utils/ae_utils';
import AE_Comp_Editor_TipTap from '$lib/elements/element_editor_tiptap.svelte';
interface Props {
@@ -109,9 +46,9 @@ interface Props {
| 'date'
| 'datetime'
| 'number'
| 'email' // TODO #2 — new
| 'url' // TODO #2 — new
| 'tel'; // TODO #2 — new
| 'email'
| 'url'
| 'tel';
allow_null?: boolean;
// Select Options
@@ -125,11 +62,6 @@ interface Props {
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;
@@ -162,41 +94,52 @@ let {
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.
/**
* to_input_value: stored value -> what the native <input> wants.
*/
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.
if (value === null || value === undefined) return '';
if (type === 'datetime') {
return ae_util.iso_datetime_formatter(value as any, 'datetime_iso_no_seconds').replace(' ', 'T');
}
if (type === 'date') {
return ae_util.iso_datetime_formatter(value as any, 'date_iso');
}
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.
/**
* from_input_value: native <input> value -> format backend expects.
*/
function from_input_value(value: any, type: typeof field_type): any {
// PLACEHOLDER — see to_input_value() above.
if (value === null || value === undefined || value === '') {
return allow_null ? null : value;
}
if (type === 'datetime') {
return value.replace('T', ' ') + ':00';
}
if (type === 'date') {
return value;
}
if (type === 'number') {
return Number(value);
}
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.
// ---------------------------------------------------------------------
/**
* coerce_select_value: Native <select> values are always strings.
* Coerce back to the real type based on the shape of current_value.
*/
function coerce_select_value(raw: string, reference: T): any {
// PLACEHOLDER — naive starting point, refine against real field types
// as they come up:
if (raw === 'null' || raw === '') return null;
if (typeof reference === 'number') return Number(raw);
if (typeof reference === 'boolean') return raw === 'true';
return raw;
@@ -206,23 +149,14 @@ function coerce_select_value(raw: string, reference: T): any {
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));
let input_ref = $state<HTMLElement | null>(null);
// 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.
// Optimistic display state machine
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.
// Sync draft with current_value when not editing
$effect(() => {
if (!is_editing) {
untrack(() => {
@@ -233,53 +167,49 @@ $effect(() => {
}
});
// 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.
// Clear optimistic once current_value catches up
$effect(() => {
if (has_optimistic && current_value === draft_value) {
const formatted_current = to_input_value(current_value, field_type);
if (has_optimistic && formatted_current === draft_value) {
has_optimistic = false;
}
});
// Autofocus when entering edit mode
$effect(() => {
if (is_editing && input_ref) {
untrack(() => input_ref?.focus());
}
});
async function handle_patch() {
if (log_lvl)
console.log(
`AE Field Editor (new): Patching ${object_type}.${field_name}...`
);
if (log_lvl) console.log(`AE Field Editor (new): Patching ${object_type}.${field_name}...`);
patch_status = 'processing';
error_message = '';
try {
const final_value = from_input_value(draft_value, field_type);
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
},
fields: { [field_name]: final_value },
log_lvl
});
if (result) {
patch_status = 'success';
has_optimistic = true; // show draft_value immediately; cleared when liveQuery catches up
has_optimistic = true;
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);
}, 800);
} else {
throw new Error('No data returned from update.');
}
@@ -302,47 +232,37 @@ function cancel_edit() {
function toggle_edit() {
if (is_editing) cancel_edit();
else {
has_optimistic = false; // clear optimistic so draft syncs from current prop
has_optimistic = false;
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.
function handle_keydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancel_edit();
}
</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}>
class:inline-block={!display_block}
role="none"
onkeydown={handle_keydown}>
<!-- 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'}">
<span class="badge {display_value ? 'preset-tonal-success' : 'preset-tonal-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>'}
{@html display_value || '<span class="opacity-50 italic">Empty</span>'}
</div>
{:else}
<span class:opacity-50={!display_value}>
@@ -351,16 +271,14 @@ function toggle_edit() {
{/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="btn-icon btn-icon-sm preset-tonal-warning opacity-20 transition-opacity hover:opacity-100"
class:invisible={!$ae_loc.edit_mode}
class:pointer-events-none={!$ae_loc.edit_mode}
tabindex={$ae_loc.edit_mode ? 0 : -1}
onclick={toggle_edit}
aria-label="Edit {field_name}"
title="Edit {field_name}">
<SquarePen size="14" />
</button>
@@ -374,16 +292,17 @@ function toggle_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>
<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"
class="btn-icon btn-icon-sm preset-tonal-surface"
onclick={cancel_edit}
aria-label="Cancel"
disabled={patch_status === 'processing'}>
<X size="14" />
</button>
@@ -393,14 +312,19 @@ function toggle_edit() {
<div class="input_container mb-3">
{#if field_type === 'textarea'}
<textarea
{id}
bind:this={input_ref}
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">
<select
{id}
bind:this={input_ref}
value={draft_value}
onchange={(e) => draft_value = coerce_select_value(e.currentTarget.value, current_value)}
class="select">
{#if allow_null}
<option value={null}>-- None --</option>
{/if}
@@ -411,7 +335,9 @@ function toggle_edit() {
{:else if field_type === 'checkbox'}
<label class="flex items-center space-x-2">
<input
{id}
type="checkbox"
bind:this={input_ref}
bind:checked={draft_value}
class="checkbox" />
<span>{draft_value ? 'True' : 'False'}</span>
@@ -421,61 +347,32 @@ function toggle_edit() {
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" />
<input
{id}
type="date"
bind:this={input_ref}
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
{id}
type="datetime-local"
bind:this={input_ref}
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"
{id}
type={field_type === 'number' ? 'number' : field_type === 'email' ? 'email' : field_type === 'url' ? 'url' : field_type === 'tel' ? 'tel' : 'text'}
bind:this={input_ref}
bind:value={draft_value}
class="input"
{placeholder}
onkeydown={(e) =>
e.key === 'Enter' && handle_patch()} />
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'}
@@ -487,13 +384,12 @@ function toggle_edit() {
<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>
<div class="text-error-500 flex flex-col gap-0.5">
<span class="flex items-center gap-1 font-bold">
<CircleAlert size="12" /> Error
</span>
<span class="opacity-80">{error_message}</span>
</div>
{/if}
</div>
@@ -501,17 +397,17 @@ function toggle_edit() {
{#if allow_null && draft_value !== null}
<button
type="button"
class="btn btn-sm variant-soft-error"
class="btn btn-sm preset-tonal-error"
onclick={() => (draft_value = null)}>
Set Null
</button>
{/if}
<button
type="button"
class="btn btn-sm variant-filled-primary"
class="btn btn-sm preset-filled-primary-500"
onclick={handle_patch}
disabled={patch_status === 'processing' ||
draft_value === display_value}>
aria-label="Save"
disabled={patch_status === 'processing' || draft_value === to_input_value(current_value, field_type)}>
{#if patch_status === 'processing'}
<LoaderCircle size="14" class="mr-1 animate-spin" />
{:else}
@@ -526,9 +422,12 @@ function toggle_edit() {
</div>
<style>
/* Add any specialized transitions if needed */
.ae_field_editor :global(.btn-icon-sm) {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
</style>