|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * The approvals inbox names the flow, the step and the object in the reader's |
| 11 | + * UI language (objectstack#26883). |
| 12 | + * |
| 13 | + * The approvals door serves `process_label`, `step_label` and `object_label` |
| 14 | + * in the language the request's `Accept-Language` names, from the app's pack |
| 15 | + * (`flows.<flow>.label`, `flows.<flow>.approvals.<node_id>.label`, |
| 16 | + * `objects.<object>.label`), and the stored authoring text when the pack has |
| 17 | + * no entry. The request page reads through the console's authenticated fetch, |
| 18 | + * which stamps the active UI language; the inbox's `approvalsApi` called a |
| 19 | + * bare `fetch`, which carries only the browser's language, so a reader who |
| 20 | + * switched the console to 中文 read the inbox in English. |
| 21 | + * |
| 22 | + * Nothing between the page and the wire is stubbed here: the real |
| 23 | + * `I18nProvider` writes `<html lang>`, the real `createAuthenticatedFetch` |
| 24 | + * reads it, and the global `fetch` below stands in for the door, answering in |
| 25 | + * the language it is asked for, as the server does. A page that does not send |
| 26 | + * the UI language gets the English labels and goes red. |
| 27 | + */ |
| 28 | + |
| 29 | +import '@testing-library/jest-dom/vitest'; |
| 30 | +import { useEffect, useState, type ReactNode } from 'react'; |
| 31 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 32 | +import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; |
| 33 | +import { MemoryRouter, Route, Routes } from 'react-router-dom'; |
| 34 | +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; |
| 35 | +import { I18nProvider } from '@object-ui/i18n'; |
| 36 | + |
| 37 | +const { ADAPTER, AUTH } = vi.hoisted(() => ({ |
| 38 | + ADAPTER: { |
| 39 | + find: async () => ({ data: [{ id: 'opp_1' }, { id: 'exp_1' }] }), |
| 40 | + getObjectSchema: async () => ({ fields: {} }), |
| 41 | + }, |
| 42 | + AUTH: { user: { id: 'u_1', email: 'approver@example.com' } }, |
| 43 | +})); |
| 44 | + |
| 45 | +vi.mock('@object-ui/auth', async (importOriginal) => ({ |
| 46 | + ...(await importOriginal<Record<string, unknown>>()), |
| 47 | + useAuth: () => AUTH, |
| 48 | +})); |
| 49 | + |
| 50 | +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ |
| 51 | + ...(await importOriginal<Record<string, unknown>>()), |
| 52 | + useAdapter: () => ADAPTER, |
| 53 | + useMetadata: () => ({ objects: [] }), |
| 54 | + DeclaredActionsBar: () => null, |
| 55 | + isViaOverrideRow: () => false, |
| 56 | +})); |
| 57 | + |
| 58 | +import { ApprovalsInboxPage } from './ApprovalsInboxPage'; |
| 59 | + |
| 60 | +/** The app's zh-CN pack, as the door would read it; `expense_approval` has no entry. */ |
| 61 | +const ZH_PACK: Record<string, string> = { |
| 62 | + 'flows.opportunity_approval.label': '大额商机审批', |
| 63 | + 'flows.opportunity_approval.approvals.manager_review.label': '销售经理审核', |
| 64 | + 'objects.opportunity.label': '商机', |
| 65 | +}; |
| 66 | + |
| 67 | +function row(id: string, flow: string, node: string, object: string, stored: { process: string; step: string; object: string }) { |
| 68 | + return { |
| 69 | + id, |
| 70 | + process_name: `flow:${flow}`, |
| 71 | + process_label: stored.process, |
| 72 | + current_step: node, |
| 73 | + step_label: stored.step, |
| 74 | + object_name: object, |
| 75 | + object_label: stored.object, |
| 76 | + record_id: id, |
| 77 | + record_title: `Record ${id}`, |
| 78 | + status: 'pending', |
| 79 | + pending_approvers: ['u_1'], |
| 80 | + submitter_id: 'u_2', |
| 81 | + submitter_name: 'Sam Submitter', |
| 82 | + submitted_at: new Date().toISOString(), |
| 83 | + }; |
| 84 | +} |
| 85 | + |
| 86 | +/** The approvals door: each label in the asked language when the pack has it, else as stored. */ |
| 87 | +function doorRows(acceptLanguage: string | null) { |
| 88 | + const zh = /^zh\b/i.test(acceptLanguage ?? ''); |
| 89 | + const pick = (key: string, stored: string) => (zh && ZH_PACK[key]) || stored; |
| 90 | + return [ |
| 91 | + row('opp_1', 'opportunity_approval', 'manager_review', 'opportunity', { |
| 92 | + process: pick('flows.opportunity_approval.label', 'Large Deal Approval'), |
| 93 | + step: pick('flows.opportunity_approval.approvals.manager_review.label', 'Sales Manager Review'), |
| 94 | + object: pick('objects.opportunity.label', 'Opportunity'), |
| 95 | + }), |
| 96 | + row('exp_1', 'expense_approval', 'finance_review', 'expense', { |
| 97 | + process: pick('flows.expense_approval.label', 'Expense Approval'), |
| 98 | + step: pick('flows.expense_approval.approvals.finance_review.label', 'Finance Review'), |
| 99 | + object: pick('objects.expense.label', 'Expense'), |
| 100 | + }), |
| 101 | + ]; |
| 102 | +} |
| 103 | + |
| 104 | +let seenLanguages: Array<string | null> = []; |
| 105 | + |
| 106 | +beforeEach(() => { |
| 107 | + seenLanguages = []; |
| 108 | + document.documentElement.lang = ''; |
| 109 | + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { |
| 110 | + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; |
| 111 | + const lang = new Headers(init?.headers).get('Accept-Language'); |
| 112 | + if (/\/approvals\/requests(\?|$)/.test(url)) { |
| 113 | + seenLanguages.push(lang); |
| 114 | + const data = doorRows(lang); |
| 115 | + return new Response(JSON.stringify({ data, total: data.length }), { status: 200 }); |
| 116 | + } |
| 117 | + return new Response(JSON.stringify({ data: [] }), { status: 200 }); |
| 118 | + })); |
| 119 | +}); |
| 120 | + |
| 121 | +afterEach(() => { |
| 122 | + cleanup(); |
| 123 | + vi.unstubAllGlobals(); |
| 124 | +}); |
| 125 | + |
| 126 | +function permissions(): MePermissionsResponse { |
| 127 | + return { |
| 128 | + authenticated: true, |
| 129 | + userId: 'u_1', |
| 130 | + tenantId: 't_1', |
| 131 | + roles: [], |
| 132 | + permissionSets: [], |
| 133 | + objects: {}, |
| 134 | + fields: {}, |
| 135 | + systemPermissions: ['setup.access'], |
| 136 | + }; |
| 137 | +} |
| 138 | + |
| 139 | +function renderInbox(language: string) { |
| 140 | + return render( |
| 141 | + <I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}> |
| 142 | + <MePermissionsProvider initialPermissions={permissions()}> |
| 143 | + <MemoryRouter initialEntries={['/apps/app/system/approvals']}> |
| 144 | + <Routes> |
| 145 | + <Route path="/apps/:appName/system/approvals" element={<AfterBoot><ApprovalsInboxPage /></AfterBoot>} /> |
| 146 | + </Routes> |
| 147 | + </MemoryRouter> |
| 148 | + </MePermissionsProvider> |
| 149 | + </I18nProvider>, |
| 150 | + ); |
| 151 | +} |
| 152 | + |
| 153 | +/** |
| 154 | + * Mounts its children one commit after the providers. The console mounts a |
| 155 | + * page behind its boot (auth, routing), long after `I18nProvider` has written |
| 156 | + * `<html lang>`; a page mounted in the provider's own first commit would run |
| 157 | + * its fetch effect before the provider's (children's effects run first). |
| 158 | + */ |
| 159 | +function AfterBoot({ children }: { children: ReactNode }) { |
| 160 | + const [booted, setBooted] = useState(false); |
| 161 | + useEffect(() => setBooted(true), []); |
| 162 | + return booted ? <>{children}</> : null; |
| 163 | +} |
| 164 | + |
| 165 | +/** The queue row (table row) that holds `recordTitle`. */ |
| 166 | +async function queueRow(recordTitle: string): Promise<HTMLElement> { |
| 167 | + return waitFor(() => { |
| 168 | + const tr = screen.getAllByText(recordTitle) |
| 169 | + .map((el) => el.closest('tr')) |
| 170 | + .find((el): el is HTMLTableRowElement => !!el); |
| 171 | + expect(tr, `no table row holds ${recordTitle}`).toBeTruthy(); |
| 172 | + return tr!; |
| 173 | + }); |
| 174 | +} |
| 175 | + |
| 176 | +describe('ApprovalsInboxPage — flow, step and object labels in the reader language (objectstack#26883)', () => { |
| 177 | + it('asks the approvals door in the active UI language', async () => { |
| 178 | + renderInbox('zh'); |
| 179 | + await queueRow('Record opp_1'); |
| 180 | + expect(seenLanguages.length).toBeGreaterThan(0); |
| 181 | + expect(seenLanguages.every((l) => l === 'zh'), `saw: ${JSON.stringify(seenLanguages)}`).toBe(true); |
| 182 | + }); |
| 183 | + |
| 184 | + it('the queue row shows the pack\'s flow, step and object labels under zh', async () => { |
| 185 | + renderInbox('zh'); |
| 186 | + const tr = await queueRow('Record opp_1'); |
| 187 | + expect(within(tr).getByText('大额商机审批')).toBeInTheDocument(); |
| 188 | + expect(within(tr).getByText('销售经理审核')).toBeInTheDocument(); |
| 189 | + expect(within(tr).getByText('商机')).toBeInTheDocument(); |
| 190 | + expect(within(tr).queryByText('Opportunity')).toBeNull(); |
| 191 | + expect(within(tr).queryByText('Large Deal Approval')).toBeNull(); |
| 192 | + expect(within(tr).queryByText('Sales Manager Review')).toBeNull(); |
| 193 | + }); |
| 194 | + |
| 195 | + it('a flow the pack does not translate keeps its stored labels', async () => { |
| 196 | + renderInbox('zh'); |
| 197 | + const tr = await queueRow('Record exp_1'); |
| 198 | + expect(within(tr).getByText('Expense Approval')).toBeInTheDocument(); |
| 199 | + expect(within(tr).getByText('Finance Review')).toBeInTheDocument(); |
| 200 | + }); |
| 201 | + |
| 202 | + it('the process and object filters list the labels shown', async () => { |
| 203 | + renderInbox('zh'); |
| 204 | + await queueRow('Record opp_1'); |
| 205 | + const triggers = screen.getAllByRole('combobox'); |
| 206 | + const texts: string[] = []; |
| 207 | + for (const trigger of triggers) { |
| 208 | + fireEvent.click(trigger); |
| 209 | + for (const option of screen.queryAllByRole('option')) texts.push((option.textContent ?? '').trim()); |
| 210 | + fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' }); |
| 211 | + cleanupOpenListbox(); |
| 212 | + } |
| 213 | + expect(texts, `options: ${JSON.stringify(texts)}`).toEqual(expect.arrayContaining(['大额商机审批', 'Expense Approval', '商机', 'Expense'])); |
| 214 | + expect(texts).not.toContain('Large Deal Approval'); |
| 215 | + expect(texts).not.toContain('opportunity'); |
| 216 | + }); |
| 217 | + |
| 218 | + it('search matches the shown text and the machine name', async () => { |
| 219 | + renderInbox('zh'); |
| 220 | + await queueRow('Record opp_1'); |
| 221 | + const search = screen.getByRole('textbox'); |
| 222 | + fireEvent.change(search, { target: { value: '销售经理' } }); |
| 223 | + expect(screen.queryAllByText('Record opp_1').length).toBeGreaterThan(0); |
| 224 | + expect(screen.queryAllByText('Record exp_1')).toHaveLength(0); |
| 225 | + fireEvent.change(search, { target: { value: '商机' } }); |
| 226 | + expect(screen.queryAllByText('Record opp_1').length).toBeGreaterThan(0); |
| 227 | + fireEvent.change(search, { target: { value: 'opportunity_approval' } }); |
| 228 | + expect(screen.queryAllByText('Record opp_1').length).toBeGreaterThan(0); |
| 229 | + expect(screen.queryAllByText('Record exp_1')).toHaveLength(0); |
| 230 | + }); |
| 231 | + |
| 232 | + it('control: an English UI reads the stored English labels', async () => { |
| 233 | + renderInbox('en'); |
| 234 | + const tr = await queueRow('Record opp_1'); |
| 235 | + expect(within(tr).getByText('Large Deal Approval')).toBeInTheDocument(); |
| 236 | + expect(within(tr).getByText('Sales Manager Review')).toBeInTheDocument(); |
| 237 | + expect(seenLanguages.every((l) => l === 'en'), `saw: ${JSON.stringify(seenLanguages)}`).toBe(true); |
| 238 | + }); |
| 239 | +}); |
| 240 | + |
| 241 | +/** Radix Select keeps a closed listbox mounted for a tick; drop any left so the next trigger lists only its own options. */ |
| 242 | +function cleanupOpenListbox(): void { |
| 243 | + for (const el of document.querySelectorAll('[role="listbox"]')) el.remove(); |
| 244 | +} |
0 commit comments