Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/record-inline-edit-polish-2572.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/fields': patch
'@object-ui/components': patch
'@object-ui/app-shell': patch
---

Record-level inline edit polish (objectui#2572, follow-up to #2407) — the five
rough edges from the live showcase verification pass:

- **Expanded reference values pass through to the picker.** `InlineFieldInput`
no longer collapses an `$expand`-ed record object to a bare id before
handing it to `LookupField` / `UserField` — the picker resolves the display
name it already carries instead of re-fetching the referenced record via
`findOne` (or sticking on the placeholder when it can't). `LookupField`
still hands its Level-2 pickers (PeoplePicker / RecordPickerDialog) bare
ids, collapsed via the existing `normalizeId`.
- **Approval-lock preflight.** The record page now re-reads the approval
state whenever the record is invalidated (a save can *trigger* an approval
flow that locks the record), derives one `approvalLocked` signal
(`approval_status` pending/in_approval OR an open pending request), gates
the inline-edit session's `canEdit` with it — hiding the pencil affordances
and no-op'ing `enter()` on a locked record — and drives the save bar's
`locked`/`lockedHint` so users can't type into a draft that Save would
reject with `RECORD_LOCKED`.
- **Numeric field types edit with the real numeric widgets.** `number` /
`currency` / `percent` route to `NumberField` / `CurrencyField` /
`PercentField` (the same widgets the form uses) instead of a free-text
input: numeric keyboard, symbol adornment, fraction↔percent display
conversion, and numbers (not strings) into the draft. `NumberField` and
`CurrencyField` now surface metadata `min`/`max` on the input, `NumberField`
honors an explicit `step` and steps by 1 for `scale: 0` (previously fell
back to `any`).
- **Header Edit CTA stands down during an inline session.** The synthesized
`sys_edit` action carries `disableDuringInlineEdit`, and the `page:header`
renderer greys such actions out while `InlineEditContext.editing` — the
classic form-edit surface can no longer be stacked on top of a live inline
draft.
- **Keyboard shortcuts for the shared edit session.** `InlineEditSaveBar`
binds **Esc → cancel** (deferring to any open Radix layer — popover /
select / dialog — which owns Escape for "close") and **Cmd/Ctrl+Enter →
save**, both respecting `saving`/`locked`.
42 changes: 39 additions & 3 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,31 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
const approvalsRef = useRef(approvals);
approvalsRef.current = approvals;

// Approval-lock preflight (objectui#2572 item 2): re-read the approval
// state whenever this record is invalidated on the bus — a saved edit can
// TRIGGER an approval flow (e.g. a budget change), and without this
// re-fetch the page kept the pre-save snapshot: the user could re-enter
// inline edit, fill a whole draft, and only learn about the lock when Save
// came back RECORD_LOCKED. Skips the initial mount (the hook fetches on
// mount itself); the ref keeps the effect off the approvals identity.
const skippedInitialApprovalsRefresh = useRef(false);
useEffect(() => {
if (!skippedInitialApprovalsRefresh.current) {
skippedInitialApprovalsRefresh.current = true;
return;
}
void approvalsRef.current.refresh();
}, [recordInvalidationNonce]);

// The shared lock signal for the inline-edit session: the record's own
// `approval_status` (what the DetailView lock band keys off) OR an open
// pending request from the approvals API — the latter catches backends
// that lock via the request record without materializing the field.
const approvalLocked =
(pageRecord as any)?.approval_status === 'pending' ||
(pageRecord as any)?.approval_status === 'in_approval' ||
!!approvals.pendingRequest;

const approvalHandler = useCallback(async (action: ActionDef) => {
const target = action.target || action.name;
const params = (action.params && !Array.isArray(action.params))
Expand Down Expand Up @@ -1655,6 +1680,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
locations: ['record_header'],
variant: 'default',
order: 100,
// objectui#2572 item 4 — while a shared inline-edit draft is live,
// the page:header renderer greys this CTA out so the classic form
// can't be stacked on top of the draft (two competing edit sessions
// with no reconciliation).
disableDuringInlineEdit: true,
onClick: () => onEdit({ id: pureRecordId }),
} as any);
}
Expand Down Expand Up @@ -1770,8 +1800,11 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
{/* 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 <InlineEditSaveBar>
below. `canEdit` carries the object-lifecycle / permission gate. */}
<InlineEditProvider canEdit={resolveCrudAffordances(objectDef as any).edit}>
below. `canEdit` carries the object-lifecycle / permission gate
AND the approval lock (objectui#2572 item 2): a locked record
hides the pencil affordances and no-ops `enter()`, so users can't
type into a draft that Save would reject with RECORD_LOCKED. */}
<InlineEditProvider canEdit={resolveCrudAffordances(objectDef as any).edit && !approvalLocked}>
<HighlightFieldsProvider>
<DiscussionContextProvider
items={feedItems as any}
Expand Down Expand Up @@ -1869,7 +1902,10 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
data={pageRecord}
refresh={notifyRecordChanged}
fieldLabelFor={(name: string) => (objectDef as any)?.fields?.[name]?.label || fieldLabel(objectName || '', name, name)}
locked={(pageRecord as any)?.approval_status === 'pending' || (pageRecord as any)?.approval_status === 'in_approval'}
locked={approvalLocked}
lockedHint={t('detail.lockedTooltip', {
defaultValue: 'This record has a pending approval request; editing is locked',
})}
/>
</div>
<MetadataPanel
Expand Down
56 changes: 56 additions & 0 deletions packages/components/src/__tests__/page-header-actions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@
*/

import { describe, it, expect, vi } from 'vitest';
import { useEffect } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import {
ActionProvider,
RecordContextProvider,
InlineEditProvider,
useInlineEdit,
} from '@object-ui/react';

function PageHeader({ schema }: { schema: any }) {
Expand Down Expand Up @@ -222,6 +225,59 @@ describe('PageHeaderRenderer — actions slot', () => {
});
});

