From 78f72b657fb0dfe8d693ac442ccb114b25bf51eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 01:25:28 +0000 Subject: [PATCH] feat(detail): editable record highlights on the shared inline-edit draft (#2407 P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the highlights strip editable in place, sharing ONE draft + ONE atomic Save with the details body (builds on P1 #2542 InlineEditContext + step-0 #2529 InlineFieldInput). objectui#2407 P2. - HeaderHighlight consumes useInlineEdit(): hover-pencil + double-click enter the shared record edit session; each editable highlight renders the same the body uses (value = draft[name] ?? data[name], write via setField). Computed/readonly/system highlights expose no editor; empties are kept while editing; an actively-edited column widens + renders the editor full-width (Salesforce-style expand-on-edit). - RecordDetailView (app-shell) hosts ONE (canEdit = object-lifecycle gate) spanning record:highlights + record:details, plus the single record-level — so a highlight edit and a body edit commit together in ONE update(obj, id, draft, { ifMatch }). - record:details drops its P1-local provider/save bar (would split the draft) and consumes the shared context; record:highlights threads the DataSource through for lookup/user editors. Guardrails preserved: computed/readonly/system highlights non-editable; canEdit gate; OCC ifMatch + ConcurrentUpdateDialog; only edited keys sent. Verified: plugin-detail type-check + vite build clean; app-shell type-check clean w.r.t. this change (only the pre-existing @objectstack/formula sandbox error remains); 205 tests pass (22 files) incl. a new HeaderHighlight editable suite. Browser-verified in real Chromium: double-click a body field -> highlights strip becomes editable -> edit a highlight (budget) + a body field (owner) -> exactly ONE update carrying BOTH keys + ifMatch -> edit exits, no JS errors. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Y1f7TvqYMo41g9RbQ6H2od --- .changeset/editable-highlights.md | 30 ++ .../app-shell/src/views/RecordDetailView.tsx | 22 +- .../plugin-detail/src/HeaderHighlight.tsx | 305 +++++++++++------- .../HeaderHighlight.editable.test.tsx | 94 ++++++ .../src/renderers/record-details.tsx | 57 +--- .../src/renderers/record-highlights.tsx | 1 + 6 files changed, 348 insertions(+), 161 deletions(-) create mode 100644 .changeset/editable-highlights.md create mode 100644 packages/plugin-detail/src/__tests__/HeaderHighlight.editable.test.tsx diff --git a/.changeset/editable-highlights.md b/.changeset/editable-highlights.md new file mode 100644 index 000000000..0528aa555 --- /dev/null +++ b/.changeset/editable-highlights.md @@ -0,0 +1,30 @@ +--- +"@object-ui/plugin-detail": minor +"@object-ui/app-shell": minor +--- + +feat(detail): editable record highlights on the shared inline-edit draft (objectui#2407 P2) + +The highlights strip is now editable in place and shares ONE draft + ONE atomic +Save with the details body (building on the P1 `InlineEditContext` / `#2529` +`InlineFieldInput`). + +- **`HeaderHighlight`** consumes `useInlineEdit()`: hovering a highlight shows a + pencil and double-click enters the shared record edit session; each editable + highlight renders the same `` the body uses (value = + `draft[name] ?? data[name]`, write via `setField`). Computed + (`formula`/`summary`/`rollup`/`auto_number`), `readonly`, and system fields + expose no editor. Empty highlights are kept while editing so they can be + filled. Compact-layout UX: an actively-edited column widens and renders the + editor full-width (Salesforce-style expand-on-edit). +- **`RecordDetailView`** (app-shell) hosts ONE `` (with the + object-lifecycle `canEdit` gate) spanning both `record:highlights` and + `record:details`, plus the single record-level `` — so a + highlight edit and a body edit commit together in ONE + `update(obj, id, draft, { ifMatch })`. +- **`record:details`** drops its P1-local provider/save bar (it would otherwise + split the draft from the highlights) and just consumes the shared context; + **`record:highlights`** threads the DataSource through for lookup/user editors. + +Guardrails preserved: computed/readonly/system highlights non-editable; `canEdit` +gate; OCC (`ifMatch` + `ConcurrentUpdateDialog`); only user-edited keys are sent. diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 81b29f407..925d84207 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -8,10 +8,10 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; -import { DetailView, RecordChatterPanel, buildDefaultPageSchema, deriveFieldGroupDetailSections, extractMentions } from '@object-ui/plugin-detail'; +import { DetailView, RecordChatterPanel, InlineEditSaveBar, buildDefaultPageSchema, deriveFieldGroupDetailSections, extractMentions } from '@object-ui/plugin-detail'; import { Empty, EmptyTitle, EmptyDescription } from '@object-ui/components'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; -import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, useGlobalUndo, useDataInvalidation, notifyDataChanged } from '@object-ui/react'; +import { ActionProvider, useObjectTranslation, useObjectLabel, usePageAssignment, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, useGlobalUndo, useDataInvalidation, notifyDataChanged } from '@object-ui/react'; import { buildExpandFields } from '@object-ui/core'; import { toast } from 'sonner'; import { useRecordPresence, PresenceAvatars } from '@object-ui/collaboration'; @@ -1966,6 +1966,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri isFavorite={isRecordFavorite} onToggleFavorite={favoriteRecord ? handleToggleRecordFavorite : undefined} > + {/* objectui#2407 P2 — ONE record-level inline-edit session spanning + the highlights strip AND the details body (both descend from this + provider), committed together by the single + below. `canEdit` carries the object-lifecycle / permission gate. */} + )} + {/* Record-level inline-edit Save/Cancel bar (objectui#2407 P2) + — commits the whole draft (highlights + body) in ONE atomic + OCC update; renders (sticky) only while editing. */} + (objectDef as any)?.fields?.[name]?.label || fieldLabel(objectName || '', name, name)} + locked={(pageRecord as any)?.approval_status === 'pending' || (pageRecord as any)?.approval_status === 'in_approval'} + /> + {/* Action Confirm Dialog */} diff --git a/packages/plugin-detail/src/HeaderHighlight.tsx b/packages/plugin-detail/src/HeaderHighlight.tsx index cd0f110e7..d39daab96 100644 --- a/packages/plugin-detail/src/HeaderHighlight.tsx +++ b/packages/plugin-detail/src/HeaderHighlight.tsx @@ -7,11 +7,21 @@ */ import * as React from 'react'; -import { cn } from '@object-ui/components'; +import { + cn, + Button, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@object-ui/components'; import type { HighlightField } from '@object-ui/types'; import { getCellRenderer, resolveCellRendererType } from '@object-ui/fields'; -import { useSafeFieldLabel } from '@object-ui/react'; -import { Check, X } from 'lucide-react'; +import { useSafeFieldLabel, useInlineEdit } from '@object-ui/react'; +import { Check, X, Pencil } from 'lucide-react'; +import { InlineFieldInput, TEXTUAL_REF_FALLBACK_TYPES } from './InlineFieldInput'; +import { NON_EDITABLE_SYSTEM_FIELDS } from './systemFields'; +import { useDetailTranslation } from './useDetailTranslation'; export interface HeaderHighlightProps { fields: HighlightField[]; @@ -21,6 +31,8 @@ export interface HeaderHighlightProps { objectName?: string; /** Object schema for field metadata enrichment */ objectSchema?: any; + /** DataSource used by reference (lookup / user) editors during inline edit */ + dataSource?: any; } export const HeaderHighlight: React.FC = ({ @@ -29,130 +41,197 @@ export const HeaderHighlight: React.FC = ({ className, objectName, objectSchema, + dataSource, }) => { const { fieldLabel } = useSafeFieldLabel(); + const { t } = useDetailTranslation(); + // Shared record-level inline-edit session (objectui#2407 P2). Null when the + // host doesn't wrap the page in an → strip stays + // read-only, exactly as before. + const inline = useInlineEdit(); + const editing = inline?.editing ?? false; + const canEdit = inline?.canEdit ?? false; + if (!fields.length || !data) return null; - // Filter to only fields with values - const visibleFields = fields.filter((f) => { + // In read mode we hide value-less fields to keep the strip dense; WHILE + // EDITING we keep them so the user can fill an empty highlight in place. + const hasValue = (f: HighlightField) => { const val = data?.[f.name]; return val !== null && val !== undefined && val !== ''; - }); + }; + const visibleFields = editing ? fields : fields.filter(hasValue); if (visibleFields.length === 0) return null; return ( -
-
- {visibleFields.map((field) => { - const value = data[field.name]; - // Enrich field metadata from objectSchema - const objectDefField = objectSchema?.fields?.[field.name]; - const resolvedType = field.type || objectDefField?.type; - const enrichedField = { - name: field.name, - label: field.label, - type: resolvedType || 'text', - ...(objectDefField?.options && { options: objectDefField.options }), - ...(objectDefField?.currency && { currency: objectDefField.currency }), - ...(objectDefField?.precision !== undefined && { precision: objectDefField.precision }), - ...((objectDefField as any)?.scale !== undefined && { scale: (objectDefField as any).scale }), - ...(objectDefField?.format && { format: objectDefField.format }), - }; + +
+
+ {visibleFields.map((field) => { + const rawValue = data[field.name]; + // Enrich field metadata from objectSchema + const objectDefField = objectSchema?.fields?.[field.name]; + const resolvedType = field.type || objectDefField?.type; + const enrichedField = { + name: field.name, + label: field.label, + type: resolvedType || 'text', + ...(objectDefField?.options && { options: objectDefField.options }), + ...(objectDefField?.currency && { currency: objectDefField.currency }), + ...(objectDefField?.precision !== undefined && { precision: objectDefField.precision }), + ...((objectDefField as any)?.scale !== undefined && { scale: (objectDefField as any).scale }), + ...(objectDefField?.format && { format: objectDefField.format }), + ...(objectDefField?.reference_to && { reference_to: objectDefField.reference_to }), + ...((objectDefField as any)?.widget && { widget: (objectDefField as any).widget }), + }; - // Use type-aware cell renderer — all renderers coerce values via - // coerceToSafeValue() so even object/array data is safe (no error #310). - const CellRenderer = getCellRenderer( - resolveCellRendererType(enrichedField as any) || resolvedType || 'text', - ); + // Live value = the user's draft edit for this field, else the record + // value. Read as `draft[name] ?? data[name]` (objectui#2407 P2). + const draftVal = inline?.draft?.[field.name]; + const value = draftVal !== undefined ? draftVal : rawValue; - // Treat numeric / currency / percent / count as KPI values — - // render larger, tabular-nums for nice column alignment. - const isKpi = - resolvedType === 'number' || - resolvedType === 'integer' || - resolvedType === 'currency' || - resolvedType === 'percent' || - resolvedType === 'decimal'; + // Field-level editability gate — mirrors DetailSection so the strip + // and the body agree on which highlights are editable. Computed + // types (formula/summary/rollup/auto_number), `readonly` (view OR + // object metadata), and immutable system/audit fields never edit. + const isComputed = TEXTUAL_REF_FALLBACK_TYPES.has(resolvedType as string); + const isReadonly = + (field as any).readonly === true || objectDefField?.readonly === true; + const isSystem = NON_EDITABLE_SYSTEM_FIELDS.has(field.name); + const fieldEditable = !isComputed && !isReadonly && !isSystem; + const canInlineEditField = canEdit && fieldEditable; + const editorActive = editing && canInlineEditField; - // Emails frequently exceed the default highlight column width - // and look mangled when truncated mid-domain - // (`zhuangjianguo@gmail.co` swallowing the `…m`). Let those - // columns grow wider so the address fits on one line. Same - // treatment for textarea fields and reference/lookup values — - // related-record names (`国家电投江苏海上风电有限公司`) are long - // and losing the tail hides the very thing the field identifies. - const isWide = - resolvedType === 'email' || - resolvedType === 'url' || - resolvedType === 'textarea' || - resolvedType === 'reference' || - resolvedType === 'lookup' || - resolvedType === 'master_detail'; - // BooleanCellRenderer paints a tiny disabled checkbox which - // reads as "empty input" in the header context. Pills with - // ✓ / ✗ icons match how every modern enterprise UI - // (Salesforce, Linear, Notion) surfaces status booleans. - const isBoolean = resolvedType === 'boolean'; + // Use type-aware cell renderer — all renderers coerce values via + // coerceToSafeValue() so even object/array data is safe (no error #310). + const CellRenderer = getCellRenderer( + resolveCellRendererType(enrichedField as any) || resolvedType || 'text', + ); - return ( -
- - {fieldLabel(objectName || '', field.name, field.label)} - - {isBoolean ? ( - value ? ( - - - Yes - - ) : ( - - - No - - ) - ) : ( - - + const isKpi = + resolvedType === 'number' || + resolvedType === 'integer' || + resolvedType === 'currency' || + resolvedType === 'percent' || + resolvedType === 'decimal'; + const isWide = + resolvedType === 'email' || + resolvedType === 'url' || + resolvedType === 'textarea' || + resolvedType === 'reference' || + resolvedType === 'lookup' || + resolvedType === 'master_detail'; + const isBoolean = resolvedType === 'boolean'; + const isEmpty = value === null || value === undefined || value === ''; + + // Compact-layout UX: an editor (select / date / lookup) needs more + // room than a KPI number, so an actively-edited column widens to the + // "wide" basis and renders the input full-width (Salesforce-style + // expand-on-edit) instead of cramming it into a 9rem column. + const useWide = isWide || editorActive; + + return ( +
+ + {fieldLabel(objectName || '', field.name, field.label)} - )} -
- ); - })} -
-
+ + {editorActive ? ( + inline!.setField(field.name, v)} + dataSource={dataSource} + autoFocus={inline!.autoFocusField === field.name} + /> + ) : ( +
inline!.enter(field.name) : undefined + } + title={canInlineEditField ? t('detail.editInlineHint') : undefined} + > +
+ {isBoolean ? ( + value ? ( + + + Yes + + ) : ( + + + No + + ) + ) : isEmpty ? ( + + — + + ) : ( + + + + )} +
+ {canInlineEditField && ( + + + + + {t('detail.editInlineHint')} + + )} +
+ )} +
+ ); + })} + +
+ ); }; diff --git a/packages/plugin-detail/src/__tests__/HeaderHighlight.editable.test.tsx b/packages/plugin-detail/src/__tests__/HeaderHighlight.editable.test.tsx new file mode 100644 index 000000000..1955a96cc --- /dev/null +++ b/packages/plugin-detail/src/__tests__/HeaderHighlight.editable.test.tsx @@ -0,0 +1,94 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { describe, it, expect, beforeAll } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { InlineEditProvider, useInlineEdit } from '@object-ui/react'; +import { HeaderHighlight } from '../HeaderHighlight'; + +/** + * objectui#2407 P2 — the highlights strip becomes editable against the SHARED + * record-level draft: double-click / pencil enters the same edit session as the + * body, computed/readonly highlights expose no editor, and edits land in the + * one draft the save bar commits. + */ + +// Surfaces the shared draft so we can prove highlight edits land in the SAME +// draft the body + save bar read. +function DraftProbe() { + const inline = useInlineEdit(); + return
{JSON.stringify(inline?.draft ?? null)}
; +} + +const objectSchema = { + fields: { + owner: { type: 'text' }, + score: { type: 'formula' }, // computed → never editable + }, +}; +const fields = [{ name: 'owner', label: 'Owner' }, { name: 'score', label: 'Score' }] as any; +const data = { owner: 'Alice', score: '99' }; + +describe('HeaderHighlight — editable highlights (P2)', () => { + beforeAll(() => { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 }); + }); + + it('is read-only with no (bare usage)', () => { + render(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.queryByDisplayValue('Alice')).toBeNull(); + }); + + it('double-clicking an editable highlight enters the shared edit session', () => { + render( + + + + , + ); + // Read mode: value shown, no editor. + expect(screen.queryByDisplayValue('Alice')).toBeNull(); + fireEvent.doubleClick(screen.getByText('Alice')); + // Now editable in place. + expect(screen.getByDisplayValue('Alice')).toBeInTheDocument(); + }); + + it('never exposes an editor for a computed highlight, even while editing', () => { + render( + + + , + ); + fireEvent.doubleClick(screen.getByText('Alice')); // enter edit + expect(screen.getByDisplayValue('Alice')).toBeInTheDocument(); // owner editable + expect(screen.queryByDisplayValue('99')).toBeNull(); // score (formula) stays read-only + }); + + it('routes a highlight edit into the SHARED draft', () => { + render( + + + + , + ); + fireEvent.doubleClick(screen.getByText('Alice')); + fireEvent.change(screen.getByDisplayValue('Alice'), { target: { value: 'Bob' } }); + expect(screen.getByTestId('draft').textContent).toBe('{"owner":"Bob"}'); + }); + + it('offers no editor when the record is not editable (canEdit=false)', () => { + render( + + + , + ); + fireEvent.doubleClick(screen.getByText('Alice')); + expect(screen.queryByDisplayValue('Alice')).toBeNull(); + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index 48442b599..1853af38d 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -11,12 +11,11 @@ */ import React from 'react'; -import { useRecordContext, useHighlightFieldNames, useSafeFieldLabel, InlineEditProvider } from '@object-ui/react'; +import { useRecordContext, useHighlightFieldNames, useSafeFieldLabel } from '@object-ui/react'; import { useFieldPermissions, usePermissions } from '@object-ui/permissions'; import { useObjectTranslation, pickLocalized } from '@object-ui/i18n'; import type { RecordDetailsComponentProps } from '@object-ui/types'; import { DetailView } from '../DetailView'; -import { InlineEditSaveBar } from '../InlineEditSaveBar'; /** Normalize a field entry (string | {field} | {name}) to its machine name. */ const fieldName = (entry: any): string | null => { @@ -64,25 +63,6 @@ export const RecordDetailsRenderer: React.FC = ({ // instance via HighlightFieldsContext (used to dedupe them out of the grid). const liveHighlightNames = useHighlightFieldNames(); - const fieldLabelFor = React.useCallback( - (name: string): string | undefined => { - const all: any[] = [ - ...(Array.isArray(schema.fields) ? (schema.fields as any[]) : []), - ...((Array.isArray(schema.sections) ? (schema.sections as any[]) : []) - .flatMap((s: any) => (Array.isArray(s?.fields) ? s.fields : []))), - ]; - for (const f of all) { - const fname = typeof f === 'string' ? f : (f?.name || f?.field); - if (fname === name) { - const label = typeof f === 'object' ? (f?.label as string | undefined) : undefined; - return label; - } - } - return undefined; - }, - [schema.fields, schema.sections] - ); - // Inline-edit save + OCC now live in the record-level // (objectui#2407 P1): it commits the whole draft in ONE atomic // `dataSource.update(..., { ifMatch: data.updated_at })` and reuses @@ -272,33 +252,18 @@ export const RecordDetailsRenderer: React.FC = ({ inlineEdit: inlineEditDefault, }; - // Approval-locked records (pending / in_approval) can't be written — the - // backend rejects with RECORD_LOCKED — so disable Save on the bar. DetailView - // renders the lock REASON badge; the save bar just prevents the write. - const approvalStatus = (ctx.data as any)?.approval_status; - const locked = approvalStatus === 'pending' || approvalStatus === 'in_approval'; - + // The inline-edit session (InlineEditProvider) + the atomic Save bar are now + // hosted by the PAGE host (app-shell RecordDetailView) so ONE draft spans the + // highlights strip AND this body (objectui#2407 P2). DetailView here just + // consumes that shared context; `inlineEdit` gates the affordance to this + // object's lifecycle/permission. return (
- {/* One shared inline-edit session for this record. In P2 this provider - lifts to the page host so the highlights strip shares the same draft; - for now it scopes the details body + its atomic Save bar. */} - - - - +
); }; diff --git a/packages/plugin-detail/src/renderers/record-highlights.tsx b/packages/plugin-detail/src/renderers/record-highlights.tsx index 0a09cdffd..8efa1d962 100644 --- a/packages/plugin-detail/src/renderers/record-highlights.tsx +++ b/packages/plugin-detail/src/renderers/record-highlights.tsx @@ -97,6 +97,7 @@ export const RecordHighlightsRenderer: React.FC = data={ctx?.data} objectName={ctx?.objectName} objectSchema={ctx?.objectSchema as any} + dataSource={ctx?.dataSource as any} /> );