Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/27450-app-root-deep-link-query.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/app-shell': patch
'@object-ui/console': patch
---

fix(console): an app-root or console-root deep link keeps its query and hash when it redirects to the landing page

Clause-②: no
Metadata-protocol: none

Opening `/apps/APP?palette=1` on an app whose root redirects to its first navigation entry landed on that page with no query string, so the command palette never opened; the same held for every URL overlay param (`?recordId=`, `?form=`) and for the hash, and for `/?palette=1` at the console root. Both redirects now carry search and hash across. When the landing entry has a query of its own (a `page` entry's `params`, an object entry's `filters` or `runAction`), the two merge: a key the link names replaces the landing's value for that key instead of doubling it.
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,12 @@ const { RootLandingRedirect } = await import('./RootLandingRedirect');
/** Reports where the router ended up, so assertions read the real location. */
function LocationProbe() {
const location = useLocation();
return <div data-testid="location">{location.pathname + location.search}</div>;
return (
<>
<div data-testid="location">{location.pathname + location.search}</div>
<div data-testid="location-hash">{location.hash}</div>
</>
);
}

/**
Expand Down Expand Up @@ -353,3 +358,35 @@ describe('redirect capture — a real deep link is honored, a bare entry is not
expect(location()).toBe('/login?redirect=%2Fapps%2Fcrm_app%2Frecord%2F1%3Ftab%3Dhistory');
});
});

// ── objectstack-ai/objectstack#27450 — a deep link's query survives `/` ────
describe('the landing carries the query and hash `/` arrived with', () => {
it('lands on the default app with `?palette=1` and the hash kept', () => {
auth.value.isAuthenticated = true;
metadata.value.apps = [OTHER_APP, DEFAULT_APP];

renderAt('/?palette=1#top');

expect(location()).toBe('/apps/crm_app?palette=1');
expect(screen.getByTestId('location-hash').textContent).toBe('#top');
});

it('lands on the launcher with the query kept too', () => {
auth.value.isAuthenticated = true;
metadata.value.apps = [OTHER_APP, { name: 'ops_app' }];

renderAt('/?palette=1');

expect(location()).toBe('/home?palette=1');
});

it('CONTROL: a bare `/` still lands on the bare path', () => {
auth.value.isAuthenticated = true;
metadata.value.apps = [DEFAULT_APP];

renderAt('/');

expect(location()).toBe('/apps/crm_app');
expect(screen.getByTestId('location-hash').textContent).toBe('');
});
});
10 changes: 9 additions & 1 deletion ui/apps/console/src/components/RootLandingRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
*/

