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
17 changes: 17 additions & 0 deletions .changeset/approval-band-request-tracked.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 13 additions & 2 deletions packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */}
<InlineEditProvider canEdit={resolveCrudAffordances(objectDef as any).edit && !approvalLocked}>
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). */}
<InlineEditProvider
canEdit={resolveCrudAffordances(objectDef as any).edit && !approvalLocked}
locked={approvalLocked}
lockedReason={t('detail.lockedTooltip', {
defaultValue: 'This record has a pending approval request; editing is locked',
})}
>
<HighlightFieldsProvider>
<DiscussionContextProvider
items={feedItems as any}
Expand Down
21 changes: 15 additions & 6 deletions packages/plugin-detail/src/DetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1000,12 +1000,21 @@ export const DetailView: React.FC<DetailViewProps> = ({
bar itself now lives in the record-level <InlineEditSaveBar>
(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 (
Expand All @@ -1015,7 +1024,7 @@ export const DetailView: React.FC<DetailViewProps> = ({
<span
role="status"
className="inline-flex items-center gap-1 rounded-md border border-amber-300 bg-amber-50 px-2 py-1 text-xs text-amber-800"
title={t('detail.lockedTooltip')}
title={inline?.lockedReason ?? t('detail.lockedTooltip')}
>
<Lock className="h-3 w-3" />
<span>{t('detail.lockedByApproval')}</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
) {
return render(
<InlineEditProvider canEdit={providerProps.canEdit ?? false} {...providerProps}>
<DetailView schema={{ ...baseSchema, data: { ...baseSchema.data, ...data } }} inlineEdit />
</InlineEditProvider>,
);
}

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();
});
});
22 changes: 22 additions & 0 deletions packages/plugin-detail/src/__tests__/InlineEditContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ function Probe() {
return (
<div>
<span data-testid="editing">{String(inline.editing)}</span>
<span data-testid="locked">{String(inline.locked)}</span>
<span data-testid="lockedReason">{inline.lockedReason ?? ''}</span>
<span data-testid="draft">{JSON.stringify(inline.draft)}</span>
<span data-testid="focus">{inline.autoFocusField ?? ''}</span>
<button onClick={() => inline.enter('status')}>enter</button>
Expand Down Expand Up @@ -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(
<InlineEditProvider canEdit>
<Probe />
</InlineEditProvider>,
);
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(
<InlineEditProvider canEdit={false} locked lockedReason="Pending approval">
<Probe />
</InlineEditProvider>,
);
expect(screen.getByTestId('locked').textContent).toBe('true');
expect(screen.getByTestId('lockedReason').textContent).toBe('Pending approval');
});
});
8 changes: 8 additions & 0 deletions packages/plugin-detail/src/useDetailTranslation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,14 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record<string, string> = {
'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',
};

/**
Expand Down
33 changes: 32 additions & 1 deletion packages/react/src/context/InlineEditContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<InlineEditProviderProps> = ({
canEdit = true,
locked = false,
lockedReason,
children,
}) => {
const [editing, setEditing] = React.useState(false);
Expand Down Expand Up @@ -113,6 +142,8 @@ export const InlineEditProvider: React.FC<InlineEditProviderProps> = ({
() => ({
editing,
canEdit,
locked,
lockedReason,
draft,
autoFocusField,
saving,
Expand All @@ -124,7 +155,7 @@ export const InlineEditProvider: React.FC<InlineEditProviderProps> = ({
setSaving,
setError,
}),
[editing, canEdit, draft, autoFocusField, saving, error, enter, setField, teardown],
[editing, canEdit, locked, lockedReason, draft, autoFocusField, saving, error, enter, setField, teardown],
);

return <InlineEditContext.Provider value={value}>{children}</InlineEditContext.Provider>;
Expand Down
Loading