Skip to content

Commit af6ca00

Browse files
feat(console): the approvals inbox keeps a way back from a record, takes an optional Reject reason, and marks requests you submitted (#28415)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuJC8PModNwfD1TdoWpgVib6)_ Part of #27520 (row 12 only: the approvals inbox; the card's other open rows stay on it) Clause-②: yes (widening: `@object-ui/i18n` gains `approvalsInbox.submittedByYou` in all ten locales; no accept set moves) Metadata-protocol: none ## 维护者速读 - **改了什么**:审批收件箱三处小改。点行里的记录链接打开记录后,记录页顶部多一个「‹ Approvals Inbox」返回链接;驳回确认框(单条和批量)多一个选填的意见框;自己提交的申请在申请人旁标「You submitted this」。 - **为什么改**:RC 走查发现:从 Account 应用的收件箱点进记录,回不到收件箱;驳回不能写理由;管理员看不出哪条是自己提交的。 - **风险与回滚**:只动收件箱页面和一个新的翻译键,不改谁能审批、不改服务端。驳回意见走审批接口本来就收的 `comment` 字段;留空时请求与原来完全一样。回滚即 revert 本 PR。 - **先看**:`ui/apps/console/src/pages/system/ApprovalsInboxPage.tsx`(`RecordCell` 的 `state`、两个驳回框、`SelfSubmittedBadge`);`ApprovalsInboxPage.recordBackLink-27520.test.tsx`。 - **两分钟验证**:`pnpm --dir ui exec vitest run apps/console/src/pages/system/` → 35 个文件全绿;或在 showcase 里以管理员进 Account → Approvals,点记录链接,记录页左上出现「‹ Approvals Inbox」。 - **需要你做的**:无,席位落地(新增 i18n 键属 Clause-② widening,席位先做 TIER_DEFAULT 复核)。 ## What changes Measured on `main` `75ecaf4a` before any code; all three observations of row 12 still held: | Observation (row 12) | On `main` | This PR | |:--|:--|:--| | The record link opens the record inside the Account app shell with no way back | The link targets `/apps/INBOX_APP/OBJECT/record/ID` and carries no origin, so `RecordDetailView`'s crumb row (which draws `state.from` or a `?from=` trail) draws nothing | Target unchanged (the existing record-link pin and the served-page ruling keep it in the inbox's app). The link now hands the record page `state.from = { pathname: INBOX_PATH_AND_SEARCH, label: INBOX_TITLE }`, the same origin a list row hands it, so the page draws "‹ Approvals Inbox" | | Reject asks for no reason | The row and bulk Reject confirmations send `{ actor_id }` only | Both offer an optional comment box (the existing `approvalsInbox.comment` label, "Comment (optional)"). A non-empty, trimmed reason is sent as `comment`, which `POST /approvals/requests/:id/reject` already accepts. Empty sends `{ actor_id }` as before. Closing a dialog clears it | | The admin may decide a request they submitted | No marker | A row whose server-attached `viewer.is_submitter` is true shows "You submitted this" beside the requester (desktop row and phone card). Not repeated on the Submitted by me tab. It gates nothing: who may decide is unchanged | No server, spec or permission change. The reject reason needed no new field. ## Tests - New `ApprovalsInboxPage.recordBackLink-27520.test.tsx` (2): mounts the real inbox and, on the record route, the real `RecordDetailView`. Clicking the record link keeps the inbox-app href, and the record page draws a link named "Approvals Inbox" that returns to the inbox. Control: a record page reached directly draws none. - New `ApprovalsInboxPage.rejectReasonSelfSubmitted-27520.test.tsx` (7): the typed reason reaches `approvalsApi.reject` trimmed as `comment`; empty sends no `comment` and a reopened dialog starts empty; the bulk reason goes on every selected request; the marker shows on the self-submitted row and card only; the marked row stays decidable; the Submitted by me tab does not repeat it; a row without `viewer.is_submitter` is not marked (the server flag decides, not a client id match). - Red first on unfixed code: the back-link case failed ("Unable to find role=link and name Approvals Inbox"); four of the queue cases failed (no comment box, no marker). - `pnpm --dir ui exec vitest run apps/console/src/pages/system/` at the fix: 35 files, 225 tests passed (lock VERDICT command-exit 0). <details><summary>Ablations (scripts/ablation-replace.mjs, each restored to blob == HEAD 46056de)</summary> | Leg | Mutation | Result | |:--|:--|:--| | 1 | delete `state={linkState}` | back-link case red (1 failed / 1 passed) | | 2 | `decisionComment` returns `{}` | row and bulk reason cases red (2 failed / 5 passed) | | 3 | drop the `tab !== 'submitted'` guard (desktop) | Submitted-tab case red | | 4 | marker keyed on `submitter_id === user.id` instead of `viewer.is_submitter` | server-flag case red | </details> ## Acceptance notes - Opening a request (row click, card, Enter) goes to the request's served page as before; it carries no origin here. That page is reached by the row, so browser Back returns, and the app's own nav lists the inbox. Only the record link was named in row 12. - The approve confirmation still says "To add a comment or attachment, open the request instead." It was not in row 12. ## Verification Local subset, measured at `9a6804d1` (the fix commit); the later merge of `origin/main` (`f7ef4650`) brought only two plugin-audit test files, none of which these gates or tests read. - `node scripts/pm/dispatch-gates.mjs --commands`: 34 derived, all 34 run with exit 0; `--ran` reconcile: "34 derived, 34 run, 0 NOT-MEASURED (a DERIVED zero — all 34 recorded an exit code)". Includes `node scripts/check-sdui-manifest.mjs` (exit 0) and `pnpm --dir ui exec vitest run scripts/` (169 files passed, 2 skipped; 4901 tests passed). - `pnpm --dir ui check:new-line-citations`: 0 new citations, exit 0. - eslint on the three console files and `ui/packages/i18n/src/locales/`: 0 errors; the 13 warnings sit on unchanged lines of `ApprovalsInboxPage.tsx`. - `pnpm --dir ui exec vitest run packages/i18n/`: 94 files passed, 1438 tests passed, 13 skipped. - `turbo run type-check --filter=@object-ui/console --filter=@object-ui/i18n`: 72 of 72 tasks successful (lock VERDICT command-exit 0). - `git merge-tree --write-tree` against the two open PRs that touch the same locale files (#28400, #28394): clean. No open PR touches `ApprovalsInboxPage*`. - No live browser run: the back-link case mounts the real `RecordDetailView` on the real route instead. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgWckvpzUHJaGf595aP)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgWckvpzUHJaGf595aP Co-authored-by: Claude <noreply@anthropic.com>
1 parent a727ba2 commit af6ca00

14 files changed

Lines changed: 531 additions & 9 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/console': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(console): the approvals inbox keeps a way back from a record, takes an optional reason on Reject, and marks requests you submitted
7+
8+
Clause-②: yes (widening: `@object-ui/i18n` gains the `approvalsInbox.submittedByYou` key in all ten locales; no accept set moves)
9+
Metadata-protocol: none
10+
11+
- **Back to the inbox.** A row's record link still opens the record in the inbox's own app. When that app's navigation does not list the record's object (the Account app lists the inbox), the record page had no way back. The link now hands the record page the same origin a list row does, so the page draws "‹ Approvals Inbox" above the record.
12+
- **Reason on Reject.** The Reject confirmation, for one row and for a bulk selection, offers an optional comment. What the approver types is sent as the reject call's `comment`, which the approvals service already records on the request's timeline. Left empty, the reject is sent as before.
13+
- **"You submitted this".** A request the viewer submitted shows "You submitted this" beside the requester, read from the server's `viewer.is_submitter`. It informs the decision and gates nothing; who may decide a request is unchanged. The Submitted by me tab does not repeat it on every row.
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* objectstack#27520 row 12 — a row's record link keeps a way back to the inbox.
6+
*
7+
* The record link stays where it was: the console's standard record route in
8+
* the inbox's own app segment (`ApprovalsInboxPage.recordLink.test.tsx` pins
9+
* the href). Inside an app whose navigation does not list the record's object,
10+
* such as the Account app, which lists the inbox, the record page had no way
11+
* back. The link now hands the record page the history-state origin every list
12+
* row hands it (`state.from`), so the page's crumb row draws a link back to the
13+
* inbox, named as the inbox's own header names it.
14+
*
15+
* The harness mounts the REAL inbox page and, on the record route, the REAL
16+
* `RecordDetailView`; only the HTTP transport, the console adapter and the
17+
* inbox's list read (`approvalsApi.listRequests`) are doubled.
18+
*/
19+
import '@testing-library/jest-dom/vitest';
20+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
21+
import { render, screen, cleanup, fireEvent, within } from '@testing-library/react';
22+
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
23+
24+
const { approvalsApiStub, ROW, AUTH } = vi.hoisted(() => {
25+
const ROW = {
26+
id: 'req_27520',
27+
process_name: 'flow:invoice_approval',
28+
process_label: 'Invoice Approval',
29+
object_name: 'invoice',
30+
object_label: 'Invoice',
31+
record_id: 'inv_1',
32+
record_title: 'INV-1010',
33+
status: 'pending',
34+
pending_approvers: ['u1'],
35+
submitter_id: 'u2',
36+
submitter_name: 'Sam Submitter',
37+
submitted_at: '2026-10-20T09:00:00.000Z',
38+
updated_at: '2026-10-20T09:00:00.000Z',
39+
};
40+
const approvalsApiStub = {
41+
listRequests: vi.fn(async () => ({ data: [ROW], total: 1 })),
42+
getRequest: vi.fn(async () => ({ data: ROW })),
43+
listActions: vi.fn(async () => ({ data: [] })),
44+
approve: vi.fn(async () => ({ data: ROW, finalized: true })),
45+
reject: vi.fn(async () => ({ data: ROW, finalized: true })),
46+
};
47+
// A STABLE singleton: a fresh user per render re-runs the inbox's load
48+
// effect forever, and the queue never leaves its skeleton.
49+
const AUTH = { user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null };
50+
return { approvalsApiStub, ROW, AUTH };
51+
});
52+
53+
vi.mock('@object-ui/auth', async (importOriginal) => ({
54+
...(await importOriginal<Record<string, unknown>>()),
55+
useAuth: () => AUTH,
56+
}));
57+
vi.mock('@object-ui/collaboration', async (importOriginal) => ({
58+
...(await importOriginal<Record<string, unknown>>()),
59+
useRecordPresence: () => [],
60+
PresenceAvatars: () => null,
61+
}));
62+
vi.mock('sonner', () => ({
63+
toast: Object.assign(vi.fn(), {
64+
success: vi.fn(), error: vi.fn(), info: vi.fn(),
65+
warning: vi.fn(), loading: vi.fn(), dismiss: vi.fn(),
66+
}),
67+
}));
68+
vi.mock('../../services/approvalsApi', async (importOriginal) => ({
69+
...(await importOriginal<Record<string, unknown>>()),
70+
approvalsApi: approvalsApiStub,
71+
}));
72+
73+
import { AdapterCtx, MetadataCtx, SchemaRendererProvider, useAdapter } from '@object-ui/react';
74+
import { I18nProvider, type I18nConfig } from '@object-ui/i18n';
75+
import { RecordDetailView } from '@object-ui/app-shell';
76+
import { ApprovalsInboxPage } from './ApprovalsInboxPage';
77+
78+
/** The Account app's route segment: its nav lists the inbox, not invoices. */
79+
const APP = 'account';
80+
const INBOX = `/apps/${APP}/component/approvals/inbox`;
81+
const RECORD_PAGE = `/apps/${APP}/invoice/record/${ROW.record_id}`;
82+
83+
const INVOICE_DEF = {
84+
name: 'invoice',
85+
label: 'Invoice',
86+
pluralLabel: 'Invoices',
87+
nameField: 'name',
88+
fields: { name: { type: 'text', label: 'Invoice Number' } },
89+
};
90+
const RECORD = { id: ROW.record_id, name: ROW.record_title };
91+
92+
function json(status: number, body: unknown): Response {
93+
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
94+
}
95+
vi.stubGlobal('fetch', vi.fn(async () => json(200, { success: true, data: [] })));
96+
97+
const host = {
98+
// The inbox's readability probe reads `id in (…)`: the record is readable.
99+
find: vi.fn(async (_object: string, params?: Record<string, unknown>) => {
100+
const ids = (params?.$filter as { id?: { $in?: string[] } } | undefined)?.id?.$in;
101+
return ids ? { data: ids.map((id) => ({ id })), total: ids.length } : { data: [], total: 0 };
102+
}),
103+
findOne: vi.fn(async (object: string, id: string) => (object === 'invoice' && id === RECORD.id ? RECORD : null)),
104+
create: vi.fn(), update: vi.fn(), delete: vi.fn(),
105+
getObjectSchema: vi.fn(async (name: string) => (name === 'invoice' ? INVOICE_DEF : null)),
106+
};
107+
const metadata = {
108+
apps: [], objects: [INVOICE_DEF], dashboards: [], reports: [],
109+
pages: [], loading: false, error: null,
110+
refresh: async () => {}, invalidate: () => {}, ensureType: async () => [],
111+
getItem: vi.fn(async (type: string, name: string) => (type === 'object' && name === 'invoice' ? INVOICE_DEF : null)),
112+
getItemsByType: () => [],
113+
getTypeStatus: () => 'ready' as const,
114+
};
115+
const EN: I18nConfig = { defaultLanguage: 'en', detectBrowserLanguage: false };
116+
117+
/** `DefaultAppContent`'s generic record route: the view over `useAdapter()`. */
118+
function GenericRecordRoute() {
119+
return <RecordDetailView dataSource={useAdapter()} objects={metadata.objects as never} onEdit={() => {}} />;
120+
}
121+
122+
function RouterProbe() {
123+
const location = useLocation();
124+
return <output data-testid="where">{`${location.pathname}${location.search}`}</output>;
125+
}
126+
127+
function renderAt(entry: string) {
128+
return render(
129+
<I18nProvider config={EN} persistLanguage={false}>
130+
<MemoryRouter initialEntries={[entry]}>
131+
<SchemaRendererProvider dataSource={host as never}>
132+
<MetadataCtx.Provider value={metadata as never}>
133+
<AdapterCtx.Provider value={host as never}>
134+
<RouterProbe />
135+
<Routes>
136+
<Route path="/apps/:appName/component/approvals/inbox" element={<ApprovalsInboxPage />} />
137+
<Route path="/apps/:appName/:objectName/record/:recordId" element={<GenericRecordRoute />} />
138+
</Routes>
139+
</AdapterCtx.Provider>
140+
</MetadataCtx.Provider>
141+
</SchemaRendererProvider>
142+
</MemoryRouter>
143+
</I18nProvider>,
144+
);
145+
}
146+
147+
const where = () => screen.getByTestId('where').textContent;
148+
149+
/** The desktop row's record link (the mobile card draws none). */
150+
async function recordLink(): Promise<HTMLElement> {
151+
await screen.findAllByText(ROW.record_title, undefined, { timeout: 8000 });
152+
const row = screen.getAllByRole('row').find((r) => within(r).queryAllByText(ROW.record_title).length > 0);
153+
if (!row) throw new Error('no inbox row for the request');
154+
return within(row).findByRole('link', { name: ROW.record_title }, { timeout: 8000 });
155+
}
156+
157+
beforeEach(() => {
158+
for (const fn of Object.values(approvalsApiStub)) fn.mockClear();
159+
});
160+
afterEach(cleanup);
161+
162+
describe('the inbox record link keeps a way back to the inbox (objectstack#27520 row 12)', () => {
163+
it('the record page it opens draws a link back to the inbox, and the link returns there', async () => {
164+
renderAt(INBOX);
165+
const link = await recordLink();
166+
// The target app stays the inbox's own (the recordLink pin's href).
167+
expect(link).toHaveAttribute('href', RECORD_PAGE);
168+
fireEvent.click(link);
169+
expect(where()).toBe(RECORD_PAGE);
170+
171+
const back = await screen.findByRole('link', { name: 'Approvals Inbox' }, { timeout: 8000 });
172+
expect(back).toHaveAttribute('href', INBOX);
173+
fireEvent.click(back);
174+
expect(where()).toBe(INBOX);
175+
});
176+
177+
it('CONTROL: a record page reached without the inbox draws no such link', async () => {
178+
renderAt(RECORD_PAGE);
179+
await screen.findAllByText(ROW.record_title, undefined, { timeout: 8000 });
180+
expect(screen.queryByRole('link', { name: 'Approvals Inbox' })).toBeNull();
181+
});
182+
});

0 commit comments

Comments
 (0)