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:
202
documentation/PROJECT__AE_Obj_Field_Editor_New.md
Normal file
202
documentation/PROJECT__AE_Obj_Field_Editor_New.md
Normal file
@@ -0,0 +1,202 @@
|
||||
# Project: AE Obj Field Editor — `_new` Rewrite
|
||||
|
||||
**Status:** 🟡 Planning
|
||||
**Priority:** Medium — quality-of-life primitive, no event deadline attached
|
||||
**Created:** 2026-06-16
|
||||
**Last Updated:** 2026-06-16
|
||||
**Related:** `BOOTSTRAP__AI_Agent_Quickstart.md`, `AE__Naming_Conventions.md`
|
||||
|
||||
---
|
||||
|
||||
## Background
|
||||
|
||||
`src/lib/elements/element_ae_obj_field_editor.svelte` is the generic inline
|
||||
field editor: given `object_type` + `object_id` + `field_name` + `current_value`,
|
||||
it renders a "click pencil → edit one field → Save" UI and PATCHes just that
|
||||
field via `api.update_ae_obj()`. It's used in 8 call sites today (session_view,
|
||||
presenter_view, location_view, person_view, device list, presentation list,
|
||||
location list, leads manage tab).
|
||||
|
||||
This component already went through one rename cycle — it was originally built
|
||||
as `element_ae_obj_field_editor_v3.svelte` (replacing an older CRUD v1/v2
|
||||
pattern), then renamed to drop the `_v3` suffix once it became canonical. That
|
||||
migration (build new → migrate callers one at a time → delete old → drop
|
||||
suffix) worked well and is the template for this project.
|
||||
|
||||
**Review findings (2026-06-16)** — see `REFERENCE__Common_Agent_Mistakes.md`
|
||||
context and the chat log for full detail:
|
||||
|
||||
1. **Built entirely on Skeleton UI classes** (`btn-icon`, `variant-soft-*`,
|
||||
`variant-filled-*`, `.input`/`.select`/`.textarea`/`.checkbox`, `badge`).
|
||||
Conflicts with the stated Tailwind v4 + Flowbite/ShadCN direction. This is
|
||||
the highest-leverage place to do that swap — fix once in `elements/`,
|
||||
every call site benefits.
|
||||
2. **`object_reload` prop is dead.** Declared, defaults to `true`, commented
|
||||
"SWR pattern" — never read in the script. Every call site compensates by
|
||||
hand-rolling identical `on_success={() => events_func.load_ae_obj_id__*(...)}`
|
||||
boilerplate. Either implement it for real or remove it and document
|
||||
`on_success` as the actual refresh mechanism.
|
||||
3. **Datetime fields need manual format conversion at every call site, only
|
||||
on the way in, never on the way out.** Confirmed in `session_view.svelte`:
|
||||
callers must pre-convert with `to_datetime_local(...)` before passing
|
||||
`current_value` because the component does no normalization between the
|
||||
stored datetime string and `<input type="datetime-local">`'s expected
|
||||
format. On save, the native datetime-local string goes straight to the
|
||||
PATCH body with no reverse conversion. Works today because the backend is
|
||||
forgiving, but the contract is leaky.
|
||||
4. **Select-binding type mismatch is a latent landmine.** `Object.entries()`
|
||||
always yields string `val`s for `<option value>`. Today's usages
|
||||
(`event_location_id`, `poc_person_id`) are string IDs already, so it's
|
||||
invisible — but a true numeric/boolean enum field would break the
|
||||
optimistic-clear check (`current_value === draft_value`, strict equality),
|
||||
leaving the field stuck on the optimistic value.
|
||||
5. **Missing field types**: no `email` (no native keyboard/validation), no
|
||||
`url`/`tel`. Original design intent explicitly wanted email support.
|
||||
6. **`current_value`/`draft_value` typed `any`** — no compile-time safety.
|
||||
Svelte 5 supports generics (`<script generics="T">`); this primitive
|
||||
should use them.
|
||||
7. **Minor a11y/polish gaps**: invisible edit-trigger button stays
|
||||
tab-focusable when `$ae_loc.edit_mode` is off; icon-only Save/Cancel
|
||||
buttons lack `aria-label`; error message is tooltip-only (`title` attr,
|
||||
not visible/announced); no Escape-to-cancel; no autofocus on entering
|
||||
edit mode.
|
||||
8. **Coarse-store cost (not a bug, just a known tradeoff)**: reads
|
||||
`$ae_loc.edit_mode` directly in the template. Fine in isolation, but this
|
||||
component is instantiated per-row in list tables, so any unrelated
|
||||
`$ae_loc` write re-renders every instance on the page. Not chasing this
|
||||
now — noting it in case a list ever feels janky.
|
||||
|
||||
What's *not* broken and should be preserved as-is: the optimistic-display
|
||||
state machine (`has_optimistic` / `draft_value` / clearing once `current_value`
|
||||
catches up) is already hardened — it went through a real bug-fix round
|
||||
(layout shift, bindable crash, optimistic display) and works correctly for
|
||||
every field type in production use today.
|
||||
|
||||
---
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Near drop-in replacement.** Same prop contract (names, types, defaults)
|
||||
wherever possible, so migrating a call site is an import swap plus maybe
|
||||
one or two new optional props — not a rewrite of the call site.
|
||||
2. **Don't let "drop-in" block real fixes.** Where the old contract is part
|
||||
of the problem (e.g. `object_reload` doing nothing, datetime conversion
|
||||
being the caller's job), change it — call sites get updated during
|
||||
migration anyway, so this is the moment to fix it properly rather than
|
||||
carry the leak forward.
|
||||
3. **Run both versions in parallel during migration.** The old file stays in
|
||||
place and fully working until every call site has migrated and been
|
||||
verified. No "migrate and pray" — verify each call site, then move to the
|
||||
next.
|
||||
4. **One canonical "_new" rewrite.** Per project naming convention going
|
||||
forward: when rewriting an existing thing, the in-progress replacement is
|
||||
named `_new` (not `_v2`/`_v4`/etc.) regardless of how many older
|
||||
versioned files may have existed historically for other components. There
|
||||
should only ever be one "new version of whatever we're working on" at a
|
||||
time. Once migration is complete, drop the `_new` suffix and delete the
|
||||
old file — exactly as was done for the previous `_v3` → (no suffix) rename.
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Multi-field / batch editing in one widget — out of scope, this stays a
|
||||
single-field editor by design.
|
||||
- JSON sub-field editing (`cfg_json.some_key`) — not a supported field_type
|
||||
today, not adding it here either.
|
||||
- Changing the underlying `api.update_ae_obj()` PATCH semantics.
|
||||
|
||||
---
|
||||
|
||||
## New Component: `element_ae_obj_field_editor_new.svelte`
|
||||
|
||||
### Prop contract changes from the original
|
||||
|
||||
| Prop | Change | Why |
|
||||
|---|---|---|
|
||||
| `object_reload` | **Removed.** | Never implemented; `on_success` is and remains the real refresh hook. Removing rather than silently leaving a dead, misleading prop. |
|
||||
| `field_type` | **Add** `'email'` \| `'url'` \| `'tel'`. | Explicitly wanted in the original design, never added. |
|
||||
| `current_value` / `draft_value` | **Typed via generic** (`<script generics="T">`) instead of `any`. | Compile-time safety for callers; this is meant to be used consistently project-wide. |
|
||||
| Datetime conversion | **Owned by the component**, both directions (stored format ↔ `datetime-local`/`date` input format). | Removes the `to_datetime_local(...)` boilerplate currently required at every datetime call site, and fixes the missing reverse conversion on save. |
|
||||
| Select value coercion | **Coerce `draft_value` back to `typeof current_value`** after a select change (or compare via `String()` in the optimistic-clear check). | Prevents the stuck-optimistic-state landmine for any future numeric/boolean enum field. |
|
||||
| Everything else (`object_type`, `object_id`, `field_name`, `allow_null`, `select_options`, `edit_label`, `display_block`, `display_absolute_edit`, `placeholder`, `class_li`, `textarea_rows`, `log_lvl`, `on_success`, `on_error`, `children`) | **Unchanged.** | Drop-in for every call site that doesn't use `object_reload`. |
|
||||
|
||||
### Styling
|
||||
|
||||
Rebuilt on Tailwind v4 utility classes + Flowbite, matching whatever the
|
||||
current non-Skeleton convention is elsewhere in the app (check a recently
|
||||
touched component, e.g. the Pres Mgmt Config page, for the current baseline
|
||||
look). No Skeleton classes (`btn-icon`, `variant-*`, `.input`/`.select`/etc.)
|
||||
in the new file.
|
||||
|
||||
### Polish fixed opportunistically while rewriting
|
||||
|
||||
- `aria-label` on icon-only Save/Cancel/edit-trigger buttons.
|
||||
- `tabindex="-1"` on the edit-trigger button when invisible (`$ae_loc.edit_mode`
|
||||
off), so it's not in the tab order while non-interactive.
|
||||
- Visible inline error text (not just a `title` tooltip) when `patch_status === 'error'`.
|
||||
- Escape key cancels edit mode (mirrors existing Enter-to-save on text/number).
|
||||
- Autofocus the input when entering edit mode.
|
||||
|
||||
---
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. **Build `element_ae_obj_field_editor_new.svelte`** in `src/lib/elements/`,
|
||||
alongside the existing file. Keep the old file completely untouched and
|
||||
working during this phase.
|
||||
2. **Smoke-test in isolation** — add it to `src/routes/testing/ae_obj_field_editor/+page.svelte`
|
||||
(the existing test playground for the old component) alongside the old
|
||||
one, covering every `field_type` including the new `email`/`url`/`tel`.
|
||||
3. **Migrate call sites one at a time**, in this order (simplest/lowest-risk
|
||||
first, datetime-heavy ones last since they exercise the riskiest contract
|
||||
change):
|
||||
- `src/routes/core/person_view.svelte`
|
||||
- `src/routes/events/[event_id]/(pres_mgmt)/device/device/ae_comp__event_device_obj_li.svelte`
|
||||
- `src/routes/events/[event_id]/(pres_mgmt)/locations/ae_comp__event_location_obj_li.svelte`
|
||||
- `src/routes/events/[event_id]/(pres_mgmt)/location/[event_location_id]/location_view.svelte`
|
||||
- `src/routes/events/ae_comp__event_presentation_obj_li.svelte`
|
||||
- `src/routes/events/[event_id]/(leads)/leads/exhibit/[exhibit_id]/ae_tab__manage.svelte`
|
||||
- `src/routes/events/[event_id]/(pres_mgmt)/presenter/[presenter_id]/presenter_view.svelte`
|
||||
- `src/routes/events/[event_id]/(pres_mgmt)/session/[session_id]/session_view.svelte`
|
||||
(has the datetime fields — migrate last, drop the `to_datetime_local(...)`
|
||||
wrapper at the call site once the component owns that conversion)
|
||||
4. **Per call site:** swap the import, remove any now-unnecessary
|
||||
`object_reload` usage (there is none today — confirmed dead) and any
|
||||
manual datetime pre-conversion, run `npx svelte-check`, visually verify
|
||||
the field in the running app (view mode, edit mode, save, optimistic
|
||||
display, error path).
|
||||
5. **Once all 8 call sites + the test playground are migrated and verified**:
|
||||
delete `element_ae_obj_field_editor.svelte` (move to `~/tmp/agents_trash`,
|
||||
never `rm`), rename `element_ae_obj_field_editor_new.svelte` →
|
||||
`element_ae_obj_field_editor.svelte`, update the now-stale import paths
|
||||
left by the rename, run `npx svelte-check` one final time.
|
||||
6. **Update docs**: `AE__Naming_Conventions.md` if a "_new" rewrite
|
||||
convention note belongs there, `BOOTSTRAP__AI_Agent_Quickstart.md` /
|
||||
`REFERENCE__Common_Agent_Mistakes.md` if anything generalizable came out
|
||||
of the datetime/select-coercion fixes, and mark this doc complete.
|
||||
|
||||
Both files coexist for the full duration of steps 1–4. Nothing is deleted
|
||||
until every consumer is migrated and verified.
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None blocking the start of step 1. Revisit field-by-field styling choices
|
||||
(e.g. exact Tailwind/Flowbite input classes) against whatever the most
|
||||
recently styled form in the app is using, since Skeleton phase-out is
|
||||
ongoing and conventions may still be settling.
|
||||
|
||||
---
|
||||
|
||||
## Naming Convention Note (for future rewrites like this)
|
||||
|
||||
Going forward, an in-progress full rewrite of an existing component/module
|
||||
is named `_new` regardless of how many historically-versioned files
|
||||
(`_v1`/`_v2`/`_v3`/etc.) may exist elsewhere in the codebase for unrelated
|
||||
components. There should be at most one "new version of the thing we're
|
||||
currently rewriting" in flight at a time. Once migration completes, the
|
||||
suffix is dropped and the old file is deleted — there is no permanent `_new`
|
||||
file left behind, just like there's no permanent `_v3` file left behind from
|
||||
the previous round of this same component.
|
||||
@@ -55,6 +55,7 @@ Use this file as the routing map for project documentation.
|
||||
- `documentation/PROJECT__IDAA_Stores_Svelte5_Migration_2026.md`
|
||||
- `documentation/PROJECT__AE_Events_PressMgmt_Config_Cleanup.md`
|
||||
- `documentation/PROJECT__AE_Site_Passcode_Security.md`
|
||||
- `documentation/PROJECT__AE_Obj_Field_Editor_New.md`
|
||||
|
||||
## 6) Active Proposals
|
||||
|
||||
|
||||
@@ -49,6 +49,12 @@ Axonius DC (June 9) is done — the show happened and the badge layout work that
|
||||
|
||||
---
|
||||
|
||||
## 🚧 AE Obj Field Editor — `_new` Rewrite (planning, 2026-06-16)
|
||||
|
||||
`element_ae_obj_field_editor.svelte` is getting a parallel-run rewrite: Skeleton UI → Tailwind/Flowbite, removes the dead `object_reload` prop, fixes datetime format conversion (currently the caller's job, one-directional), fixes a latent select-binding type-coercion landmine, adds `email`/`url`/`tel` field types, adds generics for `current_value`/`draft_value`. Both versions run side by side until all 8 call sites are migrated and verified; see `PROJECT__AE_Obj_Field_Editor_New.md` for the full plan and migration order. Not started yet — no code written.
|
||||
|
||||
---
|
||||
|
||||
## 🚧 V3 CRUD Migration (Surgical Cleanup)
|
||||
Finalizing the 100% adoption of V3 Standard endpoints and retirement of legacy wrappers.
|
||||
|
||||
|
||||
534
src/lib/elements/element_ae_obj_field_editor_new.svelte
Normal file
534
src/lib/elements/element_ae_obj_field_editor_new.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user