Skip to content

Record-level inline edit: editable highlights + one atomic Save (follow-up to #2402) #2407

Description

@os-zhuang

Follow-up to #2402 (Edit-as-primary CTA + double-click inline edit, merged). Delivered as one issue, two phases.

Problem

  1. Fields are interrelated → per-field save-on-blur is unsafe (bypasses cross-field validation, can persist inconsistent intermediate state). The whole-record batch model from Detail view: Edit as primary CTA; enter inline edit by double-clicking a field (#2401) #2402 is correct; keep one atomic save.
  2. Highlight fields (Status / Health / Budget / End Date / Account) can't be inline-edited. Root cause (confirmed in code): `record:highlights` (→ `HeaderHighlight`) is a separate, read-only page block that registers its field names into `HighlightFieldsContext`; `record:details` dedupes those fields out of the editable body. Inline-edit state (`isInlineEditing` / `editedValues`) lives inside `DetailView` (a child of `record:details`), so the sibling highlights block can't reach it. Net: entering inline edit only makes body fields editable; the most edit-worthy fields (highlights) stay read-only.

Goal

Lift inline edit into a record-level shared mode so the highlights strip and the details body share ONE edit session + ONE atomic, cross-field-validated Save. Fixes (2), honors (1). Long-term: inline edit becomes a reusable record-level primitive (any `record:*` block can opt in), not `DetailView`-private state.

Design

New primitives

  • `InlineEditContext` (@object-ui/react) — a separate context (mirrors `HighlightFieldsContext` so edit-draft churn doesn't re-render every `record:*` consumer). Pure UI state: `editing, canEdit, draft, autoFocusField, saving, error, enter(field?), setField, cancel, reset`. Export `InlineEditProvider` + `useInlineEdit()`.
  • `` (plugin-detail) — extract the edit-input branch from `DetailSection` so `HeaderHighlight` renders identical inputs. Covers every widget it handles today: `SelectField`, `BooleanField`, `LookupField`, `UserField`, `CapabilityMultiSelectField` (feat(app-shell,plugin-detail): permission sets — Studio designs, Setup assigns (ADR-0056) #2403), plain input (number/date/text, ISO coercion), `extractLookupId`, `PermissionFacetLink` (feat(app-shell,plugin-detail): permission sets — Studio designs, Setup assigns (ADR-0056) #2403). Keeps the computed-type gate.
  • `` (plugin-detail) — record-level sticky Save/Cancel bar + atomic save + OCC. On Save: one `dataSource.update(obj, id, draft, { ifMatch: data.updated_at })` → `refresh()` → reset. On 409 → reuse `ConcurrentUpdateDialog`. `draft` holds only user-edited keys → computed/read-only never sent.

Wiring

  • `RecordDetailView` (app-shell): wrap page content in `` inside `RecordContextProvider` (around the highlights+details SchemaRenderer) + render ``.
  • `record-highlights` / `HeaderHighlight`: when `editing`, render `` (value = `draft[name] ?? data[name]`, write via `setField`), pencil/double-click `enter(field)`, computed/readonly + `canEdit` gate, don't hide empty while editing.
  • `record-details` / `DetailView` / `DetailSection`: DetailView consumes `InlineEditContext` instead of local state; drop its Block B bar + batch save. Keep the highlight↔body dedup.

Standalone `DetailView` (drawer / direct usage, e.g. `RecordDetailDrawer`): DetailView always consumes `InlineEditContext`; standalone hosts get a local `` + `` wrapper (no dual-mode branching inside DetailView). This is the main regression surface.

Phasing

  • P1 — lift + atomic save (plumbing). Add `InlineEditContext`; move DetailView (page + standalone) onto it; add `` with the single atomic OCC save; delete DetailView's local batch save. Highlights stay read-only. Isolates the riskiest change (DetailView rewire + save semantics).
  • P2 — editable highlights. Extract ``; make `HeaderHighlight` editable against the shared draft; pencil/double-click on highlight fields. Includes compact-layout UX (a select/lookup/date editor in a narrow highlight column — design expand-on-edit / popover, Salesforce-style).

Each phase is its own PR.

Guardrails preserved

Computed (`formula`/`summary`/`rollup`/`auto_number`) + `readonly` non-editable; `affordances.edit` object-lifecycle gate (via `canEdit`); approval lock; OCC (`ifMatch` + `ConcurrentUpdateDialog`); atomic partial update never writes computed/read-only fields; FLS/permission field filtering unchanged.

Cost & risk

  • Cost: ~7–9 files incl. a foundational shared context + extracting the most complex part of `DetailSection` + a new save bar.
  • Risk: (1) `DetailView` is multi-use — rewiring its state ownership is the Implement visual designer for Object UI schemas #1 regression risk; (2) save semantics change (per-field loop → one atomic write) interacts with backend validation / computed recompute / OCC-conflict UX; (3) editable highlights in a compact strip = layout/UX polish risk.
  • Mitigation: the P1/P2 split isolates the risky plumbing from the visible feature; each phase independently verified.

Acceptance criteria

P1

  • `InlineEditContext` / `InlineEditProvider` / `useInlineEdit` exported from @object-ui/react.
  • Double-click / pencil on a body field enters edit via the shared context; one record-level Save/Cancel bar.
  • Save issues exactly ONE `update` with only edited fields; 409 → ConcurrentUpdateDialog.
  • Standalone DetailView (drawer) still edits + saves.
  • Existing inline-edit tests pass; new tests for atomic save + standalone.

P2

  • Hover/double-click a highlight field (Status/Budget/…) enters the same record edit session, editable in place.
  • Highlight + body edits commit together in ONE Save.
  • Computed/readonly highlight fields expose no editor.
  • Compact-layout editor is legible.

Verification

  • Unit: InlineFieldInput parity; editable highlights; computed/readonly gate; atomic-save assembles one update from `draft`; standalone DetailView.
  • Builds: `plugin-detail` (vite), `app-shell` (tsc), `react`.
  • Browser (:5181, showcase Project): double-click a highlight (Status/Budget) AND a body field → single Save → both persist; Cancel restores; confirm one PATCH carrying only edited fields.

Reuse (don't reinvent)

`HighlightFieldsContext` pattern (RecordContext.tsx); `ConcurrentUpdateDialog` + `isConcurrentUpdateError`; sticky-bar chrome from `DraftPreviewBar.tsx` / `RuntimeDraftBar.tsx`; the update/updateOne/patch + `ifMatch` fallback in `record-details.tsx`.

Activity

  1. os-zhuang commented on Jul 13, 2026

    @os-zhuang
    ContributorAuthor

    Execution note (sequencing): land the <InlineFieldInput> extraction as a standalone, behavior-preserving refactor PR first. It's already covered by the #2402 DetailSection inline-edit tests, so it can go in green with zero UX change, and it shrinks P2's risk surface — P2 then only adds the editable-highlights wiring on top of an already-proven shared input component.

    Suggested order:

    1. (0) <InlineFieldInput> extraction — no-op refactor.
    2. (P1) record-level InlineEditContext + one atomic OCC save (DetailView delegates; standalone wrapped). Highlights still read-only.
    3. (P2) editable highlights on top.
  2. self-assigned this
    on Jul 15, 2026
  3. os-zhuang commented on Jul 16, 2026

    @os-zhuang
    ContributorAuthor

    ✅ Verified end-to-end — closing

    All phases merged: step 0 (#2529), P1 (#2542), P2 (#2549), plus the reference-key follow-up fix (#2587).

    Live browser verification (isolated stack: fresh showcase backend objectstack dev --seed-admin --fresh + console dev from objectui main, showcase Project "Website Relaunch"):

    P2 + #2587 fix

    • Double-click on the 客户/account highlight enters the shared record edit session; the lookup editor hydrates the current value (Northwind chip) and the picker lists candidates (Northwind 已选 / Contoso / Fabrikam / Initech / Globex). Before fix(detail): highlight strip lookup editor honors ObjectStack reference key (#2407) #2587 this showed the "选择..." placeholder and "未找到选项" because the backend serves "reference": "showcase_account" (not reference_to) and HeaderHighlight didn't normalize the key.
    • Edited the account highlight (→ Contoso) AND a body field (Financials 已花费 60000 → 61000) in one session → one Save issued exactly one PATCH /api/v1/data/showcase_project/:id (200) carrying both fields; both values persisted across a full page reload (highlight strip resolves the new label correctly); activity feed logged a single update.
    • Cancel restores: re-entered edit (Contoso chip hydrated — fix works for the new value too), changed 预算 to 999000, hit 取消 → value back to 150,000, no PATCH fired.
    • Compact-layout editors legible: select/lookup/date/number editors render at highlight width with expand-on-edit.

    P1 (verified by merged unit/e2e suites in #2542 + exercised above): shared InlineEditContext session, one atomic record-level save bar, draft holds only edited keys.

    Guardrails observed live: computed/system fields exposed no editor; affordances.edit gate honored.

    Unit coverage: packages/plugin-detail suite (23 files / 211 tests) green, including the new reference-key normalization regression tests from #2587.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions