Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit 20c6d35

Browse files
fix(app-shell): the approval decision panel draws a localized notice off an approval request page (objectui#12072) (#12073)
Fixes #12072 Clause-②: yes ## What this does Placed anywhere other than an approval request's record page, the decision panel used to draw nothing. Its renderer (`ApprovalDecisionRenderer` in `@object-ui/app-shell`, the renderer for the proposed `record:approval_decision`, still module-internal) now draws a short, localized notice there instead of null. A block an author places on the wrong page is now visible to the author instead of silently empty. - **The "outside" test** is the existing `ctx?.objectName === SYS_APPROVAL_REQUEST_OBJECT` comparison. It stays one comparison, written as `!==` with an early return of the notice, and `row` is read from `ctx.data` after it. There is no second copy. - **Off a request page** (another object's record page, or a page with no record context at all): the notice. **On a request page whose row has not loaded yet:** null, as before. **On a request page with its row:** the panel, unchanged. - **One new language-pack key**, `approvalsInbox.decisionPanelOffRequestPage`, in all ten packs. No other key. - **Not here:** the registration, the index import, the known-types line and the zod arm, which belong to objectui#12045's page-mount step. No lint, save-door or schema rule (triage's choice). - The module docblock's "renders nothing" sentence now describes the notice. Changesets: `@object-ui/i18n` minor (one new pack key, so the exported `en` pack and the `TranslationKeys` type read off it gain one member) and `@object-ui/app-shell` patch, in `.changeset/12072-decision-panel-notice.md`. ## Zone 2 hypotheses, measured 1. **How the sibling renderers translate.** The approval module's siblings, `DecisionProgressIndicator` and `RecordApprovalsPanel`, call `useObjectTranslation` from `@object-ui/react` and read `approvalsInbox.*` rows by literal key, with an inline `defaultValue`. The notice does the same: `t('approvalsInbox.decisionPanelOffRequestPage', { defaultValue })`. The default equals the `en` value, which `check:i18n-keys` verifies. The key is `approvalsInbox.decisionPanelOffRequestPage`. 2. **The notice's look.** No notice component exists for a record block's "not applicable here" state. The nearest sibling state is `record:path`'s "no stages configured" placeholder (plugin-detail `record-path.tsx`, the `stages.length === 0` branch), a muted, italic, dashed-border box: `text-xs text-muted-foreground italic px-3 py-2 border border-dashed rounded`. The notice reuses that class string verbatim; `record:path`'s copy is a literal, this one comes from the packs. Nothing under `packages/components/src/ui/**` is touched. The node's `className` and designer attributes stay on the wrapper, as they do on the panel. 3. **No record context.** Outside a provider `useRecordContext()` returns null, so `ctx?.objectName` is undefined, which is not the request object, and the notice draws. That matches the card ("any page other than an approval request's") and the claim ("the context names another object, or none"). Pinned below. On the real request page, `RecordDetailView`'s `RecordContextProvider` names the object from the route on its first render, so no notice flashes while the request row loads. The console's `approvalRequestsDataSource.decisionPage.test.tsx`, which mounts the real request page with the renderer registered in-test, is 7/7 green on this head. ## Pins (`ApprovalDecisionPanel.test.tsx`) The claim's four, plus the no-context case and a designer-attribute check: - the block on a non-request record page renders the notice, whose text is the `en` pack value, with no panel and no actions bar; - the block with no record context at all renders the same notice; - the block on a request page renders the panel, and no notice; - the notice string is in the i18n bundle: all ten packs define the key with a non-empty value, a `zh` provider renders the `zh` pack value, and a control shows an `en` provider renders the `en` value and that the two packs differ; - on a request page whose row is not yet loaded, the node still renders nothing; - the notice keeps the node's `className`, `data-obj-id` and `data-obj-type`. ## Gates, on head `7d10b7c05` - `pnpm exec vitest run packages/app-shell/src/views/approval-decision/`: 1 file, 11 passed. - `pnpm exec vitest run apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx`: 1 file, 7 passed. - `pnpm exec vitest run packages/i18n/`: 82 files, 1,316 passed, 13 skipped. - `pnpm --filter @object-ui/app-shell type-check` and `pnpm --filter @object-ui/i18n type-check`: exit 0, both script names echoed. They ran after `turbo run build --filter='@object-ui/app-shell^...'` (28 tasks), so the types are read from fresh `dist` builds. `tsc -p tsconfig.test.json --listFilesOnly` lists the panel test, so the test is type-checked too. - `pnpm exec eslint` on the 12 touched code files: 0 errors, 0 warnings. - Exit 0: `check:i18n-keys`, `check:i18n-drift` ("0 en value(s) changed (1 key(s) added …)"), `check:i18n-dead-keys` (the new key is not listed), `check:i18n-designer-parity`, `check:control-bytes`, `check:test-path-roots`, `check:changeset-claims`, `check:pending-changeset-literals`, `check:new-line-citations`, `check:vi-mock-inherit`, `check:action-forward-parity` and `check:installed-pin-claims`. Also exit 0: the changeset presence, no-major, fixed and overwrite scripts. - **The eslint run covered only the touched files.** Three facts show that this narrowing excludes nothing: - the population is `eslint.config.js`'s `files: ['**/*.{ts,tsx}']`, which covers all 12 code files; - the JSON output counts 12 files; - the config has no `parserOptions` and no `projectService`, and no rule under `eslint-rules/` reads the filesystem. Linting is therefore per-file, and this diff cannot move a verdict on any untouched file. ## Ablation (predictions written before either leg ran) Both legs ran through `ablation-replace` in wrap mode. Each anchor hit exactly once, and each mutation was confirmed on disk by the anchor count and a blob change. After each leg, the file's blob equals its HEAD blob and `git diff HEAD` is empty. The test resolves `@object-ui/i18n` to source through the vitest alias, so no `dist` is in the path. - **Leg A: the renderer returns null off a request page again** (`if (ctx?.objectName !== SYS_APPROVAL_REQUEST_OBJECT) return null;`, with the notice block made dead code). Predicted 5 failed and 6 passed; observed 5 failed and 6 passed, the same five: both notice cases, the designer-attribute check, the `zh` provider and the `en` control. - **Leg B: the key's line deleted from the `zh` pack.** Predicted 2 failed and 9 passed; observed 2 failed and 9 passed: "all ten packs define the key" and "the `zh` provider renders the `zh` value" (it fell back to `en`). ## First-load bytes No entry imports the panel module: `git grep` for `ApprovalDecisionPanel` outside tests returns zero hits, and as a control the console test does import it. The `en` catalogue is in the eager closure, though, so the new `en` value adds bytes there. At the source level, `en.ts` grows by 102 B raw and 36 B gzip -9. `check:eager-closure` is NOT MEASURED here, because it needs a full console build; the CI Bundle Analysis job runs it. ## Acceptance notes - **objectui#12045's pending changeset** (`.changeset/12045-approval-decision-panel.md`) says that outside a `sys_approval_request` record page the panel "renders nothing". If both changesets release together, this change makes that clause false. That file is outside this claim's file surface and is not edited here. Instead, this PR's changeset says it replaces that clause. The seat may add an append-only "Superseded in part" paragraph there, as amendment 4 did for objectui#12032. - The dispatch reading "the notice should move no first-load bytes" holds for the renderer code, but not for the `en` value; see First-load bytes above. NOT MEASURED, left to CI: the full app-shell suite (per the dispatch), `check:eager-closure` and `check:eager-locale-catalogues` (both need a console build; no catalogue is added, so the eager composition is unchanged by construction), and the repo-wide `pnpm lint`. Session: `https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8` (the dispatching seat's session; this dev ran as its subagent). --- _Generated by [Claude Code](https://claude.ai/code/session_01CGZy1BGCjdN5cXqL9cnvB8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c0c0a0d commit 20c6d35

14 files changed

Lines changed: 126 additions & 9 deletions

File tree

‎.changeset/12045-approval-decision-panel.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ A pointer field draws the record it points at, the console reads an approval req
1313
- **The console's approval request page.** The console's record route for `sys_approval_request` mounts the record page over the routed approvals source, so the request carries the `viewer` block its declared actions gate on and the decision tally. The source now reads one request's `sys_approval_action` timeline from `GET /approvals/requests/:id/actions`, applying the read's `$orderby`, `$top` and `$skip` itself, dropping `$select` and `$expand`, and refusing any other parameter with `UNSUPPORTED_QUERY_PARAM`. On a request's own page the record view no longer asks for approvals opened on the request itself.
1414

1515
No language-pack key is added, and `CellRendererProps` is unchanged.
16+
17+
**Superseded in part (objectui#12072):** outside a `sys_approval_request` record page the panel now draws a short localized notice instead of nothing, so the sentence above saying it renders nothing there no longer holds when both changes release together.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/i18n': minor
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
The approval decision panel draws a short notice instead of nothing when it is placed anywhere other than an approval request's record page (objectui#12072).
7+
8+
- **`@object-ui/app-shell`.** Off a `sys_approval_request` record page, that is on another object's record page or on a page with no record at all, the decision panel (the renderer proposed as `record:approval_decision`, still module-internal) now draws a muted notice saying it works only on approval request pages. A misplaced block is therefore visible to its author instead of silently empty. On a request page whose record has not loaded yet it still draws nothing, and on a request page with its record it draws the panel as before. This replaces the clause of objectui#12045's entry that says the panel renders nothing outside a request page.
9+
- **`@object-ui/i18n`.** One new language-pack key for that notice, `approvalsInbox.decisionPanelOffRequestPage`, in all ten packs.
10+
11+
**Clause-②: yes (widening).** The published language packs gain one key, `approvalsInbox.decisionPanelOffRequestPage`, so the exported `en` pack and the `TranslationKeys` type read off it gain that one member. No export or prop is added or removed, and no existing key changes.

‎packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.test.tsx‎

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@
1818
* location, what happens after a decision). The real bar, its dialog and the
1919
* POST it sends are pinned over the real record page in the console's
2020
* `approvalRequestsDataSource.decisionPage.test.tsx`.
21+
*
22+
* Off a request page the node draws a localized notice instead of nothing
23+
* (objectui#12072), so a block placed on the wrong page is visible to its
24+
* author. On a request page whose row has not loaded it still draws nothing.
2125
*/
2226

2327
import * as React from 'react';
@@ -34,6 +38,8 @@ vi.mock('../DeclaredActionsBar.js', () => ({
3438

3539
import { ComponentRegistry } from '@object-ui/core';
3640
import { RecordContextProvider, subscribeDataChanges, type DataChange } from '@object-ui/react';
41+
import { I18nProvider } from '@object-ui/i18n';
42+
import { builtInLocales } from '@object-ui/i18n/locales';
3743
import { ApprovalDecisionRenderer } from './ApprovalDecisionPanel';
3844

3945
afterEach(() => {
@@ -60,6 +66,16 @@ function mountOn(objectName: string, data: Record<string, unknown> | undefined)
6066
);
6167
}
6268

69+
/** The notice's pack key, and what one pack says for it. */
70+
const NOTICE_KEY = 'approvalsInbox.decisionPanelOffRequestPage';
71+
const packNotice = (language: string): unknown =>
72+
NOTICE_KEY.split('.').reduce<unknown>(
73+
(node, part) => (node as Record<string, unknown> | undefined)?.[part],
74+
(builtInLocales as Record<string, unknown>)[language],
75+
);
76+
77+
const noticeText = () => screen.getByTestId('approval-decision-off-request-page').textContent;
78+
6379
describe('the approval decision panel (objectui#12045)', () => {
6480
it('is module-internal this round: loading it registers no component type', () => {
6581
expect(ComponentRegistry.has('record:approval_decision')).toBe(false);
@@ -77,6 +93,8 @@ describe('the approval decision panel (objectui#12045)', () => {
7793
expect(props.location).toBe('record_section');
7894
expect(props.record).toMatchObject({ id: 'req_12045', viewer: REQUEST.viewer });
7995
expect(props.exclude, 'nothing is excluded: no other part of this page offers a decision').toBeUndefined();
96+
expect(screen.getByTestId('approval-decision-panel')).toBeTruthy();
97+
expect(screen.queryByTestId('approval-decision-off-request-page'), 'no notice on a request page').toBeNull();
8098
});
8199

82100
it('draws no tally for a node that carries none, and still offers the actions', () => {
@@ -100,12 +118,62 @@ describe('the approval decision panel (objectui#12045)', () => {
100118
]);
101119
});
102120

103-
it.each([
104-
['another object', 'invoice', { id: 'inv_1' }],
105-
['a request page whose record has not loaded', 'sys_approval_request', undefined],
106-
])('renders nothing on %s', (_case, objectName, data) => {
107-
const { container } = mountOn(objectName, data);
121+
it('renders nothing on a request page whose record has not loaded', () => {
122+
const { container } = mountOn('sys_approval_request', undefined);
108123
expect(container.innerHTML).toBe('');
109124
expect(barProps).toHaveLength(0);
110125
});
111126
});
127+
128+
describe('off an approval request page, a localized notice instead of nothing (objectui#12072)', () => {
129+
it.each([
130+
["another object's record page", () => mountOn('invoice', { id: 'inv_1' })],
131+
['a page with no record context', () => render(<ApprovalDecisionRenderer />)],
132+
])('draws the notice on %s, and no panel', (_case, mount) => {
133+
mount();
134+
expect(noticeText()).toBe(packNotice('en'));
135+
expect(screen.queryByTestId('approval-decision-panel')).toBeNull();
136+
expect(barProps).toHaveLength(0);
137+
});
138+
139+
it("keeps the node's className and designer attributes on the notice", () => {
140+
render(<ApprovalDecisionRenderer className="mt-4" data-obj-id="node_1" data-obj-type="record:approval_decision" />);
141+
const wrapper = screen.getByTestId('approval-decision-off-request-page').parentElement!;
142+
expect(wrapper.className).toBe('mt-4');
143+
expect(wrapper.getAttribute('data-obj-id')).toBe('node_1');
144+
expect(wrapper.getAttribute('data-obj-type')).toBe('record:approval_decision');
145+
});
146+
147+
it('the notice copy is a key in all ten language packs', () => {
148+
const languages = Object.keys(builtInLocales);
149+
expect(languages).toHaveLength(10);
150+
for (const language of languages) {
151+
const value = packNotice(language);
152+
expect(typeof value, `${language} defines ${NOTICE_KEY}`).toBe('string');
153+
expect((value as string).trim(), `${language} defines ${NOTICE_KEY}`).not.toBe('');
154+
}
155+
});
156+
157+
it('renders the active language: the zh pack value under a zh provider', () => {
158+
render(
159+
<I18nProvider config={{ defaultLanguage: 'zh', detectBrowserLanguage: false }} persistLanguage={false}>
160+
<RecordContextProvider objectName="invoice" recordId="inv_1" data={{ id: 'inv_1' }}>
161+
<ApprovalDecisionRenderer />
162+
</RecordContextProvider>
163+
</I18nProvider>,
164+
);
165+
expect(noticeText()).toBe(packNotice('zh'));
166+
});
167+
168+
it('CONTROL: the en provider renders the en value, and the two packs differ', () => {
169+
render(
170+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
171+
<RecordContextProvider objectName="invoice" recordId="inv_1" data={{ id: 'inv_1' }}>
172+
<ApprovalDecisionRenderer />
173+
</RecordContextProvider>
174+
</I18nProvider>,
175+
);
176+
expect(noticeText()).toBe(packNotice('en'));
177+
expect(packNotice('zh')).not.toBe(packNotice('en'));
178+
});
179+
});

‎packages/app-shell/src/views/approval-decision/ApprovalDecisionPanel.tsx‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@
3737
* silently ignored prop.
3838
*
3939
* Outside a `sys_approval_request` record page there is no request to decide
40-
* on, and the panel renders nothing.
40+
* on, so the node draws a short notice saying it works only on approval request
41+
* pages (objectui#12072). A block placed on another object's record page, or on
42+
* a page with no record at all, is thereby visible to its author instead of
43+
* silently empty. On a request page whose row has not loaded yet, the node
44+
* still draws nothing.
4145
*
4246
* ## After a decision
4347
*
@@ -68,7 +72,7 @@
6872
*/
6973

7074
import * as React from 'react';
71-
import { notifyDataChanged, useRecordContext } from '@object-ui/react';
75+
import { notifyDataChanged, useObjectTranslation, useRecordContext } from '@object-ui/react';
7276
import { cn } from '@object-ui/components';
7377
import { DecisionProgressIndicator } from '../approval-progress/DecisionProgressIndicator.js';
7478
import { DeclaredActionsBar } from '../DeclaredActionsBar.js';
@@ -123,15 +127,37 @@ export interface ApprovalDecisionRendererProps {
123127
[k: string]: unknown;
124128
}
125129

126-
/** The node renderer: the panel over the bound request, or nothing. */
130+
/**
131+
* The node renderer: the panel over the bound request; off a request page, a
132+
* notice that the block works only there; on a request page whose row has not
133+
* loaded, nothing.
134+
*
135+
* The notice is the muted dashed placeholder `record:path` draws when it cannot
136+
* do its job where it was placed, with its copy from the language packs.
137+
*/
127138
export const ApprovalDecisionRenderer: React.FC<ApprovalDecisionRendererProps> = ({
128139
schema: _schema,
129140
className,
130141
...props
131142
}) => {
132143
const ctx = useRecordContext();
144+
const { t } = useObjectTranslation();
133145
const designer = splitDesigner(props);
134-
const row = ctx?.objectName === SYS_APPROVAL_REQUEST_OBJECT ? ctx.data : undefined;
146+
if (ctx?.objectName !== SYS_APPROVAL_REQUEST_OBJECT) {
147+
return (
148+
<div className={className} {...designer}>
149+
<div
150+
className="text-xs text-muted-foreground italic px-3 py-2 border border-dashed rounded"
151+
data-testid="approval-decision-off-request-page"
152+
>
153+
{String(t('approvalsInbox.decisionPanelOffRequestPage', {
154+
defaultValue: 'The approval decision panel only works on approval request pages.',
155+
}))}
156+
</div>
157+
</div>
158+
);
159+
}
160+
const row = ctx.data;
135161
if (row == null || typeof row !== 'object' || (row as { id?: unknown }).id == null) return null;
136162
return (
137163
<div className={className} {...designer}>

‎packages/i18n/src/locales/ar.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3907,6 +3907,7 @@ const ar = {
39073907
progressEligible_other: '{{count}} معتمِد مؤهل',
39083908
progressBar: 'تقدم القرار',
39093909
declaredActions: 'الإجراءات',
3910+
decisionPanelOffRequestPage: 'لوحة قرار الموافقة تعمل فقط في صفحات طلبات الموافقة.',
39103911
attachmentChip: 'مرفق',
39113912
attachmentOpenFailed: 'تعذّر فتح المرفق — يرجى المحاولة مرة أخرى',
39123913
approveOneTitle: 'الموافقة على "{{title}}"؟',

‎packages/i18n/src/locales/de.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3616,6 +3616,7 @@ const de = {
36163616
progressEligible_other: '{{count}} berechtigte Genehmiger',
36173617
progressBar: 'Entscheidungsfortschritt',
36183618
declaredActions: 'Aktionen',
3619+
decisionPanelOffRequestPage: 'Der Entscheidungsbereich für Genehmigungen funktioniert nur auf Seiten von Genehmigungsanfragen.',
36193620
attachmentChip: 'Anhang',
36203621
attachmentOpenFailed: 'Anhang konnte nicht geöffnet werden – bitte erneut versuchen',
36213622
approveOneTitle: '„{{title}}“ genehmigen?',

‎packages/i18n/src/locales/en.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4247,6 +4247,7 @@ const en = {
42474247
progressEligible_other: '{{count}} eligible approvers',
42484248
progressBar: 'Decision progress',
42494249
declaredActions: 'Actions',
4250+
decisionPanelOffRequestPage: 'The approval decision panel only works on approval request pages.',
42504251
attachmentChip: 'Attachment',
42514252
attachmentOpenFailed: 'Could not open the attachment — please try again',
42524253
approveOneTitle: 'Approve "{{title}}"?',

‎packages/i18n/src/locales/es.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3687,6 +3687,7 @@ const es = {
36873687
progressEligible_other: '{{count}} aprobadores elegibles',
36883688
progressBar: 'Progreso de la decisión',
36893689
declaredActions: 'Acciones',
3690+
decisionPanelOffRequestPage: 'El panel de decisión de aprobación solo funciona en las páginas de solicitudes de aprobación.',
36903691
attachmentChip: 'Adjunto',
36913692
attachmentOpenFailed: 'No se pudo abrir el archivo adjunto: inténtalo de nuevo',
36923693
approveOneTitle: '¿Aprobar «{{title}}»?',

‎packages/i18n/src/locales/fr.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3684,6 +3684,7 @@ const fr = {
36843684
progressEligible_other: '{{count}} approbateurs éligibles',
36853685
progressBar: 'Avancement de la décision',
36863686
declaredActions: 'Actions',
3687+
decisionPanelOffRequestPage: 'Le panneau de décision d\'approbation ne fonctionne que sur les pages de demande d\'approbation.',
36873688
attachmentChip: 'Pièce jointe',
36883689
attachmentOpenFailed: "Impossible d'ouvrir la pièce jointe — veuillez réessayer",
36893690
approveOneTitle: 'Approuver « {{title}} » ?',

‎packages/i18n/src/locales/ja.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3618,6 +3618,7 @@ const ja = {
36183618
progressEligible_other: '承認可能者 {{count}} 名',
36193619
progressBar: '承認の進捗',
36203620
declaredActions: 'アクション',
3621+
decisionPanelOffRequestPage: '承認の決定パネルは承認リクエストのページでのみ機能します。',
36213622
attachmentChip: '添付ファイル',
36223623
attachmentOpenFailed: '添付ファイルを開けませんでした。もう一度お試しください',
36233624
approveOneTitle: '「{{title}}」を承認しますか?',

0 commit comments

Comments
 (0)