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
15 changes: 15 additions & 0 deletions .changeset/remaining-setup-links-3611.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/app-shell': patch
---

Point the four remaining "Settings" senders at the system hub `/apps/setup/system` instead of the bare `/apps/setup` (objectui#3611).

Same root cause as objectui#3590, which fixed the three call sites inside its declared file surface: `AppContent` mounts the system hub only under `isSystemRoute`, which keys on a `/system` path segment, so on a zero-app deployment the bare `/apps/setup` *is* the "No Apps Configured" empty state's own URL and every entry spelling it looped in place.

Three of the four are live defects, all reachable on a zero-app deployment today:

- `AppSidebar`'s no-active-app sidebar header (`system-sidebar-header`) — the sharpest of them, since it renders *only* when there is no active app, i.e. it was unreachable except in exactly the state where its target was broken.
- `AppSidebar`'s user-menu "Settings" entry.
- `SystemRedirect`'s bare `/system` legacy bookmark. This forwarder was already half right — every *suffixed* bookmark (`/system/users`) was correctly rewritten to `/apps/setup/system/users`, and only the bare one dropped the `system` segment. The bare branch now agrees with the suffixed branch beside it; no new logic.

The fourth, `QuickActions`' "System Settings" card, is dormant — the component has zero JSX call sites repo-wide, so no user can reach it today. It is corrected in the same pass so the dead link cannot return with the component if it is ever remounted.
10 changes: 8 additions & 2 deletions packages/app-shell/src/console/ConsoleShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -370,11 +370,17 @@ export function RootRedirect() {

/**
* SystemRedirect — forwards legacy /system/* URLs to the canonical
* /apps/setup/* location so bookmarks keep working. Suffix is preserved.
* /apps/setup/system/* location so bookmarks keep working. Suffix is preserved.
*
* #3611 — the bare `/system` bookmark used to land on the bare `/apps/setup`,
* which on a zero-app deployment is the "No Apps Configured" empty state's own
* URL. Every suffixed bookmark was already forwarded to `/apps/setup/system…`;
* the bare one now agrees with them instead of dropping the `system` segment
* that makes the hub mount at all.
*/
export function SystemRedirect() {
const location = useLocation();
const suffix = location.pathname.replace(/^\/system/, '');
const target = suffix ? `/apps/setup/system${suffix}` : '/apps/setup';
const target = suffix ? `/apps/setup/system${suffix}` : '/apps/setup/system';
return <Navigate to={`${target}${location.search}${location.hash}`} replace />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `SystemRedirect` — the legacy `/system*` bookmark forwarder (objectui#3611).
*
* ## The defect: the component disagreed with itself
*
* The forwarder was already HALF right. It built its target as
*
* suffix ? `/apps/setup/system${suffix}` : '/apps/setup'
*
* so every SUFFIXED legacy bookmark (`/system/users`) was correctly forwarded
* to `/apps/setup/system/users`, while the BARE `/system` bookmark dropped the
* `system` segment entirely and landed on `/apps/setup`.
*
* That segment is not decoration: `AppContent` mounts the system hub only when
* `isSystemRoute` (`pathname.includes('/system')`) holds, so on a zero-app
* deployment the bare `/apps/setup` falls through to the "No Apps Configured"
* empty state — it is that empty state's own URL. The fix makes the bare branch
* agree with the suffixed branch beside it; it adds no new logic.
*
* ## Route shape
*
* The route below is spelled exactly as the real consumers spell it
* (`apps/console/src/App.tsx`, `examples/console-starter/src/App.tsx`):
* `<Route path="/system/*" />`. The splat also matches the bare `/system`, with
* an empty splat — which is precisely how the defective branch was reachable.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';

import { SystemRedirect } from '../ConsoleShell';

/** Reports where the redirect actually landed, including search and hash. */
function Landing() {
const { pathname, search, hash } = useLocation();
return <div data-testid="landing">{`${pathname}${search}${hash}`}</div>;
}

function landedFrom(entry: string): string {
render(
<MemoryRouter initialEntries={[entry]}>
<Routes>
{/* The one route declaration every console consumer ships. */}
<Route path="/system/*" element={<SystemRedirect />} />
<Route path="*" element={<Landing />} />
</Routes>
</MemoryRouter>,
);
return screen.getByTestId('landing').textContent ?? '';
}

describe('SystemRedirect legacy bookmark forwarding (objectui#3611)', () => {
it('THE FIX: the bare /system bookmark lands on the system hub, not the empty state URL', () => {
expect(landedFrom('/system')).toBe('/apps/setup/system');
});

it('REGRESSION: suffixed bookmarks — the half that was already correct — are unchanged', () => {
expect(landedFrom('/system/users')).toBe('/apps/setup/system/users');
});

it('REGRESSION: a deep suffix keeps every segment', () => {
expect(landedFrom('/system/metadata/object')).toBe('/apps/setup/system/metadata/object');
});

it('preserves search and hash on the bare bookmark too', () => {
// The bare branch is the one that changed, so its query/hash carry-over is
// worth pinning explicitly rather than inferring it from the suffixed case.
expect(landedFrom('/system?tab=general#audit')).toBe('/apps/setup/system?tab=general#audit');
});
});
4 changes: 3 additions & 1 deletion packages/app-shell/src/console/home/QuickActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ export function QuickActions() {
label: t('home.quickActions.systemSettings', { defaultValue: 'System Settings' }),
description: t('home.quickActions.systemSettingsDesc', { defaultValue: 'Configure your workspace' }),
icon: Settings,
href: '/apps/setup',
// #3611 — the system hub, not the bare `/apps/setup` (which is the
// "No Apps Configured" empty state's own URL on a zero-app deployment).
href: '/apps/setup/system',
iconBg: 'bg-gradient-to-br from-emerald-500/15 to-teal-500/10 ring-emerald-500/20',
iconText: 'text-emerald-600 dark:text-emerald-400',
hoverBorder: 'hover:border-emerald-500/40',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `QuickActions` — "System Settings" card target (objectui#3611).
*
* ## This one is DORMANT, and the test says so on purpose
*
* Unlike the other three sites #3611 fixes, no user can reach this card today:
* `QuickActions` has zero JSX call sites repo-wide. It is exported from
* `console/home/index.ts` and rendered by nobody (`HomePage` builds its own
* tiles). So there is no user-visible behavior change here and nothing to
* verify through a mounted page.
*
* It was fixed anyway, in the same pass, for one reason: the day someone
* remounts this component on `/home`, the dead link comes back with it. This
* file is the guard that makes that reappearance impossible — it renders the
* component DIRECTLY (the honest scope for dormant code) rather than pretending
* a route reaches it.
*
* ## The target
*
* Same root cause as its three live siblings: `AppContent` mounts the system
* hub only on `isSystemRoute`, so a bare `/apps/setup` is the "No Apps
* Configured" empty state's own URL on a zero-app deployment. The card's
* sibling ("Manage Objects") already spelled `/apps/setup/system/...`, which is
* what made this one the odd entry out.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string, options?: Record<string, unknown>) => String(options?.defaultValue ?? key),
}),
}));

