Skip to content

Commit c37c3e9

Browse files
committed
refactor(sql): derive fullscreen routes from staticData, drop hardcoded /sql [UX-1330]
Fullscreen routes (the SQL studio) render minimal chrome. Detection now reads the typed staticData.fullscreen flag — StaticDataRouteOption is augmented in app.tsx so the per-call-site casts can go. The path fallback stays (on soft navigation useMatches() lags useLocation(), so staticData is briefly stale and the studio would flash full chrome on the way in) but no longer hardcodes /sql: fullscreen-routes.ts derives the paths from the route tree, so future fullscreen routes are covered automatically.
1 parent 6780b8b commit c37c3e9

6 files changed

Lines changed: 293 additions & 53 deletions

File tree

frontend/src/app.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import { builderCustomComponents } from 'components/builder-io/builder-custom-co
4242
import { BUILDER_API_KEY } from 'components/constants';
4343
import { CustomFeatureFlagProvider } from 'custom-feature-flag-provider';
4444
import useDeveloperView from 'hooks/use-developer-view';
45+
import type { LucideIcon } from 'lucide-react';
4546
import { protobufRegistry } from 'protobuf-registry';
4647
import queryClient from 'query-client';
4748
import { useEffect } from 'react';
@@ -98,6 +99,16 @@ declare module '@tanstack/react-router' {
9899
content?: string;
99100
score?: number;
100101
}
102+
103+
// biome-ignore lint/style/useConsistentTypeDefinitions: Required for TanStack Router module augmentation
104+
interface StaticDataRouteOption {
105+
/** Route title shown in the page header/breadcrumbs. */
106+
title?: string;
107+
/** Lucide icon for the route's sidebar entry. */
108+
icon?: LucideIcon;
109+
/** Render the route with minimal chrome (no page header/footer/padding). */
110+
fullscreen?: boolean;
111+
}
101112
}
102113

103114
const EMPTY_SETUP_ARGS = {};

frontend/src/components/layout/header.tsx

Lines changed: 54 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
*/
1111

1212
import { Button, ColorModeSwitch, CopyButton } from '@redpanda-data/ui';
13-
import { Link, useLocation, useMatchRoute } from '@tanstack/react-router';
13+
import { Link, useLocation, useMatches, useMatchRoute } from '@tanstack/react-router';
1414
import { Heading } from 'components/redpanda-ui/components/typography';
1515
import { cn } from 'components/redpanda-ui/lib/utils';
1616
import { ChevronLeft } from 'lucide-react';
@@ -69,8 +69,14 @@ function BreadcrumbHeaderRow({ useNewSidebar, breadcrumbItems }: BreadcrumbHeade
6969
);
7070
}
7171

