Merge pull request #53 from QRun-IO/feature/home-app

feat(navigation): instance home app at /app, sidebar logo and breadcrumb root
This commit was merged in pull request #53.
This commit is contained in:
james.maes authored and GitHub committed 2026-10-11 22:55:31 -05:00
commit 84c95e524a
15 files changed
+314 -20

No files matched your search

+2 -2
View File
@@ -25,7 +25,7 @@ The source of truth for the frontend shape is [`src/types/metadata.ts`](../../sr
}
```
`appTree` controls the sidebar; `apps[name].widgets` controls ordered dashboard widgets; `apps[name].sections` controls the app home cards. The server may omit denied objects or send disabled permission flags; the frontend also checks flags before requesting data. The server must still enforce authorization. A widget can also be placed in a table section through `QTableSection.widgetName`, or embedded in a process step of type `WIDGET`. `QInstance.redirects` can map old exact or wildcard Material paths to new targets. `supplementalInstanceMetaData.materialDashboardTheme` supplies the application theme, and `environmentValues` can configure analytics.
`appTree` controls the sidebar; `apps[name].widgets` controls ordered dashboard widgets; `apps[name].sections` controls the app home cards. The server may omit denied objects or send disabled permission flags; the frontend also checks flags before requesting data. The server must still enforce authorization. A widget can also be placed in a table section through `QTableSection.widgetName`, or embedded in a process step of type `WIDGET`. `QInstance.redirects` can map old exact or wildcard Material paths to new targets. `QInstance.homeAppName` (QQQ [#1059](https://github.com/QRun-IO/qqq/issues/1059)) designates the app whose dashboard is the instance's home page; it is published only to users who may open that app. `supplementalInstanceMetaData.materialDashboardTheme` supplies the application theme, and `environmentValues` can configure analytics.
## 2. Install and run
@@ -77,7 +77,7 @@ Permissions apply to metadata and data: check table capabilities and the corresp
|---|---|
| `/` | Opens `/app`, or handles an Auth0 authorization callback. |
| `/login`, `/callback`, `/token` | Sign-in and supported authorization callback routes. |
| `/app` | Instance dashboard / app entry point. |
| `/app` | Instance home page: the `homeAppName` app's dashboard when the instance designates one the user may open (client-side redirect to `/app/{homeAppName}`; the sidebar logo and breadcrumb Home crumb link there and the sidebar has no separate Dashboard entry), otherwise Next's overview. |
| `/app/{appName}` | App home and its ordered widgets/sections. |
| `/app/{tableName}` | Query or key lookup, according to table capability. |
| `/app/{tableName}/create` | Create record. |
+1
View File
@@ -13,6 +13,7 @@
## Features
- Support an instance home app. When the backend designates one with `QInstance.homeAppName` ([qqq#1059](https://github.com/QRun-IO/qqq/issues/1059)), and the user may open it, `/app` shows that app's dashboard (a client-side redirect, matching backends that redirect full page loads of `/app/`), the sidebar logo and the breadcrumb Home crumb link there, and the sidebar drops its separate Dashboard entry. Without a home app, or with one the user may not open, `/app` remains Next's overview.
- Show `FILE_PREVIEW`-adorned fields as thumbnails, and open a preview on click or Enter. Thumbnails appear in the query grid and card view, the record view (a larger thumbnail with the file name), associated-record tables, child-record list widgets and record-grid widgets. They load lazily at a fixed size, so loading does not shift the layout, with a loading pulse and a file-type icon fallback. The preview is an accessible dialog: focus is trapped and restored, Escape closes it, and Left/Right/Home/End step through the other previews on the same screen. It offers zoom and fit, open-original and download links, and shows type, size, dimensions and caption. PDFs served by the backend (same-origin `/qqq/` paths) and video/audio play inline; other types, and other or cross-origin PDFs (frames are limited to `'self'`), get a file card or open in a new tab. Thumbnails are shown only for explicitly adorned fields; plain URL values are never auto-detected. URLs are limited to http(s), same-origin paths and inline images. The lightbox loads on demand (about 3.5 KB gzip). Requires the QQQ backend adornment from [qqq#1056](https://github.com/QRun-IO/qqq/issues/1056).
## Product fixes
+84
View File
@@ -0,0 +1,84 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// Tests for the /app home page: Next's overview, or the instance's home app (QRun-IO/qqq#1059)
import React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
const replace = vi.fn()
vi.mock('next/navigation', () => ({
usePathname: () => '/app',
useRouter: () => ({ push: vi.fn(), replace }),
}))
vi.mock('@/lib/context/q-context', () => ({ useQContext: () => ({ setPageHeader: vi.fn() }) }))
vi.mock('@/lib/auth/use-auth', () => ({ useAuth: () => ({ user: { name: 'Avery Example' } }) }))
const metaData = vi.hoisted(() => ({ value: {} as Record<string, unknown> }))
vi.mock('@/lib/api/metadata', () => ({ loadMetaData: vi.fn(async () => metaData.value) }))
import DashboardPage from './page'
/**
* Instance metadata with two apps, optionally designating a home app.
*
* @param homeAppName - The home app.
* @returns The metadata.
*/
function instance(homeAppName?: string): Record<string, unknown> {
return {
apps: { home: { name: 'home', label: 'Home' }, cms: { name: 'cms', label: 'CMS' } },
appTree: [{ name: 'home', label: 'Home', type: 'APP', children: [] }, { name: 'cms', label: 'CMS', type: 'APP', children: [] }],
tables: {}, processes: {}, reports: {}, widgets: {},
...(homeAppName ? { homeAppName } : {}),
}
}
/** Renders the page with a fresh query client. */
function renderPage() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(<QueryClientProvider client={client}><DashboardPage /></QueryClientProvider>)
}
beforeEach(() => {
replace.mockReset()
})
describe('/app home page', () => {
it('shows the overview when the instance designates no home app', async () => {
metaData.value = instance()
renderPage()
await waitFor(() => expect(screen.getByText('CMS')).toBeInTheDocument())
expect(document.querySelector('[data-qqq-id="dashboard-home"]')).toBeInTheDocument()
expect(replace).not.toHaveBeenCalled()
})
it('goes to the home app dashboard instead of the overview when the instance designates one', async () => {
metaData.value = instance('home')
renderPage()
await waitFor(() => expect(replace).toHaveBeenCalledWith('/app/home'))
expect(document.querySelector('[data-qqq-id="dashboard-home"]')).not.toBeInTheDocument()
expect(document.querySelector('[data-qqq-id="dashboard-home-redirect"]')).toHaveAttribute('aria-busy', 'true')
})
it('keeps the overview when the designated home app is not one the user may open', async () => {
metaData.value = { ...instance(), homeAppName: 'secret' }
renderPage()
await waitFor(() => expect(screen.getByText('CMS')).toBeInTheDocument())
expect(replace).not.toHaveBeenCalled()
})
})
+15 -1
View File
@@ -22,6 +22,7 @@
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import {
Workflow,
@@ -44,6 +45,7 @@ import { canInsertRecords, canRunProcess } from '@/lib/auth/permissions'
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
import type { SidebarRoute } from '@/lib/hooks/use-routes'
import { getRecentRecords } from '@/lib/utils/recent-records'
import { DEFAULT_HOME_PATH } from '@/lib/utils/home-app'
import type { RecentRecord } from '@/lib/utils/recent-records'
import { MetadataIcon, type MetadataIconKind } from '@/components/layout/MetadataIcon'
@@ -102,7 +104,15 @@ export default function DashboardPage() {
staleTime: 1000 * 60 * 30,
})
const { sidebarRoutes, navTargets } = useAppTreeRoutes(metaData)
const { sidebarRoutes, navTargets, homePath } = useAppTreeRoutes(metaData)
const router = useRouter()
const hasHomeApp = homePath !== DEFAULT_HOME_PATH
// An instance with a home app (homeAppName) shows that app's dashboard as its home page
// (the backend redirects full page loads of /app; this covers client-side navigation).
useEffect(() => {
if (hasHomeApp) router.replace(homePath)
}, [hasHomeApp, homePath, router])
const appRoutes = sidebarRoutes.filter((route) => route.nodeType === 'APP')
const appName = metaData?.branding?.appName || 'QQQ Admin'
@@ -145,6 +155,10 @@ export default function DashboardPage() {
{ id: 'widgets', label: 'Widgets', value: widgetNames.size, hint: 'Dashboard components', icon: <TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> },
]
if (hasHomeApp) {
return <div aria-busy="true" data-qqq-id="dashboard-home-redirect" />
}
return (
<div className="space-y-8" data-qqq-id="dashboard-home">
{/* Welcome header */}
+4 -1
View File
@@ -99,7 +99,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
})
// Generate sidebar routes and path map from app tree
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets } = useAppTreeRoutes(metaData)
const { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets, homePath } = useAppTreeRoutes(metaData)
// Every route change starts at the top of the page (Material App scrolls to 0 on each pathname)
useEffect(() => {
@@ -273,6 +273,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
<Sidebar
routes={sidebarRoutes}
branding={metaData?.branding}
homePath={homePath}
logout={logout}
userName={user?.name}
userEmail={user?.email}
@@ -283,6 +284,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
<Sidebar
routes={sidebarRoutes}
branding={metaData?.branding}
homePath={homePath}
logout={logout}
userName={user?.name}
userEmail={user?.email}
@@ -306,6 +308,7 @@ function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
helpButtonRef={helpButtonRef}
pathToLabelMap={pathToLabelMap}
ancestorAppMap={ancestorAppMap}
homePath={homePath}
navTargets={navTargets}
searchTables={searchTables}
/>
@@ -178,3 +178,14 @@ describe('Breadcrumbs', () => {
}
})
})
describe('Breadcrumbs home crumb', () => {
it('links to the instance home app when there is one, and to the overview otherwise', () => {
currentPathname = '/app/person'
const { unmount } = render(<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} homePath="/app/home" />)
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/app/home')
unmount()
render(<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} />)
expect(screen.getByRole('link', { name: 'Home' })).toHaveAttribute('href', '/app')
})
})
+5 -3
View File
@@ -35,6 +35,8 @@ export interface BreadcrumbsProps {
pathToLabelMap: Record<string, string>
/** Maps node paths to their enclosing apps, outermost first. */
ancestorAppMap?: Record<string, ParentAppInfo[]>
/** Where the home crumb links: the instance's home app, or the `/app` overview (default). */
homePath?: string
}
/**
@@ -158,7 +160,7 @@ export function buildDocumentTitle(crumbs: Breadcrumb[], pageTitle: string | und
* `<Link>` crumbs (ancestor pages) followed by a `<span aria-current="page">`
* for the current page, or `null` on the dashboard root and before labels load.
*/
export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {} }: BreadcrumbsProps) {
export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {}, homePath = '/app' }: BreadcrumbsProps) {
const pathname = usePathname()
const breadcrumbs = buildBreadcrumbs(pathname, pathToLabelMap, ancestorAppMap)
const trailRef = useRef<HTMLElement>(null)
@@ -189,9 +191,9 @@ export default function Breadcrumbs({ pathToLabelMap, ancestorAppMap = {} }: Bre
aria-label="Breadcrumb"
data-qqq-id="breadcrumbs"
>
{/* Home crumb (Material's home icon), back to the dashboard */}
{/* Home crumb (Material's home icon), back to the home page (the home app, or the overview) */}
<Link
href="/app"
href={homePath}
aria-label="Home"
className="inline-flex items-center justify-center text-muted-foreground hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring pointer-coarse:min-h-11 pointer-coarse:min-w-11 pointer-coarse:flex-shrink-0"
data-qqq-id="breadcrumb-home"
+4 -2
View File
@@ -53,6 +53,8 @@ export interface HeaderProps {
pathToLabelMap?: Record<string, string>
/** Maps node paths to their enclosing apps, passed through to Breadcrumbs. */
ancestorAppMap?: Record<string, ParentAppInfo[]>
/** Where the breadcrumb home crumb links (the instance's home app, or `/app`). */
homePath?: string
/** Navigable app-tree nodes for the header search. */
navTargets?: NavTarget[]
/** Tables the backend record search covers, for the header search (empty: local search only). */
@@ -75,7 +77,7 @@ export interface HeaderProps {
* {@link GlobalSearch} + notifications bell on the right. The hamburger and
* GlobalSearch are each conditionally visible based on the `md` breakpoint.
*/
export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, onSearchOpen, onCommandOpen, onHelpOpen, helpButtonRef, pathToLabelMap = {}, ancestorAppMap = {}, navTargets = [], searchTables }: HeaderProps) {
export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, onSearchOpen, onCommandOpen, onHelpOpen, helpButtonRef, pathToLabelMap = {}, ancestorAppMap = {}, homePath, navTargets = [], searchTables }: HeaderProps) {
const [notificationCount] = useState(0)
// toasts appear below the header, never over its controls
const headerRef = useRef<HTMLElement>(null)
@@ -103,7 +105,7 @@ export default function Header({ onMenuOpen, menuOpen = false, menuButtonRef, on
>
<Menu className="h-5 w-5" aria-hidden="true" />
</button>
<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} />
<Breadcrumbs pathToLabelMap={pathToLabelMap} ancestorAppMap={ancestorAppMap} homePath={homePath} />
</div>
{/* Right section: search + help + notifications */}
+10
View File
@@ -500,3 +500,13 @@ describe('Sidebar', () => {
expect(document.querySelector('[data-qqq-id="sidebar-user-avatar"]')).toHaveTextContent('A')
})
})
describe('Sidebar logo link', () => {
it('links to the instance home app when there is one, and to the overview otherwise', () => {
const { container, unmount } = render(<Sidebar routes={leafRoutes} homePath="/app/home" />)
expect(container.querySelector('[data-qqq-id="sidebar-logo-link"]')).toHaveAttribute('href', '/app/home')
unmount()
const plain = render(<Sidebar routes={leafRoutes} />)
expect(plain.container.querySelector('[data-qqq-id="sidebar-logo-link"]')).toHaveAttribute('href', '/app')
})
})
+8 -4
View File
@@ -106,6 +106,8 @@ export interface SidebarProps {
routes: SidebarRoute[]
/** Application branding metadata (logo, icon, app name, banners). */
branding?: QBrandingMetaData
/** Where the logo links: the instance's home app, or the `/app` overview (default). */
homePath?: string
/** Called when the mouse enters the sidebar (desktop hover expansion). */
onMouseEnter?: () => void
/** Called when the mouse leaves the sidebar (desktop hover collapse). */
@@ -132,21 +134,22 @@ export interface SidebarProps {
*
* @param props - Component properties.
* @param props.branding - Branding metadata.
* @param props.href - The home page the logo links to.
* @returns The branding link element.
*/
function SidebarBranding({ branding }: { branding?: QBrandingMetaData }) {
function SidebarBranding({ branding, href }: { branding?: QBrandingMetaData; href: string }) {
const appName = branding?.appName || 'QQQ Admin'
const linkClass = 'flex min-w-0 flex-1 items-center rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-ring'
if (branding?.logo) {
return (
<Link href="/app" className={linkClass} data-qqq-id="sidebar-logo-link">
<Link href={href} className={linkClass} data-qqq-id="sidebar-logo-link">
{/* eslint-disable-next-line @next/next/no-img-element -- branding logos are arbitrary backend assets */}
<img src={branding.logo} alt={appName} title={appName} className="max-h-12 w-full object-contain object-left" data-qqq-id="sidebar-logo" />
</Link>
)
}
return (
<Link href="/app" className={linkClass} data-qqq-id="sidebar-logo-link">
<Link href={href} className={linkClass} data-qqq-id="sidebar-logo-link">
{branding?.icon ? (
// eslint-disable-next-line @next/next/no-img-element -- branding icons are arbitrary backend assets
<img src={branding.icon} alt="" className="h-12 w-12 flex-shrink-0 object-contain" data-qqq-id="sidebar-icon" />
@@ -182,6 +185,7 @@ function SidebarBranding({ branding }: { branding?: QBrandingMetaData }) {
export default function Sidebar({
routes,
branding,
homePath = '/app',
onMouseEnter,
onMouseLeave,
logout,
@@ -238,7 +242,7 @@ export default function Sidebar({
<div className="flex min-h-0 flex-1 flex-col" data-qqq-id="sidenav-root">
{/* Logo / App Branding — height matches header so border lines up */}
<div className="flex items-center gap-2 border-b border-border px-4" style={{ height: 'var(--qqq-header-height)' }} data-qqq-id="sidenav-logo-area">
<SidebarBranding branding={branding} />
<SidebarBranding branding={branding} href={homePath} />
{/* Close button for mobile drawer */}
{isMobileDrawer && (
<button
+57
View File
@@ -280,3 +280,60 @@ describe('useAppTreeRoutes', () => {
expect(JSON.stringify(sidebarRoutes)).not.toContain('qryItem')
})
})
describe('buildRouteMap home app', () => {
const tree: QAppTreeNode[] = [
{ name: 'home', label: 'Home', type: 'APP', children: [] },
{ name: 'cms', label: 'CMS', type: 'APP', children: [] },
]
/**
* Metadata with the tree's apps, optionally designating a home app.
*
* @param homeAppName - The instance's home app.
* @returns The metadata.
*/
function withHome(homeAppName?: string): QInstance {
const metaData = makeMetaData(tree)
metaData.apps = { home: { name: 'home', label: 'Home' }, cms: { name: 'cms', label: 'CMS' } } as unknown as QInstance['apps']
return homeAppName === undefined ? metaData : { ...metaData, homeAppName }
}
it('keeps the Dashboard overview as home when the instance designates no home app', () => {
const routes = buildRouteMap(withHome())
expect(routes.homePath).toBe('/app')
expect(routes.sidebarRoutes.map((route) => route.path)).toEqual(['/app', '/app/home', '/app/cms'])
})
it('makes the home app the home page and drops the separate Dashboard entry', () => {
const routes = buildRouteMap(withHome('home'))
expect(routes.homePath).toBe('/app/home')
expect(routes.sidebarRoutes.map((route) => route.path)).toEqual(['/app/home', '/app/cms'])
})
it('ignores a home app that is not among the apps the user may open', () => {
const routes = buildRouteMap(withHome('secret'))
expect(routes.homePath).toBe('/app')
expect(routes.sidebarRoutes[0].path).toBe('/app')
expect(buildRouteMap(undefined).homePath).toBe('/app')
})
})
describe('buildRouteMap single landing page', () => {
it('has no duplicate landing entry when homeAppName is set: the home app is the single top entry', () => {
// kof22 labels its home app "Dashboard", and the backend may order it after other apps
const metaData = makeMetaData([
{ name: 'cms', label: 'CMS', type: 'APP', children: [] },
{ name: 'home', label: 'Dashboard', type: 'APP', children: [] },
])
metaData.apps = { home: { name: 'home', label: 'Dashboard' }, cms: { name: 'cms', label: 'CMS' } } as unknown as QInstance['apps']
const routes = buildRouteMap({ ...metaData, homeAppName: 'home' })
expect(routes.homePath).toBe('/app/home')
expect(routes.sidebarRoutes.map((route) => route.path)).toEqual(['/app/home', '/app/cms'])
expect(routes.sidebarRoutes.filter((route) => route.name === 'Dashboard')).toHaveLength(1)
expect(routes.sidebarRoutes.some((route) => route.key === 'dashboard' || route.path === '/app')).toBe(false)
// without homeAppName the built-in Dashboard overview entry stays first, as before
expect(buildRouteMap(metaData).sidebarRoutes.map((route) => route.path)).toEqual(['/app', '/app/cms', '/app/home'])
})
})
+16 -7
View File
@@ -23,6 +23,7 @@ import { useMemo } from 'react'
import type { QInstance, QAppTreeNode, QIcon } from '@/types'
import type { QAppNodeType } from '@/types/enums'
import { DEFAULT_HOME_PATH, homePath as instanceHomePath } from '@/lib/utils/home-app'
/**
* A single entry in the sidebar navigation tree.
@@ -78,7 +79,7 @@ export interface NavTarget {
* command palette, header search and landing page.
*/
export interface RouteMap {
/** Ordered sidebar nodes: the Dashboard entry, then every top-level APP node. */
/** Ordered sidebar nodes: the Dashboard entry (or, with a home app, that app as the single top entry), then every top-level APP node. */
sidebarRoutes: SidebarRoute[]
/** Maps every known route path to a human-readable label for breadcrumbs. */
pathToLabelMap: Record<string, string>
@@ -88,6 +89,11 @@ export interface RouteMap {
navTargets: NavTarget[]
/** The first accessible app route, used as the post-login redirect target. */
defaultRoute: string
/**
* Where "home" goes (sidebar logo, breadcrumb root, `/app` itself): the instance's home app
* (`homeAppName`) when it designates one the user may open, otherwise the `/app` overview.
*/
homePath: string
}
/** Fixed pages under `/app` that are not app-tree nodes. */
@@ -143,7 +149,7 @@ export function isHiddenNode(node: QAppTreeNode, metaData: QInstance): boolean {
*/
export function buildRouteMap(metaData: QInstance | undefined): RouteMap {
if (!metaData) {
return { sidebarRoutes: [], pathToLabelMap: {}, ancestorAppMap: {}, navTargets: [], defaultRoute: '/no-apps' }
return { sidebarRoutes: [], pathToLabelMap: {}, ancestorAppMap: {}, navTargets: [], defaultRoute: '/no-apps', homePath: DEFAULT_HOME_PATH }
}
const pathToLabelMap: Record<string, string> = { ...STATIC_PAGE_LABELS }
@@ -212,11 +218,14 @@ export function buildRouteMap(metaData: QInstance | undefined): RouteMap {
}
}
}
const sidebarRoutes: SidebarRoute[] = [
{ name: 'Dashboard', key: 'dashboard', path: '/app', icon: { name: 'dashboard' }, type: 'item' },
...appRoutes,
]
return { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets, defaultRoute: defaultRoute ?? '/no-apps' }
// One landing page: with a home app, /app shows that app, which becomes the single top entry in
// place of the built-in Dashboard (no separate overview entry beside it).
const homePath = instanceHomePath(metaData)
const homeRoute = appRoutes.find((route) => route.path === homePath)
const sidebarRoutes: SidebarRoute[] = homeRoute
? [homeRoute, ...appRoutes.filter((route) => route !== homeRoute)]
: [{ name: 'Dashboard', key: 'dashboard', path: '/app', icon: { name: 'dashboard' }, type: 'item' }, ...appRoutes]
return { sidebarRoutes, pathToLabelMap, ancestorAppMap, navTargets, defaultRoute: defaultRoute ?? '/no-apps', homePath }
}
/**
+39
View File
@@ -0,0 +1,39 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest'
import type { QInstance } from '@/types'
import { DEFAULT_HOME_PATH, homeAppName, homePath } from './home-app'
const apps = { home: { name: 'home', label: 'Home' }, 'my app': { name: 'my app', label: 'Mine' } } as unknown as QInstance['apps']
describe('home app', () => {
it('is the designated app when the user may open it', () => {
expect(homeAppName({ apps, homeAppName: 'home' })).toBe('home')
expect(homePath({ apps, homeAppName: 'home' })).toBe('/app/home')
expect(homePath({ apps, homeAppName: 'my app' })).toBe('/app/my%20app')
})
it('falls back to the overview without a usable home app', () => {
expect(DEFAULT_HOME_PATH).toBe('/app')
expect(homePath(undefined)).toBe('/app')
expect(homePath({ apps })).toBe('/app')
expect(homePath({ apps, homeAppName: '' })).toBe('/app')
expect(homePath({ apps, homeAppName: 'notThere' })).toBe('/app')
expect(homeAppName({ apps: undefined as unknown as QInstance['apps'], homeAppName: 'home' })).toBeUndefined()
})
})
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright 2026 QRun.IO, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @file home-app — the instance's home page: the dashboard of the app the backend designates
* as its home (`homeAppName`, QRun-IO/qqq#1059), or Next's own overview at `/app`.
*/
import type { QInstance } from '@/types'
/** Next's own overview page, the home when the instance designates no home app. */
export const DEFAULT_HOME_PATH = '/app'
/**
* The instance's home app, when the backend designates one the user may open.
*
* The backend publishes `homeAppName` only to users who may access that app; it is also checked
* against the apps in the metadata, so an older or inconsistent backend never sends the user to
* an app that is not there.
*
* @param instance - Instance metadata, or undefined while loading.
* @returns The app name, or undefined.
*/
export function homeAppName(instance: Pick<QInstance, 'homeAppName' | 'apps'> | undefined): string | undefined {
const name = instance?.homeAppName
return typeof name === 'string' && name !== '' && instance?.apps?.[name] ? name : undefined
}
/**
* Where "home" goes (the sidebar logo, the breadcrumb root, `/app` itself): the home app's
* dashboard, or Next's overview.
*
* @param instance - Instance metadata, or undefined while loading.
* @returns `/app/{homeApp}` or `/app`.
*/
export function homePath(instance: Pick<QInstance, 'homeAppName' | 'apps'> | undefined): string {
const name = homeAppName(instance)
return name ? `${DEFAULT_HOME_PATH}/${encodeURIComponent(name)}` : DEFAULT_HOME_PATH
}
+6
View File
@@ -65,6 +65,12 @@ export interface QInstance {
* e.g. from a table under an app the user may not open to the same table under one they may.
*/
redirects?: Record<string, string>
/**
* The app whose dashboard is the instance's home page (QRun-IO/qqq#1059): `/app`, the sidebar
* logo and the breadcrumb root go there. Sent only when the user may open that app; absent,
* Next's own overview is the home page.
*/
homeAppName?: string
}
/**