|
| 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-ai/objectstack#27450 — an app-root deep link keeps its query. |
| 11 | + * |
| 12 | + * The URL overlays (`?palette=1`, `?recordId=`, `?form=`) live in the query. |
| 13 | + * The app root (`/apps/:appName`) redirects to the app's landing, and that |
| 14 | + * redirect built its target from the landing alone, so |
| 15 | + * `/apps/showcase_app?palette=1` settled on the landing page with no query and |
| 16 | + * the command palette never opened. The redirect now carries search and hash |
| 17 | + * across, merging with a landing that has a query of its own. |
| 18 | + * |
| 19 | + * Measured from a real `AppContent` mount (the `AppContent.nameAliasRedirect |
| 20 | + * -26183` harness); the router's location is the observation point, and the |
| 21 | + * real `CommandPaletteProvider` (which `ConsoleLayout` mounts in the shipped |
| 22 | + * shell) reads the landed URL, so "the palette is open" is the provider's own |
| 23 | + * answer. |
| 24 | + * |
| 25 | + * Direction, written before the run: on the pre-fix wiring every case that |
| 26 | + * arrives with a query is RED (the landing has none); the bare-root control |
| 27 | + * is GREEN on both sides. |
| 28 | + */ |
| 29 | + |
| 30 | +import '@testing-library/jest-dom/vitest'; |
| 31 | +import { describe, it, expect, vi, afterEach } from 'vitest'; |
| 32 | +import React, { useEffect } from 'react'; |
| 33 | +import { render, cleanup, waitFor, screen } from '@testing-library/react'; |
| 34 | +import { MemoryRouter, Routes, Route, useLocation, useNavigationType } from 'react-router-dom'; |
| 35 | +import { createI18n, I18nProvider } from '@object-ui/i18n'; |
| 36 | + |
| 37 | +// The route's recent-items tracker runs; this file does not observe it. |
| 38 | +vi.mock('../../context/RecentItemsProvider', async (importOriginal) => ({ |
| 39 | + ...(await importOriginal<Record<string, unknown>>()), |
| 40 | + useRecentItems: () => ({ recentItems: [], addRecentItem: () => {}, clearRecentItems: () => {} }), |
| 41 | +})); |
| 42 | + |
| 43 | +vi.mock('@object-ui/plugin-form', async (importOriginal) => ({ |
| 44 | + ...(await importOriginal<Record<string, unknown>>()), |
| 45 | + ModalForm: () => null, |
| 46 | +})); |
| 47 | + |
| 48 | +vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({ |
| 49 | + ...(await importOriginal<typeof import('@object-ui/plugin-designer')>()), |
| 50 | + CreateAppPage: () => <div data-testid="create-app-page" />, |
| 51 | + EditAppPage: () => <div data-testid="edit-app-page" />, |
| 52 | + DashboardDesignPage: () => <div data-testid="dashboard-design-page" />, |
| 53 | +})); |
| 54 | + |
| 55 | +// The shipped `ConsoleLayout` mounts the real `CommandPaletteProvider` around |
| 56 | +// the route slot; this stand-in keeps that provider and drops the chrome. |
| 57 | +vi.mock('../../layout/ConsoleLayout', async () => { |
| 58 | + const { CommandPaletteProvider, useCommandPalette } = await import('../../context/CommandPaletteProvider'); |
| 59 | + function PaletteProbe() { |
| 60 | + const { open } = useCommandPalette(); |
| 61 | + return open ? <div data-testid="palette-open" /> : null; |
| 62 | + } |
| 63 | + return { |
| 64 | + ConsoleLayout: ({ children }: { children?: React.ReactNode }) => ( |
| 65 | + <CommandPaletteProvider> |
| 66 | + <PaletteProbe /> |
| 67 | + <div data-testid="console-layout">{children}</div> |
| 68 | + </CommandPaletteProvider> |
| 69 | + ), |
| 70 | + }; |
| 71 | +}); |
| 72 | +vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null })); |
| 73 | +vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null })); |
| 74 | +vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null })); |
| 75 | +vi.mock('../../views/ObjectView', () => ({ ObjectView: () => <div data-testid="object-view" /> })); |
| 76 | +vi.mock('../../views/RecordDetailView', () => ({ RecordDetailView: () => null })); |
| 77 | + |
| 78 | +vi.mock('@object-ui/auth', async (importOriginal) => ({ |
| 79 | + ...(await importOriginal<Record<string, unknown>>()), |
| 80 | + useAuth: () => ({ |
| 81 | + user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' }, |
| 82 | + getAuthConfig: async () => ({ features: {} }), |
| 83 | + activeOrganization: null, |
| 84 | + }), |
| 85 | + useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }), |
| 86 | +})); |
| 87 | + |
| 88 | +const dataSourceStub = { |
| 89 | + onConnectionStateChange: () => () => {}, |
| 90 | + getConnectionState: () => 'connected', |
| 91 | +}; |
| 92 | +vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({ |
| 93 | + ...(await importOriginal<Record<string, unknown>>()), |
| 94 | + useAdapter: () => dataSourceStub, |
| 95 | +})); |
| 96 | + |
| 97 | +/** The showcase app as the catalog serves it: a name AND a package id. */ |
| 98 | +const SHOWCASE = { |
| 99 | + name: 'showcase_app', |
| 100 | + label: 'Showcase', |
| 101 | + _packageId: 'com.example.showcase', |
| 102 | + isDefault: true, |
| 103 | + navigation: [{ id: 'tasks', type: 'object', objectName: 'showcase_task', label: 'Tasks' }], |
| 104 | +}; |
| 105 | + |
| 106 | +/** The same app whose landing entry carries a query of its own (`?runAction=`). */ |
| 107 | +const SHOWCASE_LANDING_WITH_QUERY = { |
| 108 | + ...SHOWCASE, |
| 109 | + navigation: [ |
| 110 | + { id: 'tasks', type: 'object', objectName: 'showcase_task', viewName: 'all', runAction: 'import', label: 'Tasks' }, |
| 111 | + ], |
| 112 | +}; |
| 113 | + |
| 114 | +const OBJECTS = [{ name: 'showcase_task', label: 'Task', pluralLabel: 'Tasks', fields: { title: { type: 'text' } } }]; |
| 115 | + |
| 116 | +/** The catalog a test serves. One value per test: the route's effects key on its members. */ |
| 117 | +const { metadataRef } = vi.hoisted(() => ({ metadataRef: { current: null as unknown } })); |
| 118 | +function serve(apps: unknown[]): void { |
| 119 | + metadataRef.current = { |
| 120 | + apps, |
| 121 | + objects: OBJECTS, |
| 122 | + loading: false, |
| 123 | + ensureType: undefined, |
| 124 | + error: null, |
| 125 | + refresh: async () => {}, |
| 126 | + }; |
| 127 | +} |
| 128 | +vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({ |
| 129 | + ...(await importOriginal<Record<string, unknown>>()), |
| 130 | + useMetadata: () => metadataRef.current, |
| 131 | +})); |
| 132 | + |
| 133 | +const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } }; |
| 134 | +vi.mock('@object-ui/react', async (importOriginal) => ({ |
| 135 | + ...(await importOriginal<Record<string, unknown>>()), |
| 136 | + useActionRunner: () => ACTION_RUNNER, |
| 137 | + useGlobalUndo: () => {}, |
| 138 | + useMutationInvalidationBridge: () => {}, |
| 139 | +})); |
| 140 | + |
| 141 | +import { AppContent } from '../AppContent'; |
| 142 | +import { COMMAND_PALETTE_PARAM, RECORD_DRAWER_PARAM } from '../../urlParams'; |
| 143 | + |
| 144 | +/** The router's location as the last render saw it. */ |
| 145 | +const seen: { pathname?: string; search?: string; hash?: string; type?: string } = {}; |
| 146 | +function LocationProbe() { |
| 147 | + const location = useLocation(); |
| 148 | + const type = useNavigationType(); |
| 149 | + useEffect(() => { |
| 150 | + Object.assign(seen, { pathname: location.pathname, search: location.search, hash: location.hash, type }); |
| 151 | + }, [location, type]); |
| 152 | + return null; |
| 153 | +} |
| 154 | + |
| 155 | +afterEach(cleanup); |
| 156 | + |
| 157 | +/** Mount the console's app router at `entry`; the landing's object list renders. */ |
| 158 | +async function visit(entry: string): Promise<void> { |
| 159 | + const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false }); |
| 160 | + render( |
| 161 | + <I18nProvider instance={i18n}> |
| 162 | + <MemoryRouter initialEntries={[entry]}> |
| 163 | + <Routes> |
| 164 | + <Route path="/apps/:appName/*" element={<AppContent />} /> |
| 165 | + </Routes> |
| 166 | + <LocationProbe /> |
| 167 | + </MemoryRouter> |
| 168 | + </I18nProvider>, |
| 169 | + ); |
| 170 | + await screen.findByTestId('object-view', undefined, { timeout: 5000 }); |
| 171 | +} |
| 172 | + |
| 173 | +describe('the app-root redirect carries the deep link\'s query (objectstack-ai/objectstack#27450)', () => { |
| 174 | + it('THE CARD: `/apps/showcase_app?palette=1` lands on the default page with `?palette=1` kept and the palette open', async () => { |
| 175 | + serve([SHOWCASE]); |
| 176 | + await visit(`/apps/showcase_app?${COMMAND_PALETTE_PARAM}=1`); |
| 177 | + await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task')); |
| 178 | + expect(seen.search).toBe(`?${COMMAND_PALETTE_PARAM}=1`); |
| 179 | + expect(seen.type).toBe('REPLACE'); |
| 180 | + expect(await screen.findByTestId('palette-open')).toBeInTheDocument(); |
| 181 | + }); |
| 182 | + |
| 183 | + it('carries any overlay param and the hash, not the palette alone', async () => { |
| 184 | + serve([SHOWCASE]); |
| 185 | + await visit(`/apps/com.example.showcase?${RECORD_DRAWER_PARAM}=rec_123456#notes`); |
| 186 | + await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task')); |
| 187 | + expect(seen.search).toBe(`?${RECORD_DRAWER_PARAM}=rec_123456`); |
| 188 | + expect(seen.hash).toBe('#notes'); |
| 189 | + }); |
| 190 | + |
| 191 | + it('merges with a landing that has a query of its own', async () => { |
| 192 | + serve([SHOWCASE_LANDING_WITH_QUERY]); |
| 193 | + await visit(`/apps/com.example.showcase?${COMMAND_PALETTE_PARAM}=1`); |
| 194 | + await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task/view/all')); |
| 195 | + const params = new URLSearchParams(seen.search); |
| 196 | + expect(params.getAll('runAction')).toEqual(['import']); |
| 197 | + expect(params.getAll(COMMAND_PALETTE_PARAM)).toEqual(['1']); |
| 198 | + expect(await screen.findByTestId('palette-open')).toBeInTheDocument(); |
| 199 | + }); |
| 200 | + |
| 201 | + it('a key both name is the link\'s value once, never doubled', async () => { |
| 202 | + serve([SHOWCASE_LANDING_WITH_QUERY]); |
| 203 | + await visit(`/apps/com.example.showcase?runAction=export&${COMMAND_PALETTE_PARAM}=1`); |
| 204 | + await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task/view/all')); |
| 205 | + const params = new URLSearchParams(seen.search); |
| 206 | + expect(params.getAll('runAction')).toEqual(['export']); |
| 207 | + expect(params.getAll(COMMAND_PALETTE_PARAM)).toEqual(['1']); |
| 208 | + }); |
| 209 | + |
| 210 | + it('CONTROL: a bare app root lands on the bare landing, palette closed', async () => { |
| 211 | + serve([SHOWCASE]); |
| 212 | + await visit('/apps/com.example.showcase'); |
| 213 | + await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task')); |
| 214 | + expect(seen.search).toBe(''); |
| 215 | + expect(seen.hash).toBe(''); |
| 216 | + expect(screen.queryByTestId('palette-open')).toBeNull(); |
| 217 | + }); |
| 218 | +}); |
0 commit comments