import { QuickActions } from '../QuickActions';

/** Reports where a card's navigate() actually put the router. */
function Landing() {
const { pathname } = useLocation();
return <div data-testid="landing">{pathname}</div>;
}

function renderQuickActions() {
render(
<MemoryRouter initialEntries={['/home']}>
<QuickActions />
<Routes>
<Route path="*" element={<Landing />} />
</Routes>
</MemoryRouter>,
);
}

const SYSTEM_HUB = '/apps/setup/system';

describe('QuickActions system-settings card (objectui#3611, dormant)', () => {
it('DORMANCY PRECONDITION: nothing renders this component, so the fix is a guard, not a user-visible change', async () => {
// Recorded as an assertion rather than prose so it goes red the day the
// component is remounted — at which point the pin below stops being a
// guard and becomes a live-path test, and this file should be re-read.
const { readFileSync, readdirSync, statSync } = await import('node:fs');
const path = await import('node:path');
const { fileURLToPath } = await import('node:url');

const here = path.dirname(fileURLToPath(import.meta.url));
// .../src/console/home/__tests__ -> .../src
const srcRoot = path.resolve(here, '../../..');

const callSites: string[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.name === 'node_modules' || entry.name === 'dist') continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (/\.tsx$/.test(entry.name) && !/\.(test|spec)\.tsx$/.test(entry.name)) {
if (/<QuickActions\b/.test(readFileSync(full, 'utf8'))) callSites.push(full);
}
}
};
if (statSync(srcRoot).isDirectory()) walk(srcRoot);

expect(callSites).toEqual([]);
});

it('the System Settings card targets the system hub, not the bare setup URL', async () => {
const user = userEvent.setup();
renderQuickActions();

await user.click(screen.getByTestId('quick-action-system-settings'));

expect(screen.getByTestId('landing')).toHaveTextContent(SYSTEM_HUB);
});

it('REGRESSION: the sibling card that was already hub-scoped is unchanged', async () => {
const user = userEvent.setup();
renderQuickActions();

await user.click(screen.getByTestId('quick-action-manage-objects'));

expect(screen.getByTestId('landing')).toHaveTextContent(`${SYSTEM_HUB}/metadata/object`);
});
});
15 changes: 13 additions & 2 deletions packages/app-shell/src/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,14 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
/* No-app fallback header */
<SidebarMenuButton
size="lg"
onClick={() => navigate('/apps/setup')}
/* #3611 — the system hub is `/apps/setup/system`, not the bare
`/apps/setup`. This header renders ONLY when `activeApp` is
falsy, i.e. exactly on the zero-app deployment where
`/apps/setup` is the "No Apps Configured" empty state's own
URL — so the bare target sent the user back to the screen
they were already looking at. Same fix as the `sys-settings`
entry above (#3590). */
onClick={() => navigate('/apps/setup/system')}
data-testid="system-sidebar-header"
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-primary text-primary-foreground">
Expand Down Expand Up @@ -680,8 +687,12 @@ export function AppSidebar({ activeAppName, onAppChange }: { activeAppName: stri
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{/* #3611 — "Settings" means the system hub. The bare
`/apps/setup` resolves to the "No Apps Configured" empty
state on a zero-app deployment, so this entry looped in
place there. */}
<DropdownMenuItem
onClick={() => navigate('/apps/setup')}
onClick={() => navigate('/apps/setup/system')}
>
<Settings className="mr-2 h-4 w-4" />
{t('user.settings', { defaultValue: 'Settings' })}
Expand Down
Loading
Loading