72-
function AppPageHeader() {
72+
function AppPageHeader({ breadcrumbOnly = false }: { breadcrumbOnly?: boolean }) {
7373
useApiStoreHook((s) => s.userData); // re-render when userData changes
74+
// Fullscreen routes (e.g. the SQL studio) carry their own title bar/toolbar, so
75+
// they never want the title+actions row — only the breadcrumb. Robust to which
76+
// layout branch renders the header (standalone vs embedded misdetection).
77+
const matches = useMatches();
78+
const isFullscreenRoute = matches.some((m) => m.staticData.fullscreen);
79+
const hideTitleRow = breadcrumbOnly || isFullscreenRoute;
7480
const showRefresh = useShouldShowRefresh();
7581
const shouldHideHeader = useShouldHideHeader();
7682
const useNewSidebar = !isEmbedded();
@@ -104,49 +110,55 @@ function AppPageHeader() {
104110
<div>
105111
<BreadcrumbHeaderRow breadcrumbItems={breadcrumbItems} useNewSidebar={useNewSidebar} />
106112

107-
<div className="flex items-center justify-between pt-6">
108-
<div className="flex flex-col gap-1">
109-
{backLink && (
110-
<RegistryButton asChild className="-ml-2 w-fit text-muted-foreground" variant="ghost">
111-
<Link to={backLink.linkTo}>
112-
<ChevronLeft className="h-4 w-4" />
113-
{backLink.title}
113+
{/* Title + actions row. Hidden for breadcrumb-only headers (e.g. the SQL
114+
studio, which carries its own title bar and toolbar). */}
115+
{!hideTitleRow && (
116+
<div className="flex items-center justify-between pt-6">
117+
<div className="flex flex-col gap-1">
118+
{backLink && (
119+
<RegistryButton asChild className="-ml-2 w-fit text-muted-foreground" variant="ghost">
120+
<Link to={backLink.linkTo}>
121+
<ChevronLeft className="h-4 w-4" />
122+
{backLink.title}
123+
</Link>
124+
</RegistryButton>
125+
)}
126+
<div className="flex items-center">
127+
{pageTitle ? (
128+
<Heading
129+
className={cn('mr-2', lastBreadcrumb?.options?.canBeTruncated ? 'break-spaces break-all' : 'nowrap')}
130+
level={1}
131+
>
132+
{pageTitle}
133+
</Heading>
134+
) : null}
135+
{lastBreadcrumb?.options?.canBeCopied ? (
136+
<CopyButton content={lastBreadcrumb.title} variant="ghost" />
137+
) : null}
138+
{Boolean(showRefresh) && <DataRefreshButton />}
139+
</div>
140+
</div>
141+
<div className="flex items-center gap-2">
142+
{!isEmbedded() && api.isRedpanda && (
143+
<Link to="/debug-bundle">
144+
<Button
145+
isDisabled={!api.userData?.canViewDebugBundle}
146+
tooltip={
147+
api.userData?.canViewDebugBundle
148+
? null
149+
: 'You need RedpandaCapability.MANAGE_DEBUG_BUNDLE permission'
150+
}
151+
variant="ghost"
152+
>
153+
Debug bundle
154+
</Button>
114155
</Link>
115-
</RegistryButton>
116-
)}
117-
<div className="flex items-center">
118-
{pageTitle ? (
119-
<Heading
120-
className={cn('mr-2', lastBreadcrumb?.options?.canBeTruncated ? 'break-spaces break-all' : 'nowrap')}
121-
level={1}
122-
>
123-
{pageTitle}
124-
</Heading>
125-
) : null}
126-
{lastBreadcrumb?.options?.canBeCopied ? (
127-
<CopyButton content={lastBreadcrumb.title} variant="ghost" />
128-
) : null}
129-
{Boolean(showRefresh) && <DataRefreshButton />}
156+
)}
157+
<UserPreferencesButton />
158+
{IsDev && !isEmbedded() && <ColorModeSwitch m={0} p={0} variant="ghost" />}
130159
</div>
131160
</div>
132-
<div className="flex items-center gap-2">
133-
{!isEmbedded() && api.isRedpanda && (
134-
<Link to="/debug-bundle">
135-
<Button
136-
isDisabled={!api.userData?.canViewDebugBundle}
137-
tooltip={
138-
api.userData?.canViewDebugBundle ? null : 'You need RedpandaCapability.MANAGE_DEBUG_BUNDLE permission'
139-
}
140-
variant="ghost"
141-
>
142-
Debug bundle
143-
</Button>
144-
</Link>
145-
)}
146-
<UserPreferencesButton />
147-
{IsDev && !isEmbedded() && <ColorModeSwitch m={0} p={0} variant="ghost" />}
148-
</div>
149-
</div>
161+
)}
150162
</div>
151163
);
152164
}

frontend/src/federation/federated-routes.tsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
import type { Transport } from '@connectrpc/connect';
1313
import type { QueryClient } from '@tanstack/react-query';
14-
import { createRootRouteWithContext, Outlet } from '@tanstack/react-router';
14+
import { createRootRouteWithContext, Outlet, useLocation, useMatches } from '@tanstack/react-router';
1515
import { NuqsAdapter } from 'nuqs/adapters/tanstack-router';
1616

1717
import { DebugHelper } from '../components/debug-helper/debug-dialog';
@@ -25,6 +25,7 @@ import { NullFallbackBoundary } from '../components/misc/null-fallback-boundary'
2525
import { RouterSync } from '../components/misc/router-sync';
2626
import { Toaster } from '../components/redpanda-ui/components/sonner';
2727
import RequireAuth from '../components/require-auth';
28+
import { isFullscreenPath } from '../utils/fullscreen-routes';
2829
import { ModalContainer } from '../utils/modal-container';
2930

3031
/**
@@ -88,24 +89,34 @@ function FederatedRootLayout() {
8889
* Similar to EmbeddedLayout from __root.tsx but optimized for MF v2.0.
8990
*/
9091
function FederatedAppContent() {
91-
// Mirrors __root.tsx's EmbeddedLayout so the embedded experience matches
92-
// production: AppPageHeader renders the page title (it already suppresses
93-
// the breadcrumb/sidebar-trigger in embedded mode — the host supplies those).
92+
const matches = useMatches();
93+
const { pathname } = useLocation();
94+
// Fullscreen routes (SQL studio) own their chrome — breadcrumb-only header, no
95+
// padding/footer. staticData is the source of truth, but on soft navigation
96+
// useMatches() lags useLocation() by a render or two (matches resolve after
97+
// pathname flips), so fall back to a path check to avoid flashing full chrome on
98+
// the way in. Single return with stable element positions: toggling props/classes
99+
// (not branching the tree) keeps the <Outlet> mounted across fullscreen↔normal
100+
// navigation, so the embedded router doesn't reset to its default route.
101+
const isFullscreen = matches.some((m) => m.staticData.fullscreen) || isFullscreenPath(pathname);
102+
94103
return (
95104
<div id="mainLayout">
96-
<NullFallbackBoundary>
97-
<LicenseNotification />
98-
</NullFallbackBoundary>
105+
{!isFullscreen && (
106+
<NullFallbackBoundary>
107+
<LicenseNotification />
108+
</NullFallbackBoundary>
109+
)}
99110
<ModalContainer />
100-
<AppPageHeader />
111+
<AppPageHeader breadcrumbOnly={isFullscreen} />
101112

102113
<ErrorDisplay>
103-
<div className="pt-8">
114+
<div className={isFullscreen ? undefined : 'pt-8'}>
104115
<Outlet />
105116
</div>
106117
</ErrorDisplay>
107118

108-
<AppFooter />
119+
{!isFullscreen && <AppFooter />}
109120

110121
<ErrorModalsRenderer />
111122

frontend/src/routes/__root.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import { NullFallbackBoundary } from '../components/misc/null-fallback-boundary'
3131
import { RouterSync } from '../components/misc/router-sync';
3232
import { SidebarInset } from '../components/redpanda-ui/components/sidebar';
3333
import RequireAuth from '../components/require-auth';
34+
import { isFullscreenPath } from '../utils/fullscreen-routes';
3435
import { ModalContainer } from '../utils/modal-container';
3536

3637
export type RouterContext = {
@@ -87,13 +88,15 @@ function EmbeddedLayout() {
8788

8889
function AppContent() {
8990
const matches = useMatches();
90-
const isFullscreen = matches.some((m) => (m.staticData as { fullscreen?: boolean }).fullscreen);
91+
const { pathname } = useLocation();
92+
const isFullscreen = matches.some((m) => m.staticData.fullscreen) || isFullscreenPath(pathname);
9193

9294
if (isFullscreen) {
9395
return (
9496
<div id="mainLayout">
9597
<TooltipProvider>
9698
<ModalContainer />
99+
{!isEmbedded() && <AppPageHeader breadcrumbOnly />}
97100
<ErrorDisplay>
98101
<Outlet />
99102
</ErrorDisplay>
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* Copyright 2026 Redpanda Data, Inc.
3+
*
4+
* Use of this software is governed by the Business Source License
5+
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
6+
*
7+
* As of the Change Date specified in that file, in accordance with
8+
* the Business Source License, use of this software will be governed
9+
* by the Apache License, Version 2.0
10+
*/
11+
12+
import { describe, expect, test } from 'vitest';
13+
14+
import { collectFullscreenPaths, isFullscreenPath, matchesFullscreenPath } from './fullscreen-routes';
15+
import { routeTree } from '../routeTree.gen';
16+
17+
describe('collectFullscreenPaths', () => {
18+
test('collects paths from built route nodes (path/staticData under options)', () => {
19+
const tree = {
20+
children: {
21+
SqlRoute: { options: { path: '/sql', staticData: { fullscreen: true } } },
22+
TopicsRoute: { options: { path: '/topics', staticData: { fullscreen: false } } },
23+
QuotasRoute: { options: { path: '/quotas' } },
24+
},
25+
};
26+
expect(collectFullscreenPaths(tree)).toEqual(['/sql']);
27+
});
28+
29+
test('collects paths from raw route nodes (path/staticData at top level)', () => {
30+
const tree = {
31+
children: {
32+
SqlRoute: { path: '/sql', staticData: { fullscreen: true } },
33+
},
34+
};
35+
expect(collectFullscreenPaths(tree)).toEqual(['/sql']);
36+
});
37+
38+
test('recurses into nested children', () => {
39+
const tree = {
40+
children: {
41+
Parent: {
42+
options: { path: '/parent' },
43+
children: {
44+
Child: { options: { path: '/parent/studio', staticData: { fullscreen: true } } },
45+
},
46+
},
47+
},
48+
};
49+
expect(collectFullscreenPaths(tree)).toEqual(['/parent/studio']);
50+
});
51+
52+
test('ignores a fullscreen route with no path', () => {
53+
const tree = { children: { Bad: { options: { staticData: { fullscreen: true } } } } };
54+
expect(collectFullscreenPaths(tree)).toEqual([]);
55+
});
56+
57+
test('tolerates non-object input', () => {
58+
expect(collectFullscreenPaths(null)).toEqual([]);
59+
expect(collectFullscreenPaths(undefined)).toEqual([]);
60+
});
61+
62+
test('derives /sql from the real route tree', () => {
63+
// Guards against route-tree shape changes and against the SQL route losing
64+
// its staticData.fullscreen flag.
65+
expect(collectFullscreenPaths(routeTree)).toContain('/sql');
66+
});
67+
});
68+
69+
describe('matchesFullscreenPath', () => {
70+
const paths = ['/sql'];
71+
72+
test('matches the exact path', () => {
73+
expect(matchesFullscreenPath('/sql', paths)).toBe(true);
74+
});
75+
76+
test('matches a nested path', () => {
77+
expect(matchesFullscreenPath('/sql/query/123', paths)).toBe(true);
78+
});
79+
80+
test('matches an embedded path with a host cluster prefix', () => {
81+
expect(matchesFullscreenPath('/clusters/abc123/sql', paths)).toBe(true);
82+
});
83+
84+
test('does not match a path that merely starts with the segment text', () => {
85+
expect(matchesFullscreenPath('/sqlx', paths)).toBe(false);
86+
expect(matchesFullscreenPath('/mysql', paths)).toBe(false);
87+
});
88+
89+
test('does not match unrelated paths', () => {
90+
expect(matchesFullscreenPath('/topics', paths)).toBe(false);
91+
});
92+
93+
test('returns false when there are no fullscreen paths', () => {
94+
expect(matchesFullscreenPath('/sql', [])).toBe(false);
95+
});
96+
});
97+
98+
describe('isFullscreenPath (wired to the real route tree)', () => {
99+
test('recognizes the SQL studio, standalone and embedded', () => {
100+
expect(isFullscreenPath('/sql')).toBe(true);
101+
expect(isFullscreenPath('/clusters/abc123/sql')).toBe(true);
102+
});
103+
104+
test('rejects non-fullscreen routes', () => {
105+
expect(isFullscreenPath('/topics')).toBe(false);
106+
expect(isFullscreenPath('/overview')).toBe(false);
107+
});
108+
});

0 commit comments

Comments
 (0)