|
| 1 | +/** |
| 2 | + * objectstack-ai/objectstack#28036 — a full-page record form opened on its |
| 3 | + * app-NAME address stays in the app after save or cancel. The console moves |
| 4 | + * that address to the package-id one with a `replace` navigate on load |
| 5 | + * (`AppContent`'s canonical app pathname), which gives the entry a generated |
| 6 | + * key with nothing of the console's behind it. Each cell replays that replace |
| 7 | + * on a real browser history under `BrowserRouter`. Direction, written before |
| 8 | + * the run: keyed on `location.key !== 'default'`, every alias cell RED (Back |
| 9 | + * leaves the app); the in-app cell GREEN. |
| 10 | + */ |
| 11 | + |
| 12 | +import '@testing-library/jest-dom/vitest'; |
| 13 | +import * as React from 'react'; |
| 14 | +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
| 15 | +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; |
| 16 | +import { BrowserRouter, Routes, Route, useLocation, useNavigate } from 'react-router-dom'; |
| 17 | +import { I18nProvider } from '@object-ui/i18n'; |
| 18 | +import { RecordFormPage } from './RecordFormPage'; |
| 19 | + |
| 20 | +const h = React.createElement; |
| 21 | + |
| 22 | +const { toastSuccess } = vi.hoisted(() => ({ toastSuccess: vi.fn() })); |
| 23 | + |
| 24 | +vi.mock('sonner', () => ({ |
| 25 | + toast: Object.assign(vi.fn(), { success: toastSuccess, error: vi.fn(), info: vi.fn(), dismiss: vi.fn() }), |
| 26 | +})); |
| 27 | + |
| 28 | +vi.mock('@object-ui/auth', async (importOriginal) => ({ |
| 29 | + ...(await importOriginal<Record<string, unknown>>()), |
| 30 | + useAuth: () => ({ |
| 31 | + user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'admin' }, |
| 32 | + getAuthConfig: async () => ({ features: {} }), |
| 33 | + activeOrganization: null, |
| 34 | + }), |
| 35 | +})); |
| 36 | + |
| 37 | +// Stand-in form: one button per exit, as `ObjectForm` calls `onSuccess(result)` / `onCancel()`. |
| 38 | +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ |
| 39 | + ...(await importOriginal<Record<string, unknown>>()), |
| 40 | + ObjectForm: ({ schema }: { schema: { onSuccess?: (s?: unknown) => void; onCancel?: () => void } }) => |
| 41 | + h( |
| 42 | + 'div', |
| 43 | + null, |
| 44 | + h('button', { type: 'button', 'data-testid': 'fire-success', onClick: () => schema.onSuccess?.({ id: 't1', subject: 'Quarterly review' }) }, 'ok'), |
| 45 | + h('button', { type: 'button', 'data-testid': 'fire-cancel', onClick: () => schema.onCancel?.() }, 'cancel'), |
| 46 | + ), |
| 47 | +})); |
| 48 | + |
| 49 | +const TASK = { name: 'showcase_task', label: 'Task', nameField: 'subject', fields: { subject: { type: 'text' } } }; |
| 50 | +const METADATA = { objects: [TASK], loading: false }; |
| 51 | +vi.mock('../providers/MetadataProvider', () => ({ useMetadata: () => METADATA })); |
| 52 | +vi.mock('../providers/AdapterProvider', () => ({ useAdapter: () => null })); |
| 53 | + |
| 54 | +const Where = () => h('div', { 'data-testid': 'where' }, useLocation().pathname); |
| 55 | +/** |
| 56 | + * The console's two navigations: a react-router push to `to`, and the |
| 57 | + * replace `AppContent` makes on an app-name address (to its package id). |
| 58 | + */ |
| 59 | +const Nav = ({ to, canonical }: { to: string; canonical: string }) => { |
| 60 | + const navigate = useNavigate(); |
| 61 | + return h( |
| 62 | + React.Fragment, |
| 63 | + null, |
| 64 | + h('button', { type: 'button', 'data-testid': 'push', onClick: () => { void navigate(to); } }, 'push'), |
| 65 | + h('button', { type: 'button', 'data-testid': 'canonical', onClick: () => { void navigate(canonical, { replace: true }); } }, 'canonical'), |
| 66 | + ); |
| 67 | +}; |
| 68 | + |
| 69 | +const ALIAS = '/apps/crm_app/showcase_task'; |
| 70 | +const APP = '/apps/com.example.crm/showcase_task'; |
| 71 | +const LIST = APP; |
| 72 | +const DETAIL = `${APP}/record/t1`; |
| 73 | +const VIEW = `${APP}/view/open`; |
| 74 | + |
| 75 | +beforeEach(() => { |
| 76 | + toastSuccess.mockClear(); |
| 77 | +}); |
| 78 | +afterEach(() => { cleanup(); vi.restoreAllMocks(); }); |
| 79 | + |
| 80 | +const formOf = (base: string, mode: 'create' | 'edit') => (mode === 'create' ? `${base}/new` : `${base}/record/t1/edit`); |
| 81 | + |
| 82 | +/** |
| 83 | + * Open the form on its app-name address — loaded on it, or pushed from |
| 84 | + * `before` — let the console replace it with the package-id address, fire |
| 85 | + * `exit`, return where it lands. |
| 86 | + */ |
| 87 | +async function exitFromAlias(mode: 'create' | 'edit', exit: 'success' | 'cancel', before?: string): Promise<string> { |
| 88 | + window.history.replaceState(null, '', before ?? formOf(ALIAS, mode)); |
| 89 | + render( |
| 90 | + h(I18nProvider, { |
| 91 | + config: { defaultLanguage: 'en', detectBrowserLanguage: false }, |
| 92 | + children: h( |
| 93 | + BrowserRouter, |
| 94 | + null, |
| 95 | + h(Nav, { to: formOf(ALIAS, mode), canonical: formOf(APP, mode) }), |
| 96 | + h( |
| 97 | + Routes, |
| 98 | + null, |
| 99 | + h(Route, { path: '/apps/:appName/:objectName/new', element: h(RecordFormPage, { mode: 'create' }) }), |
| 100 | + h(Route, { path: '/apps/:appName/:objectName/record/:recordId/edit', element: h(RecordFormPage, { mode: 'edit' }) }), |
| 101 | + h(Route, { path: '*', element: h(Where) }), |
| 102 | + ), |
| 103 | + ), |
| 104 | + }), |
| 105 | + ); |
| 106 | + if (before) fireEvent.click(screen.getByTestId('push')); |
| 107 | + fireEvent.click(screen.getByTestId('canonical')); |
| 108 | + // The replace minted a key, so the old in-app test read this entry as having history. |
| 109 | + const { key } = window.history.state as { key?: string }; |
| 110 | + expect(key).toBeTruthy(); |
| 111 | + expect(key).not.toBe('default'); |
| 112 | + fireEvent.click(screen.getByTestId(exit === 'success' ? 'fire-success' : 'fire-cancel')); |
| 113 | + return (await screen.findByTestId('where')).textContent ?? ''; |
| 114 | +} |
| 115 | + |
| 116 | +describe('RecordFormPage — the app-name address stays in the app (objectstack-ai/objectstack#28036)', () => { |
| 117 | + it('edit, app-name URL: saves, toasts once, lands on the record detail', async () => { |
| 118 | + expect(await exitFromAlias('edit', 'success')).toBe(DETAIL); |
| 119 | + expect(toastSuccess).toHaveBeenCalledTimes(1); |
| 120 | + expect(toastSuccess).toHaveBeenCalledWith('Task "Quarterly review" updated'); |
| 121 | + }); |
| 122 | + |
| 123 | + it('create, app-name URL: saves, toasts once, lands on the object list', async () => { |
| 124 | + expect(await exitFromAlias('create', 'success')).toBe(LIST); |
| 125 | + expect(toastSuccess).toHaveBeenCalledTimes(1); |
| 126 | + expect(toastSuccess).toHaveBeenCalledWith('Task "Quarterly review" created'); |
| 127 | + }); |
| 128 | + |
| 129 | + it('edit, app-name URL: cancel lands on the record detail, no toast', async () => { |
| 130 | + expect(await exitFromAlias('edit', 'cancel')).toBe(DETAIL); |
| 131 | + expect(toastSuccess).not.toHaveBeenCalled(); |
| 132 | + }); |
| 133 | + |
| 134 | + it('edit, pushed from a page of the app: save goes back to that page', async () => { |
| 135 | + expect(await exitFromAlias('edit', 'success', VIEW)).toBe(VIEW); |
| 136 | + expect(toastSuccess).toHaveBeenCalledTimes(1); |
| 137 | + }); |
| 138 | +}); |
0 commit comments