import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import {
useMetadata,
LoadingFallback,
Expand Down Expand Up @@ -181,6 +182,7 @@ const APP_LIST_RETRY_DELAY_MS = 1500;

export function RootLandingRedirect() {
const { apps, loading, getTypeStatus, refresh } = useMetadata();
const location = useLocation();

// "Not yet" (`loading`) and "asked and failed" (`status === 'error'`) are
// both "no answer" — they differ only in whether waiting will help.
Expand Down Expand Up @@ -210,5 +212,11 @@ export function RootLandingRedirect() {
// while the app list loads, and handing off to a null-rendering redirect left
// `#root` empty for 147 ms while `/home` rendered at transition priority. The
// splash is unchanged across the handoff, so the boot reads as one screen.
return <RedirectWithSplash to={resolveLandingPath(apps as LandingApp[] | undefined)} replace />;
//
// Search and hash carry over (objectstack-ai/objectstack#27450): the URL
// overlays (`?palette=1`, `?recordId=`) live in the query, and the landing is
// a bare `/apps/<segment>` or `/home`, so there is nothing to merge with. The
// app root it lands on carries them on to the app's own landing in turn.
const landing = resolveLandingPath(apps as LandingApp[] | undefined);
return <RedirectWithSplash to={`${landing}${location.search}${location.hash}`} replace />;
}
33 changes: 32 additions & 1 deletion ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1692,6 +1692,34 @@ function resolveLandingRoute(navigation: NavigationItem[], ctx?: NavTemplateCont
return findFirstRoute(navigation, ctx);
}

/**
* objectstack-ai/objectstack#27450 — the landing target with the search and
* hash the app-root URL arrived with carried onto it. The URL overlays
* (`?palette=1`, `?recordId=`, `?form=`, `urlParams.ts`) live in the query, so
* a redirect that rebuilt the URL from the landing alone dropped the overlay a
* deep link asked for before anything read it.
*
* A landing that has a query of its own (a `page` entry's `params`, an
* `object` entry's `filters`) keeps it, and the two MERGE: a key the arriving
* URL names replaces the landing's values for that key, never doubles them —
* the link is the more specific ask. With nothing arriving the target is
* returned untouched; with no query on the landing the arriving search is
* appended as written.
*/
function carryQueryOntoLanding(target: string, search: string, hash: string): string {
const hashAt = target.indexOf('#');
const beforeHash = hashAt >= 0 ? target.slice(0, hashAt) : target;
const carriedHash = hash || (hashAt >= 0 ? target.slice(hashAt) : '');
if (!search || search === '?') return `${beforeHash}${carriedHash}`;
const queryAt = beforeHash.indexOf('?');
if (queryAt < 0) return `${beforeHash}${search}${carriedHash}`;
const params = new URLSearchParams(beforeHash.slice(queryAt + 1));
const arriving = new URLSearchParams(search);
for (const key of new Set(arriving.keys())) params.delete(key);
arriving.forEach((value, key) => params.append(key, value));
return `${beforeHash.slice(0, queryAt)}?${params.toString()}${carriedHash}`;
}

/**
* The app root (`/apps/:appName`): navigates to the app's landing, or says the
* member has nothing here.
Expand Down Expand Up @@ -1731,6 +1759,7 @@ function AppRootLanding({
setupPath: string | null;
}) {
const gates = useNavMemberGates();
const location = useLocation();
// objectstack#25338 — an app that names objects, none of which this member
// may read, has nothing for them: its dashboards and pages would answer a
// grid of "You don't have access" tiles. Say so once, before any landing is
Expand Down Expand Up @@ -1763,7 +1792,9 @@ function AppRootLanding({
</div>
);
}
if (landing) return <Navigate to={landing} replace />;
// objectstack-ai/objectstack#27450 — search and hash carry over, as on the
// other URL-rewrite redirects in this file.
if (landing) return <Navigate to={carryQueryOntoLanding(landing, location.search, location.hash)} replace />;
// objectui#12079 — an app that shows this member NO navigation (every entry
// gated away from them, or none served at all) is an app they have no access
// in yet. Say so, and offer Setup to a caller who can grant access: the
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectstack-ai/objectstack#27450 — an app-root deep link keeps its query.
*
* The URL overlays (`?palette=1`, `?recordId=`, `?form=`) live in the query.
* The app root (`/apps/:appName`) redirects to the app's landing, and that
* redirect built its target from the landing alone, so
* `/apps/showcase_app?palette=1` settled on the landing page with no query and
* the command palette never opened. The redirect now carries search and hash
* across, merging with a landing that has a query of its own.
*
* Measured from a real `AppContent` mount (the `AppContent.nameAliasRedirect
* -26183` harness); the router's location is the observation point, and the
* real `CommandPaletteProvider` (which `ConsoleLayout` mounts in the shipped
* shell) reads the landed URL, so "the palette is open" is the provider's own
* answer.
*
* Direction, written before the run: on the pre-fix wiring every case that
* arrives with a query is RED (the landing has none); the bare-root control
* is GREEN on both sides.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, afterEach } from 'vitest';
import React, { useEffect } from 'react';
import { render, cleanup, waitFor, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route, useLocation, useNavigationType } from 'react-router-dom';
import { createI18n, I18nProvider } from '@object-ui/i18n';

// The route's recent-items tracker runs; this file does not observe it.
vi.mock('../../context/RecentItemsProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useRecentItems: () => ({ recentItems: [], addRecentItem: () => {}, clearRecentItems: () => {} }),
}));

vi.mock('@object-ui/plugin-form', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
ModalForm: () => null,
}));

vi.mock('@object-ui/plugin-designer', async (importOriginal) => ({
...(await importOriginal<typeof import('@object-ui/plugin-designer')>()),
CreateAppPage: () => <div data-testid="create-app-page" />,
EditAppPage: () => <div data-testid="edit-app-page" />,
DashboardDesignPage: () => <div data-testid="dashboard-design-page" />,
}));

// The shipped `ConsoleLayout` mounts the real `CommandPaletteProvider` around
// the route slot; this stand-in keeps that provider and drops the chrome.
vi.mock('../../layout/ConsoleLayout', async () => {
const { CommandPaletteProvider, useCommandPalette } = await import('../../context/CommandPaletteProvider');
function PaletteProbe() {
const { open } = useCommandPalette();
return open ? <div data-testid="palette-open" /> : null;
}
return {
ConsoleLayout: ({ children }: { children?: React.ReactNode }) => (
<CommandPaletteProvider>
<PaletteProbe />
<div data-testid="console-layout">{children}</div>
</CommandPaletteProvider>
),
};
});
vi.mock('../../chrome/CommandPalette', () => ({ CommandPalette: () => null }));
vi.mock('../../chrome/KeyboardShortcutsDialog', () => ({ KeyboardShortcutsDialog: () => null }));
vi.mock('../../chrome/OnboardingWalkthrough', () => ({ OnboardingWalkthrough: () => null }));
vi.mock('../../views/ObjectView', () => ({ ObjectView: () => <div data-testid="object-view" /> }));
vi.mock('../../views/RecordDetailView', () => ({ RecordDetailView: () => null }));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({
user: { id: 'u1', name: 'Ada', email: 'ada@example.com', role: 'user' },
getAuthConfig: async () => ({ features: {} }),
activeOrganization: null,
}),
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
}));

const dataSourceStub = {
onConnectionStateChange: () => () => {},
getConnectionState: () => 'connected',
};
vi.mock('../../providers/AdapterProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAdapter: () => dataSourceStub,
}));

/** The showcase app as the catalog serves it: a name AND a package id. */
const SHOWCASE = {
name: 'showcase_app',
label: 'Showcase',
_packageId: 'com.example.showcase',
isDefault: true,
navigation: [{ id: 'tasks', type: 'object', objectName: 'showcase_task', label: 'Tasks' }],
};

/** The same app whose landing entry carries a query of its own (`?runAction=`). */
const SHOWCASE_LANDING_WITH_QUERY = {
...SHOWCASE,
navigation: [
{ id: 'tasks', type: 'object', objectName: 'showcase_task', viewName: 'all', runAction: 'import', label: 'Tasks' },
],
};

const OBJECTS = [{ name: 'showcase_task', label: 'Task', pluralLabel: 'Tasks', fields: { title: { type: 'text' } } }];

/** The catalog a test serves. One value per test: the route's effects key on its members. */
const { metadataRef } = vi.hoisted(() => ({ metadataRef: { current: null as unknown } }));
function serve(apps: unknown[]): void {
metadataRef.current = {
apps,
objects: OBJECTS,
loading: false,
ensureType: undefined,
error: null,
refresh: async () => {},
};
}
vi.mock('../../providers/MetadataProvider', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useMetadata: () => metadataRef.current,
}));

