|
| 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 | + * objectstack#29935 — after a successful declared `operation: 'update'` the |
| 11 | + * record page shows what the route wrote, with no `refreshAfter: true`. |
| 12 | + * |
| 13 | + * The spec materializes `refreshAfter: false` on an action authored without |
| 14 | + * it, and that parsed shape is what the Console is served. The runner hands an |
| 15 | + * update action to the page's registered `script` dispatch, whose refresh read |
| 16 | + * `refreshAfter !== false`: the route answered 200, the toast said "Ticket |
| 17 | + * resolved.", and the page kept the old status and the Resolve button whose |
| 18 | + * `visible` predicate was now false. A declared update's success is now a |
| 19 | + * record change. |
| 20 | + * |
| 21 | + * Where it reaches: the record-scoped `notifyDataChanged` the page sends |
| 22 | + * re-reads this record, and every list reader of the object |
| 23 | + * (`useDataInvalidation(objectName)` with no record id — the hook `ListView` |
| 24 | + * and `ObjectView` refetch on) refetches with its own filter, so the record |
| 25 | + * leaves a list it no longer matches. The drawer is this same view mounted |
| 26 | + * `embedded` beside its list. |
| 27 | + * |
| 28 | + * CONTROL: a non-update `script` action served with the same |
| 29 | + * `refreshAfter: false` still sends no re-read. |
| 30 | + * |
| 31 | + * Harness: the `RecordDetailView.apiNewTabUrl-26747` mount — the real record |
| 32 | + * page, header and runner, and the real console server-action handler, with |
| 33 | + * only the authenticated fetch answered here. |
| 34 | + */ |
| 35 | + |
| 36 | +import * as React from 'react'; |
| 37 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 38 | +import { render, screen, waitFor, fireEvent, cleanup, act } from '@testing-library/react'; |
| 39 | +import '@testing-library/jest-dom'; |
| 40 | +import { MemoryRouter, Routes, Route } from 'react-router-dom'; |
| 41 | + |
| 42 | +const authFetchSpy = vi.fn(); |
| 43 | +vi.mock('@object-ui/auth', async (importOriginal) => ({ |
| 44 | + ...(await importOriginal<Record<string, unknown>>()), |
| 45 | + useAuth: () => ({ user: null, activeOrganization: null }), |
| 46 | + createAuthenticatedFetch: () => authFetchSpy, |
| 47 | +})); |
| 48 | + |
| 49 | +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ |
| 50 | + ...(await importOriginal<Record<string, unknown>>()), |
| 51 | + useRecordPresence: () => [], |
| 52 | + PresenceAvatars: () => null, |
| 53 | +})); |
| 54 | + |
| 55 | +vi.mock('sonner', () => ({ |
| 56 | + toast: Object.assign(vi.fn(), { |
| 57 | + success: vi.fn(), |
| 58 | + error: vi.fn(), |
| 59 | + info: vi.fn(), |
| 60 | + warning: vi.fn(), |
| 61 | + loading: vi.fn(), |
| 62 | + dismiss: vi.fn(), |
| 63 | + }), |
| 64 | +})); |
| 65 | + |
| 66 | +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); |
| 67 | +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); |
| 68 | +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); |
| 69 | +vi.mock('./MetadataInspector', () => ({ |
| 70 | + MetadataPanel: () => null, |
| 71 | + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), |
| 72 | +})); |
| 73 | + |
| 74 | +import { toast } from 'sonner'; |
| 75 | +import { I18nProvider } from '@object-ui/i18n'; |
| 76 | +import { MetadataCtx, useDataInvalidation } from '@object-ui/react'; |
| 77 | +import { RecordDetailView } from './RecordDetailView'; |
| 78 | +import { FavoritesProvider } from '../context/FavoritesProvider'; |
| 79 | + |
| 80 | +const OBJECT_NAME = 'support_desk_ticket'; |
| 81 | +const RECORD_ID = 'tkt_1'; |
| 82 | +const OTHER_ID = 'tkt_2'; |
| 83 | +const RECORD_PATH = `/apps/support_desk/${OBJECT_NAME}/record/${RECORD_ID}`; |
| 84 | +const ACTION_URL = `/api/v1/actions/${OBJECT_NAME}/resolve_ticket`; |
| 85 | + |
| 86 | +const FIELDS = { |
| 87 | + id: { type: 'text', label: 'Id' }, |
| 88 | + subject: { type: 'text', label: 'Subject' }, |
| 89 | + status: { |
| 90 | + type: 'select', |
| 91 | + label: 'Status', |
| 92 | + options: [ |
| 93 | + { value: 'open', label: 'Open' }, |
| 94 | + { value: 'resolved', label: 'Resolved' }, |
| 95 | + { value: 'closed', label: 'Closed' }, |
| 96 | + ], |
| 97 | + }, |
| 98 | +}; |
| 99 | + |
| 100 | +/** |
| 101 | + * The tutorial's action as the Console is served it: parsed, so |
| 102 | + * `type: 'script'` and `refreshAfter: false` are materialized although the |
| 103 | + * author wrote neither. |
| 104 | + */ |
| 105 | +const resolveTicket = { |
| 106 | + name: 'resolve_ticket', |
| 107 | + label: 'Resolve', |
| 108 | + objectName: OBJECT_NAME, |
| 109 | + type: 'script', |
| 110 | + operation: 'update', |
| 111 | + patch: { status: 'resolved' }, |
| 112 | + locations: ['record_header'], |
| 113 | + visible: 'record.status != "resolved" && record.status != "closed"', |
| 114 | + successMessage: 'Ticket resolved.', |
| 115 | + refreshAfter: false, |
| 116 | +}; |
| 117 | + |
| 118 | +/** CONTROL: a server-side script action that writes nothing the page reads. */ |
| 119 | +const notifyOwner = { |
| 120 | + name: 'notify_owner', |
| 121 | + label: 'Notify owner', |
| 122 | + objectName: OBJECT_NAME, |
| 123 | + type: 'script', |
| 124 | + locations: ['record_header'], |
| 125 | + successMessage: 'Owner notified.', |
| 126 | + refreshAfter: false, |
| 127 | +}; |
| 128 | + |
| 129 | +/** The stored rows; the action route writes here, the reads read here. */ |
| 130 | +let rows: Array<Record<string, unknown>>; |
| 131 | + |
| 132 | +function makeDataSource() { |
| 133 | + return { |
| 134 | + find: vi.fn(async (_object: string, query?: { $filter?: Record<string, unknown> }) => { |
| 135 | + const status = query?.$filter?.status; |
| 136 | + const data = rows.filter((r) => status === undefined || r.status === status).map((r) => ({ ...r })); |
| 137 | + return { data, total: data.length }; |
| 138 | + }), |
| 139 | + findOne: vi.fn(async (_object: string, id: string) => ({ ...rows.find((r) => r.id === id)! })), |
| 140 | + create: vi.fn(async () => ({})), |
| 141 | + update: vi.fn(async () => ({})), |
| 142 | + delete: vi.fn(async () => ({})), |
| 143 | + } as any; |
| 144 | +} |
| 145 | + |
| 146 | +/** |
| 147 | + * A list reader of the object — the `useDataInvalidation(objectName)` a list |
| 148 | + * view refetches on — drawing the Open list. |
| 149 | + */ |
| 150 | +function OpenTicketsProbe({ dataSource }: { dataSource: any }) { |
| 151 | + const nonce = useDataInvalidation(OBJECT_NAME); |
| 152 | + const [ids, setIds] = React.useState<string[] | null>(null); |
| 153 | + React.useEffect(() => { |
| 154 | + let live = true; |
| 155 | + void dataSource.find(OBJECT_NAME, { $filter: { status: 'open' } }).then((res: { data: Array<{ id: string }> }) => { |
| 156 | + if (live) setIds(res.data.map((r) => r.id)); |
| 157 | + }); |
| 158 | + return () => { live = false; }; |
| 159 | + }, [dataSource, nonce]); |
| 160 | + return <ul data-testid="open-list">{(ids ?? []).map((id) => <li key={id}>{id}</li>)}</ul>; |
| 161 | +} |
| 162 | + |
| 163 | +async function mount(actions: unknown[], mode: 'page' | 'drawer') { |
| 164 | + const objects = [ |
| 165 | + { name: OBJECT_NAME, label: 'Ticket', managedBy: 'platform', fields: FIELDS, actions }, |
| 166 | + ]; |
| 167 | + const metadata = { |
| 168 | + objects, |
| 169 | + pages: [], |
| 170 | + loading: false, |
| 171 | + error: null, |
| 172 | + refresh: async () => {}, |
| 173 | + invalidate: () => {}, |
| 174 | + ensureType: async () => [], |
| 175 | + getItem: async () => null, |
| 176 | + getItemsByType: () => [], |
| 177 | + } as any; |
| 178 | + const dataSource = makeDataSource(); |
| 179 | + const view = mode === 'page' |
| 180 | + ? ( |
| 181 | + <Routes> |
| 182 | + <Route |
| 183 | + path="/apps/:appName/:objectName/record/:recordId" |
| 184 | + element={<RecordDetailView dataSource={dataSource} objects={objects} onEdit={() => {}} />} |
| 185 | + /> |
| 186 | + </Routes> |
| 187 | + ) |
| 188 | + : ( |
| 189 | + <> |
| 190 | + <OpenTicketsProbe dataSource={dataSource} /> |
| 191 | + <RecordDetailView |
| 192 | + dataSource={dataSource} |
| 193 | + objects={objects} |
| 194 | + onEdit={() => {}} |
| 195 | + objectNameOverride={OBJECT_NAME} |
| 196 | + recordIdOverride={RECORD_ID} |
| 197 | + embedded |
| 198 | + /> |
| 199 | + </> |
| 200 | + ); |
| 201 | + render( |
| 202 | + <I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}> |
| 203 | + <MetadataCtx.Provider value={metadata}> |
| 204 | + <FavoritesProvider> |
| 205 | + <MemoryRouter initialEntries={[RECORD_PATH]}>{view}</MemoryRouter> |
| 206 | + </FavoritesProvider> |
| 207 | + </MetadataCtx.Provider> |
| 208 | + </I18nProvider>, |
| 209 | + ); |
| 210 | + await waitFor(() => expect(dataSource.findOne).toHaveBeenCalled()); |
| 211 | + await waitFor(() => expect(document.querySelector('[role="toolbar"]')).toBeTruthy()); |
| 212 | + return dataSource; |
| 213 | +} |
| 214 | + |
| 215 | +/** Every inline CTA the header currently renders, by visible label. */ |
| 216 | +function headerCtaLabels(): string[] { |
| 217 | + const bar = document.querySelector('[role="toolbar"]'); |
| 218 | + if (!bar) return []; |
| 219 | + return Array.from(bar.querySelectorAll('button')) |
| 220 | + .map((b) => (b.textContent ?? '').trim()) |
| 221 | + .filter(Boolean); |
| 222 | +} |
| 223 | + |
| 224 | +/** |
| 225 | + * The header path bar's current stage, as each rendering of it (wide and |
| 226 | + * narrow) names it to assistive tech. |
| 227 | + */ |
| 228 | +function currentStages(): string[] { |
| 229 | + return Array.from(document.querySelectorAll('[role="listitem"][aria-current="step"]')) |
| 230 | + .map((el) => el.getAttribute('aria-label') ?? ''); |
| 231 | +} |
| 232 | + |
| 233 | +function actionPosts(): string[] { |
| 234 | + return authFetchSpy.mock.calls |
| 235 | + .map((c) => String(c[0])) |
| 236 | + .filter((u) => u.includes('/api/v1/actions/')); |
| 237 | +} |
| 238 | + |
| 239 | +async function click(label: string) { |
| 240 | + await act(async () => { |
| 241 | + fireEvent.click(screen.getByRole('button', { name: label })); |
| 242 | + }); |
| 243 | +} |
| 244 | + |
| 245 | +beforeEach(() => { |
| 246 | + cleanup(); |
| 247 | + localStorage.clear(); |
| 248 | + rows = [ |
| 249 | + { id: RECORD_ID, subject: 'Printer jam', status: 'open' }, |
| 250 | + { id: OTHER_ID, subject: 'VPN down', status: 'open' }, |
| 251 | + ]; |
| 252 | + authFetchSpy.mockReset(); |
| 253 | + authFetchSpy.mockImplementation(async (url: string, init?: RequestInit) => { |
| 254 | + // The action route: write the declared patch AS the route does, answer |
| 255 | + // the declarative update's envelope with the written record. |
| 256 | + if (String(url).endsWith(ACTION_URL) && init?.method === 'POST') { |
| 257 | + const row = rows.find((r) => r.id === RECORD_ID)!; |
| 258 | + row.status = 'resolved'; |
| 259 | + return new Response( |
| 260 | + JSON.stringify({ success: true, data: { ok: true, action: 'resolve_ticket', objectName: OBJECT_NAME, recordId: RECORD_ID, result: { ...row } } }), |
| 261 | + { status: 200, headers: { 'content-type': 'application/json' } }, |
| 262 | + ); |
| 263 | + } |
| 264 | + return new Response(JSON.stringify({ success: true, data: {} }), { |
| 265 | + status: 200, |
| 266 | + headers: { 'content-type': 'application/json' }, |
| 267 | + }); |
| 268 | + }); |
| 269 | + vi.stubGlobal( |
| 270 | + 'fetch', |
| 271 | + vi.fn(async () => |
| 272 | + new Response(JSON.stringify({ data: [] }), { |
| 273 | + status: 200, |
| 274 | + headers: { 'content-type': 'application/json' }, |
| 275 | + }), |
| 276 | + ), |
| 277 | + ); |
| 278 | +}); |
| 279 | + |
| 280 | +afterEach(() => { |
| 281 | + vi.unstubAllGlobals(); |
| 282 | + vi.restoreAllMocks(); |
| 283 | +}); |
| 284 | + |
| 285 | +describe('objectstack#29935 — a declared update action reflects its write on the record page', () => { |
| 286 | + it('full page: after the 200 the page re-reads, the path bar reads Resolved and the Resolve button is gone', async () => { |
| 287 | + const dataSource = await mount([resolveTicket], 'page'); |
| 288 | + expect(headerCtaLabels()).toContain('Resolve'); |
| 289 | + await waitFor(() => expect(currentStages()).toContain('Open, current stage')); |
| 290 | + const readsBefore = dataSource.findOne.mock.calls.length; |
| 291 | + |
| 292 | + await click('Resolve'); |
| 293 | + |
| 294 | + expect(actionPosts()).toEqual([expect.stringContaining(ACTION_URL)]); |
| 295 | + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Ticket resolved.', expect.anything())); |
| 296 | + await waitFor(() => expect(dataSource.findOne.mock.calls.length).toBeGreaterThan(readsBefore)); |
| 297 | + await waitFor(() => expect(headerCtaLabels()).not.toContain('Resolve')); |
| 298 | + await waitFor(() => expect(currentStages()).toContain('Resolved, current stage')); |
| 299 | + expect(currentStages()).not.toContain('Open, current stage'); |
| 300 | + }); |
| 301 | + |
| 302 | + it('drawer: the same click re-reads the record and the Open list it came from drops it', async () => { |
| 303 | + const dataSource = await mount([resolveTicket], 'drawer'); |
| 304 | + const openList = () => Array.from(screen.getByTestId('open-list').querySelectorAll('li')).map((li) => li.textContent); |
| 305 | + await waitFor(() => expect(openList()).toEqual([RECORD_ID, OTHER_ID])); |
| 306 | + await waitFor(() => expect(currentStages()).toContain('Open, current stage')); |
| 307 | + const readsBefore = dataSource.findOne.mock.calls.length; |
| 308 | + |
| 309 | + await click('Resolve'); |
| 310 | + |
| 311 | + await waitFor(() => expect(dataSource.findOne.mock.calls.length).toBeGreaterThan(readsBefore)); |
| 312 | + await waitFor(() => expect(headerCtaLabels()).not.toContain('Resolve')); |
| 313 | + await waitFor(() => expect(currentStages()).toContain('Resolved, current stage')); |
| 314 | + await waitFor(() => expect(openList()).toEqual([OTHER_ID])); |
| 315 | + }); |
| 316 | + |
| 317 | + it('CONTROL: a non-update script action served with `refreshAfter: false` sends no re-read', async () => { |
| 318 | + const dataSource = await mount([notifyOwner], 'page'); |
| 319 | + const readsBefore = dataSource.findOne.mock.calls.length; |
| 320 | + |
| 321 | + await click('Notify owner'); |
| 322 | + |
| 323 | + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Owner notified.', expect.anything())); |
| 324 | + expect(actionPosts()).toEqual([expect.stringContaining(`/api/v1/actions/${OBJECT_NAME}/notify_owner`)]); |
| 325 | + // Let any refetch the click could have scheduled land before counting. |
| 326 | + await act(async () => { await new Promise((r) => setTimeout(r, 50)); }); |
| 327 | + expect(dataSource.findOne.mock.calls.length).toBe(readsBefore); |
| 328 | + }); |
| 329 | +}); |
0 commit comments