describe('PageHeaderRenderer — inline-edit session gate (objectui#2572)', () => {
// Enters the shared inline-edit session on mount, simulating a user
// double-clicking a field in the record body.
function EnterInlineEdit() {
const inline = useInlineEdit()!;
useEffect(() => {
inline.enter();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
}

function renderWithInlineEdit(opts: { editing: boolean; actions: any[] }) {
return render(
<ActionProvider>
<RecordContextProvider
objectName="proj"
recordId="1"
data={{ id: '1' }}
objectSchema={{ name: 'proj', label: 'Project' }}
headerSystemActions={opts.actions}
>
<InlineEditProvider canEdit>
{opts.editing && <EnterInlineEdit />}
<PageHeader schema={{ type: 'page:header', title: 'Project' }} />
</InlineEditProvider>
</RecordContextProvider>
</ActionProvider>,
);
}

const editCta = { name: 'sys_edit', label: 'Edit', disableDuringInlineEdit: true };

it('disables a `disableDuringInlineEdit` action while the session is active', () => {
renderWithInlineEdit({ editing: true, actions: [editCta] });
expect(screen.getByRole('button', { name: /Edit/i })).toBeDisabled();
});

it('keeps the action enabled when no session is active', () => {
renderWithInlineEdit({ editing: false, actions: [editCta] });
expect(screen.getByRole('button', { name: /Edit/i })).toBeEnabled();
});

it('leaves unflagged actions alone during the session', () => {
renderWithInlineEdit({
editing: true,
actions: [editCta, { name: 'convert', label: 'Convert' }],
});
expect(screen.getByRole('button', { name: /Edit/i })).toBeDisabled();
expect(screen.getByRole('button', { name: /Convert/i })).toBeEnabled();
});
});

describe('PageHeaderRenderer — inline/overflow split (objectui#2361)', () => {
it('renders up to three actions side-by-side with no overflow menu', () => {
renderHeader({
Expand Down
19 changes: 16 additions & 3 deletions packages/components/src/renderers/layout/containers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import React from 'react';
import { ComponentRegistry, ExpressionEvaluator, getRecordDisplayName } from '@object-ui/core';
import { useRecordContext, useAction, usePredicateScope, usePageVariables } from '@object-ui/react';
import { useRecordContext, useAction, usePredicateScope, usePageVariables, useInlineEdit } from '@object-ui/react';
import { renderChildren, cn } from '../../lib/utils';
import { LazyIcon } from '../../lib/lazy-icon';
import { RelatedCountStore, useRelatedCountVersion } from '../../hooks/related-count-store';
Expand Down Expand Up @@ -748,6 +748,13 @@ export function cleanupTitleSeparators(s: string): string {
const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
const { designer } = splitDesignerProps(props);
const ctx = useRecordContext();
// Record-level inline-edit session (objectui#2572 item 4): while a shared
// inline draft is active, header actions flagged `disableDuringInlineEdit`
// (the host's Edit CTA) grey out so the classic form-edit surface can't be
// stacked on top of the live draft — two competing edit sessions with no
// reconciliation. `useInlineEdit` is null outside an InlineEditProvider
// (non-record pages), which leaves everything enabled.
const inlineEditing = !!useInlineEdit()?.editing;
// Ambient host scope (signed-in user / app / features), fed by app-shell's
// ExpressionProvider. Needed so header-action `visible` CEL predicates can
// gate on `ctx.user.*` (e.g. sys_environment "Change Plan (admin)" →
Expand Down Expand Up @@ -972,13 +979,19 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
if (!src) return false;
try { return !!dEvaluator.evaluateExpression(src); } catch { return false; }
};
// A live inline-edit session disables actions the host flagged with
// `disableDuringInlineEdit` (objectui#2572 item 4) — see `inlineEditing`
// at the top of the renderer.
const isActionDisabled = (action: any): boolean =>
(inlineEditing && action?.disableDuringInlineEdit === true) ||
resolveDisabled(action?.disabled);
const renderButton = (action: any, idx: number) => {
const label = resolveLabel(action, idx);
// `variant: 'primary'` is valid ActionSchema but not a Shadcn Button
// variant — map it to `default` (same as action-button.tsx).
const variant = action.variant === 'primary' ? 'default' : (action.variant || 'default');
const size = action.size || 'sm';
const disabled = resolveDisabled(action.disabled);
const disabled = isActionDisabled(action);
const icon = typeof action.icon === 'string' ? action.icon : null;
return (
<Button
Expand Down Expand Up @@ -1022,7 +1035,7 @@ const PageHeaderRenderer: React.FC<any> = ({ schema, className, ...props }) => {
<DropdownMenuContent align="end" className="w-44">
{overflowActions.map((action, idx) => {
const label = resolveLabel(action, idx + inlineActions.length);
const disabled = resolveDisabled(action.disabled);
const disabled = isActionDisabled(action);
const icon = typeof action.icon === 'string' ? action.icon : null;
const isDestructive =
action.variant === 'destructive' || action.name === 'sys_delete';
Expand Down
35 changes: 35 additions & 0 deletions packages/fields/src/standard-widgets.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,41 @@ describe('Standard Field Widgets', () => {
);
expect(container.textContent).toBe('99.9');
});

it('should surface metadata min/max on the input (objectui#2572)', () => {
render(
<NumberField
value={5}
onChange={mockOnChange}
field={{ type: 'number', min: 0, max: 100 } as any}
/>
);
const input = screen.getByRole('spinbutton');
expect(input).toHaveAttribute('min', '0');
expect(input).toHaveAttribute('max', '100');
});

it('should step by 1 for scale: 0 (whole numbers), not "any"', () => {
render(
<NumberField
value={5}
onChange={mockOnChange}
field={{ type: 'number', scale: 0 } as any}
/>
);
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '1');
});

it('should honor an explicit metadata step over the scale-derived one', () => {
render(
<NumberField
value={5}
onChange={mockOnChange}
field={{ type: 'number', scale: 2, step: 5 } as any}
/>
);
expect(screen.getByRole('spinbutton')).toHaveAttribute('step', '5');
});
});

describe('TextField', () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/fields/src/widgets/CurrencyField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,11 @@ export function CurrencyField({ value, onChange, field, readonly, errorMessage,
placeholder={currencyField?.placeholder || '0.00'}
disabled={readonly || props.disabled}
className={`${symbol ? 'pl-8' : ''} ${className || ''}`}
// Surface the field's declared range (e.g. `min: 0` on a budget) so the
// browser's spinner/keyboard affordances respect it (objectui#2572);
// server-side validation still owns enforcement.
min={typeof currencyField?.min === 'number' ? currencyField.min : undefined}
max={typeof currencyField?.max === 'number' ? currencyField.max : undefined}
step={Math.pow(10, -precision).toFixed(precision)}
aria-invalid={!!errorMessage}
/>
Expand Down
14 changes: 12 additions & 2 deletions packages/fields/src/widgets/LookupField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,16 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
[idField],
);

// Level-2 pickers (PeoplePicker / RecordPickerDialog) operate on bare ids —
// their seed queries and selected-row matching compare against `idField`
// values. Collapse any `$expand`-ed record objects (passed through
// uncollapsed by the inline editor, objectui#2572) before handing the value
// down, or the seed fetch would query for a bogus object-shaped id.
const pickerValue = useMemo(
() => (multiple ? (Array.isArray(value) ? value.map(normalizeId) : []) : normalizeId(value)),
[multiple, value, normalizeId],
);

// Resolve a raw field value into its display option. An expanded-reference
// object is mapped directly (mirroring the read cell's display-name path) so
// the inline editor shows the record's name instead of the placeholder; a bare
Expand Down Expand Up @@ -914,7 +924,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
subtitleFields={subtitleFields}
avatarField={avatarField}
pageSize={lookupPageSize}
value={value}
value={pickerValue}
onSelect={onChange}
onSelectRecords={handlePickerSelectRecords}
lookupFilters={lookupFilters}
Expand Down Expand Up @@ -1135,7 +1145,7 @@ export function LookupField({ value, onChange, field, readonly, ...props }: Fiel
titleFormat={refTitleFormat}
idField={idField}
pageSize={lookupPageSize}
value={value}
value={pickerValue}
onSelect={onChange}
onSelectRecords={handlePickerSelectRecords}
lookupFilters={lookupFilters}
Expand Down
16 changes: 14 additions & 2 deletions packages/fields/src/widgets/NumberField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,16 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel

const numberField = (field || (props as any).schema) as NumberFieldMetadata;
// Step follows `scale` (decimal places), not `precision` (total digit count):
// a decimal(10, 0) column has 0 decimal places, so the input should step by 1.
// a decimal(10, 0) column has 0 decimal places, so the input should step by 1
// (`scale: 0` is a valid declaration — hence the typeof check, not truthiness).
// An explicit `step` in the metadata wins over the derived one.
const scale = numberField?.scale;
const step =
typeof numberField?.step === 'number'
? numberField.step
: typeof scale === 'number'
? Math.pow(10, -scale)
: 'any';

// Filter out non-DOM props
const { inputType, ...domProps } = props as any;
Expand All @@ -31,7 +39,11 @@ export function NumberField({ value, onChange, field, readonly, ...props }: Fiel
}}
placeholder={numberField?.placeholder}
disabled={readonly || domProps.disabled}
step={scale ? Math.pow(10, -scale) : 'any'}
// Surface the field's declared range so the browser's spinner/keyboard
// affordances respect it (server-side validation still owns enforcement).
min={typeof numberField?.min === 'number' ? numberField.min : undefined}
max={typeof numberField?.max === 'number' ? numberField.max : undefined}
step={step}
/>
);
}
20 changes: 20 additions & 0 deletions packages/plugin-detail/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,26 @@ the whole section renders nothing — no header, no empty grid, no "New"
button that would be rejected server-side. With no `PermissionProvider`
mounted (Studio designer, standalone embeds) the gate stays open.

### InlineEditSaveBar & InlineFieldInput

The record-level inline-edit session (#2407): double-clicking a field (or its
hover pencil) in the highlights strip or details body stages edits into ONE
shared draft (`InlineEditProvider` from `@object-ui/react`), committed by
`<InlineEditSaveBar>` as a single atomic OCC-guarded update. Polish shipped in
#2572:

- **Editors**: `InlineFieldInput` routes every field type to the same widget
the form uses — including `number` / `currency` / `percent` (numeric
keyboard, `min`/`max`/`step` from metadata, fraction↔percent conversion) and
reference pickers, which receive `$expand`-ed record objects as-is so the
display name renders without a hydration re-fetch.
- **Keyboard shortcuts**: while the session is active, **Esc** cancels (open
popovers/dialogs keep owning Escape for "close") and **Cmd/Ctrl+Enter**
saves; both respect the in-flight `saving` and `locked` states.
- **Approval lock**: hosts pass `locked` / `lockedHint` to the save bar and
gate `InlineEditProvider.canEdit` when the record is approval-locked, so a
locked record hides its edit affordances instead of rejecting at Save.

<!-- release-metadata:v3.3.0 -->

## Compatibility
Expand Down
Loading
Loading