const ACTION_RUNNER = { execute: () => {}, runner: { registerHandler: () => {}, getContext: () => ({}) } };
vi.mock('@object-ui/react', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useActionRunner: () => ACTION_RUNNER,
useGlobalUndo: () => {},
useMutationInvalidationBridge: () => {},
}));

import { AppContent } from '../AppContent';
import { COMMAND_PALETTE_PARAM, RECORD_DRAWER_PARAM } from '../../urlParams';

/** The router's location as the last render saw it. */
const seen: { pathname?: string; search?: string; hash?: string; type?: string } = {};
function LocationProbe() {
const location = useLocation();
const type = useNavigationType();
useEffect(() => {
Object.assign(seen, { pathname: location.pathname, search: location.search, hash: location.hash, type });
}, [location, type]);
return null;
}

afterEach(cleanup);

/** Mount the console's app router at `entry`; the landing's object list renders. */
async function visit(entry: string): Promise<void> {
const i18n = createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
render(
<I18nProvider instance={i18n}>
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/apps/:appName/*" element={<AppContent />} />
</Routes>
<LocationProbe />
</MemoryRouter>
</I18nProvider>,
);
await screen.findByTestId('object-view', undefined, { timeout: 5000 });
}

describe('the app-root redirect carries the deep link\'s query (objectstack-ai/objectstack#27450)', () => {
it('THE CARD: `/apps/showcase_app?palette=1` lands on the default page with `?palette=1` kept and the palette open', async () => {
serve([SHOWCASE]);
await visit(`/apps/showcase_app?${COMMAND_PALETTE_PARAM}=1`);
await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task'));
expect(seen.search).toBe(`?${COMMAND_PALETTE_PARAM}=1`);
expect(seen.type).toBe('REPLACE');
expect(await screen.findByTestId('palette-open')).toBeInTheDocument();
});

it('carries any overlay param and the hash, not the palette alone', async () => {
serve([SHOWCASE]);
await visit(`/apps/com.example.showcase?${RECORD_DRAWER_PARAM}=rec_123456#notes`);
await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task'));
expect(seen.search).toBe(`?${RECORD_DRAWER_PARAM}=rec_123456`);
expect(seen.hash).toBe('#notes');
});

it('merges with a landing that has a query of its own', async () => {
serve([SHOWCASE_LANDING_WITH_QUERY]);
await visit(`/apps/com.example.showcase?${COMMAND_PALETTE_PARAM}=1`);
await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task/view/all'));
const params = new URLSearchParams(seen.search);
expect(params.getAll('runAction')).toEqual(['import']);
expect(params.getAll(COMMAND_PALETTE_PARAM)).toEqual(['1']);
expect(await screen.findByTestId('palette-open')).toBeInTheDocument();
});

it('a key both name is the link\'s value once, never doubled', async () => {
serve([SHOWCASE_LANDING_WITH_QUERY]);
await visit(`/apps/com.example.showcase?runAction=export&${COMMAND_PALETTE_PARAM}=1`);
await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task/view/all'));
const params = new URLSearchParams(seen.search);
expect(params.getAll('runAction')).toEqual(['export']);
expect(params.getAll(COMMAND_PALETTE_PARAM)).toEqual(['1']);
});

it('CONTROL: a bare app root lands on the bare landing, palette closed', async () => {
serve([SHOWCASE]);
await visit('/apps/com.example.showcase');
await waitFor(() => expect(seen.pathname).toBe('/apps/com.example.showcase/showcase_task'));
expect(seen.search).toBe('');
expect(seen.hash).toBe('');
expect(screen.queryByTestId('palette-open')).toBeNull();
});
});
Loading