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`.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions