diff --git a/.changeset/approval-band-request-tracked.md b/.changeset/approval-band-request-tracked.md new file mode 100644 index 000000000..c0a43a5b7 --- /dev/null +++ b/.changeset/approval-band-request-tracked.md @@ -0,0 +1,17 @@ +--- +"@object-ui/react": patch +"@object-ui/plugin-detail": patch +"@object-ui/app-shell": patch +--- + +fix(detail): show the "Locked for approval" band on request-tracked backends (objectui#2618) + +The DetailView approval-lock band keyed only off the record's own +`approval_status` field, so it never rendered on backends that track the lock +via an open approval request and never materialize that field — even though +the lock was real (writes rejected with `RECORD_LOCKED`). The record-level +`InlineEditContext` now carries the host's `locked`/`lockedReason` signal +(the same dual-source `approvalLocked` that already gates `canEdit` in +`RecordDetailView`), and the band renders from it while keeping `DetailView` +DataSource-agnostic. Also backfills the approval-lock strings into the detail +translation defaults so a bare DetailView shows the label, not the raw i18n key. diff --git a/packages/app-shell/src/views/RecordDetailView.tsx b/packages/app-shell/src/views/RecordDetailView.tsx index 69456bf34..d7a9fa979 100644 --- a/packages/app-shell/src/views/RecordDetailView.tsx +++ b/packages/app-shell/src/views/RecordDetailView.tsx @@ -1807,8 +1807,19 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri 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. */} - + type into a draft that Save would reject with RECORD_LOCKED. + `locked` surfaces the approval lock as its own signal so the + DetailView "Locked for approval" band renders from the SAME + dual-source `approvalLocked` that gated `canEdit` — engaging even + on backends that track the lock via approval requests only and + never materialize an `approval_status` field (objectui#2618). */} + = ({ bar itself now lives in the record-level (objectui#2407 P1); this band is lock-only. */} {inlineEdit && schema.showHeader === false && (() => { - // Detect approval lock on the live record. When `approval_status` - // is `pending`/`in_approval`, the backend will reject any update - // with RECORD_LOCKED — show the lock badge inline so users see - // a clear reason instead of editing and failing on save. + // Detect approval lock. Prefer the host-supplied signal + // (`inline.locked`) — the record-level session computes it from the + // record's `approval_status` field OR an open approvals request + // (objectui#2618), so the band engages even on backends that track + // the lock via approval requests only and never materialize an + // `approval_status` field on the record. Fall back to the record's + // own field for bare/legacy DetailView usage without a host that + // threads the lock. Either way a locked record's writes are rejected + // with RECORD_LOCKED, so surface the badge instead of editing and + // failing on save. const approvalStatus = data?.approval_status; - const isLocked = approvalStatus === 'pending' || approvalStatus === 'in_approval'; + const isLocked = + (inline?.locked ?? false) || + approvalStatus === 'pending' || + approvalStatus === 'in_approval'; // Nothing to surface (not locked, no approval-cancel error): no band. if (!isLocked && !saveError) return null; return ( @@ -1015,7 +1024,7 @@ export const DetailView: React.FC = ({ {t('detail.lockedByApproval')} diff --git a/packages/plugin-detail/src/__tests__/DetailView.approvalBand.test.tsx b/packages/plugin-detail/src/__tests__/DetailView.approvalBand.test.tsx new file mode 100644 index 000000000..8136ec9ac --- /dev/null +++ b/packages/plugin-detail/src/__tests__/DetailView.approvalBand.test.tsx @@ -0,0 +1,73 @@ +/** + * 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 } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { DetailView } from '../DetailView'; +import { InlineEditProvider } from '@object-ui/react'; +import type { DetailViewSchema } from '@object-ui/types'; + +/** + * DetailView approval-lock band (objectui#2618). + * + * The band renders only when the DetailView's own header is suppressed + * (`showHeader === false`, composed under a Lightning-style page header) and + * inline editing is enabled. It must engage from the HOST-supplied lock + * signal (`InlineEditProvider locked`) — not only the record's own + * `approval_status` field — because some backends track the lock via an open + * approval *request* and never materialize an `approval_status` on the record. + * Without this the lock was real (writes rejected with RECORD_LOCKED) yet the + * band silently never showed. + */ + +const baseSchema: DetailViewSchema = { + type: 'detail-view', + title: 'Budget', + objectName: 'budget', + showHeader: false, + data: { id: 'B1', name: 'Q3 Budget' }, + sections: [{ title: 'Basics', fields: [{ name: 'name', label: 'Name' }] }], +}; + +function renderBand( + providerProps: { locked?: boolean; lockedReason?: string; canEdit?: boolean }, + data?: Record, +) { + return render( + + + , + ); +} + +describe('DetailView – approval-lock band (objectui#2618)', () => { + it('shows the band from the host lock signal even with no approval_status field', () => { + renderBand({ locked: true }); + // Backend tracks the lock via approval request only → record carries no + // approval_status, but the host threads `locked` — band must still show. + expect(screen.getByText('Locked for approval')).toBeInTheDocument(); + }); + + it('uses the host-supplied lockedReason as the badge tooltip', () => { + renderBand({ locked: true, lockedReason: 'Pending manager approval' }); + const badge = screen.getByRole('status'); + expect(badge).toHaveAttribute('title', 'Pending manager approval'); + }); + + it('still shows the band from the record field for field-tracked backends', () => { + // No host `locked`, but the record materializes approval_status — the + // legacy field-only signal remains a valid fallback. + renderBand({ locked: false }, { approval_status: 'pending' }); + expect(screen.getByText('Locked for approval')).toBeInTheDocument(); + }); + + it('does not show the band when neither signal indicates a lock', () => { + renderBand({ locked: false }, { approval_status: 'draft' }); + expect(screen.queryByText('Locked for approval')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/plugin-detail/src/__tests__/InlineEditContext.test.tsx b/packages/plugin-detail/src/__tests__/InlineEditContext.test.tsx index 1c148366e..fb74e57aa 100644 --- a/packages/plugin-detail/src/__tests__/InlineEditContext.test.tsx +++ b/packages/plugin-detail/src/__tests__/InlineEditContext.test.tsx @@ -23,6 +23,8 @@ function Probe() { return (
{String(inline.editing)} + {String(inline.locked)} + {inline.lockedReason ?? ''} {JSON.stringify(inline.draft)} {inline.autoFocusField ?? ''} @@ -83,4 +85,24 @@ describe('InlineEditContext', () => { fireEvent.click(screen.getByText('enter')); expect(screen.getByTestId('editing').textContent).toBe('false'); }); + + it('defaults locked to false and lockedReason to undefined (objectui#2618)', () => { + render( + + + , + ); + expect(screen.getByTestId('locked').textContent).toBe('false'); + expect(screen.getByTestId('lockedReason').textContent).toBe(''); + }); + + it('surfaces the host-supplied approval lock + reason on the context (objectui#2618)', () => { + render( + + + , + ); + expect(screen.getByTestId('locked').textContent).toBe('true'); + expect(screen.getByTestId('lockedReason').textContent).toBe('Pending approval'); + }); }); diff --git a/packages/plugin-detail/src/useDetailTranslation.ts b/packages/plugin-detail/src/useDetailTranslation.ts index d3f4dd504..457832541 100644 --- a/packages/plugin-detail/src/useDetailTranslation.ts +++ b/packages/plugin-detail/src/useDetailTranslation.ts @@ -200,6 +200,14 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.concurrentUpdateReload': 'Reload latest', 'detail.concurrentUpdateOverwrite': 'Overwrite anyway', 'detail.concurrentUpdateCancel': 'Cancel', + // Approval lock band (objectui#2618) + 'detail.lockedByApproval': 'Locked for approval', + 'detail.lockedTooltip': 'This record has a pending approval request; editing is locked', + 'detail.cancelApproval': 'Recall approval', + 'detail.cancelApprovalInFlight': 'Recalling…', + 'detail.cancelApprovalTooltip': 'Recall the pending approval request to unlock this record', + 'detail.cancelApprovalFailed': 'Failed to recall approval', + 'detail.cancelApprovalUnavailable': 'Recalling approvals is not supported on this data source', }; /** diff --git a/packages/react/src/context/InlineEditContext.tsx b/packages/react/src/context/InlineEditContext.tsx index ce5dda26d..511725b04 100644 --- a/packages/react/src/context/InlineEditContext.tsx +++ b/packages/react/src/context/InlineEditContext.tsx @@ -33,6 +33,24 @@ export interface InlineEditContextValue { * is a no-op and consumers must not surface the edit affordance. */ canEdit: boolean; + /** + * Whether this record is *approval-locked* — a pending approval request has + * the record locked for writes (the backend rejects updates with + * `RECORD_LOCKED`). This is a DISTINCT signal from `!canEdit`: a record can + * be non-editable for many reasons (no permission, wrong lifecycle stage), + * but only an approval lock warrants the "Locked for approval" band + recall + * affordance. The host computes it (objectui#2618) — typically from the + * record's `approval_status` field OR an open request in the approvals API — + * so the band renders from the same signal that gated `canEdit`, keeping the + * renderer DataSource-agnostic. Defaults to `false`. + */ + locked: boolean; + /** + * Human-readable reason for the approval lock, surfaced as the band's + * tooltip. Optional — consumers fall back to their own localized default + * when omitted. + */ + lockedReason?: string; /** * Draft of user-edited values. Holds ONLY the keys the user actually * changed, so the save path never writes computed / read-only / untouched @@ -68,11 +86,22 @@ export interface InlineEditProviderProps { * a single source. Defaults to `true`. */ canEdit?: boolean; + /** + * Whether the record is approval-locked (objectui#2618). Surfaced verbatim + * on the context so lock-aware consumers (the DetailView "Locked for + * approval" band) render from the host's signal instead of re-deriving it + * from a record field the backend may not materialize. Defaults to `false`. + */ + locked?: boolean; + /** Optional human-readable lock reason, surfaced as the band tooltip. */ + lockedReason?: string; children: React.ReactNode; } export const InlineEditProvider: React.FC = ({ canEdit = true, + locked = false, + lockedReason, children, }) => { const [editing, setEditing] = React.useState(false); @@ -113,6 +142,8 @@ export const InlineEditProvider: React.FC = ({ () => ({ editing, canEdit, + locked, + lockedReason, draft, autoFocusField, saving, @@ -124,7 +155,7 @@ export const InlineEditProvider: React.FC = ({ setSaving, setError, }), - [editing, canEdit, draft, autoFocusField, saving, error, enter, setField, teardown], + [editing, canEdit, locked, lockedReason, draft, autoFocusField, saving, error, enter, setField, teardown], ); return {children};