From 6ba1b9cc91754f63a3dd4be09cccb252d27b094a Mon Sep 17 00:00:00 2001 From: Dawson Toth Date: Sat, 10 Oct 2026 21:41:31 -0400 Subject: [PATCH] fix(cards): org and cluster card menu links no longer resolve one segment too deep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OrgCard and ClusterCard built their menu targets as bare relative segments (`${cluster.id}/sign-in`, `${organizationId}/roles`, and OrgCard's whole-card `to={organizationId}`). TanStack resolves a relative `to` against the location the Link renders under, so a card still mounted while the router moves into a sibling entity re-resolved to /org-a/clu-b/clu-c/sign-in or /org-b/org-a — the latter matching the cluster home with clusterId bound to an org id. Same class as #1710's instance rows. Build all twelve targets with buildAbsoluteLinkToPage from the card's own entity ids, and narrow EntityMenuItem.to and the helper's return to a new AbsolutePath (`/${string}`) type, so the next relative menu target is a compile error rather than a latent bug. useInstanceMenuItems already passes the helper's output and compiles unchanged. Fixes #1711 Co-Authored-By: Claude Opus 5.5 --- DESIGN.md | 3 +- src/components/ui/entityMenu.tsx | 4 +- .../clusters/components/ClusterCard.tsx | 14 +- ...usterCardLinkLocationIndependence.test.tsx | 144 ++++++++++++++++++ .../organizations/components/OrgCard.tsx | 15 +- .../orgCardLinkLocationIndependence.test.tsx | 136 +++++++++++++++++ src/lib/urls/buildAbsoluteLinkToPage.test.ts | 30 ++++ src/lib/urls/buildAbsoluteLinkToPage.ts | 13 +- 8 files changed, 336 insertions(+), 23 deletions(-) create mode 100644 src/features/clusters/components/clusterCardLinkLocationIndependence.test.tsx create mode 100644 src/features/organizations/components/orgCardLinkLocationIndependence.test.tsx create mode 100644 src/lib/urls/buildAbsoluteLinkToPage.test.ts diff --git a/DESIGN.md b/DESIGN.md index be3545a83..53af2e1cb 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -60,7 +60,8 @@ Central manager only ever sends the boolean, never the other organizations' ids; - **Routing**: TanStack Router, file-based, lazy-loaded. Build a link to another org/cluster/instance with `buildAbsoluteLinkToPage`. A relative `../…` is only safe from a component that unmounts with its own route: TanStack resolves it against the _current_ location, so a row that outlives a pending - navigation re-resolves its href one level too deep (HarperFast/studio#1710). + navigation re-resolves its href one level too deep (HarperFast/studio#1710, #1711). + `EntityMenuItem.to` accepts only the helper's `AbsolutePath`, so a relative menu target fails `tsc`. - **Data fetching**: TanStack React Query 5. Query keys must be instance-scoped when the request targets a specific instance. - **Forms**: react-hook-form with a zod `zodResolver`. A form whose submit button waits on `formState.isValid` must not use the default `onSubmit` mode, which keeps `isValid` current but shows errors only after a submit the disabled button never allows, so the user is blocked with no reason given (HarperFast/studio#1550). Use `mode: 'onTouched'`: a field's error appears once it is first left and then follows each change, while `isValid` still tracks every keystroke (`onBlur` recomputes `isValid` only on blur, so the button lags the text). Enter neither submits past a disabled button nor leaves the field, so give the `
` `onKeyDown={revealFieldErrorOnEnter(form)}`, which treats Enter in a text input as leaving it. A refine that compares two fields reports on one of them; re-check that field from the other's `onChange` once it has been touched (`getFieldState(name).isTouched`, then `trigger(name)`), because `rules={{ deps }}` fires on the other field's first blur, before the user reaches it. react-hook-form only updates the `formState` fields a render has read, and `disabled={!isDirty || !isValid}` skips `isValid` while the form is pristine, so where one change can complete the form (a single field, a paste, a checkbox) the button stays disabled on a valid form. Read the fields before the JSX (`const { isDirty, isValid } = form.formState;`) and pin it with a test that makes one change and checks the button. - **Cloud permission gates** ([`usePermissions.ts`](src/hooks/usePermissions.ts)) must mirror the check the central manager endpoint runs, not the nearest role flag. An org admin appears in `/User/current` as `roles[orgId].role === 'admin'`; central manager builds every org role with `permission.super_user: false`, so the member helpers' `super_user` short-circuits never identify one — an admin gets full access there from its all-true flags. Container lifecycle ops are where the two diverge: their endpoints admit only org admins and staff holding `instance:update`, so gate them on `useContainerOpsPermission`, never on cluster `update`. diff --git a/src/components/ui/entityMenu.tsx b/src/components/ui/entityMenu.tsx index 5b38b7e54..ec1bf1aa0 100644 --- a/src/components/ui/entityMenu.tsx +++ b/src/components/ui/entityMenu.tsx @@ -7,6 +7,7 @@ import { ContextMenuTrigger, } from '@/components/ui/contextMenu'; import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator } from '@/components/ui/dropdownMenu'; +import type { AbsolutePath } from '@/lib/urls/buildAbsoluteLinkToPage'; import { Link } from '@tanstack/react-router'; import { ComponentType, Fragment, MouseEventHandler, ReactNode } from 'react'; @@ -23,8 +24,7 @@ export type EntityMenuItem = key: string; label: ReactNode; icon?: ReactNode; - /** Router path; renders the item inside a Link. Absolute when the target is a different entity. */ - to?: string; + to?: AbsolutePath; onClick?: MouseEventHandler; disabled?: boolean; className?: string; diff --git a/src/features/clusters/components/ClusterCard.tsx b/src/features/clusters/components/ClusterCard.tsx index d3e283634..9c10485be 100644 --- a/src/features/clusters/components/ClusterCard.tsx +++ b/src/features/clusters/components/ClusterCard.tsx @@ -26,6 +26,7 @@ import { clusterIsSelfManaged } from '@/integrations/api/clusterIsSelfManaged'; import { onInstanceLogoutSubmit } from '@/integrations/api/instance/auth/onInstanceLogoutSubmit'; import { excludeFalsy } from '@/lib/arrays/excludeFalsy'; import { LocalStorageKeys } from '@/lib/storage/localStorageKeys'; +import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage'; import { getOperationsUrlForCluster } from '@/lib/urls/getOperationsUrlForCluster'; import { useQueryClient } from '@tanstack/react-query'; import { Link, useRouter } from '@tanstack/react-router'; @@ -51,6 +52,7 @@ import { toast } from 'sonner'; export function ClusterCard({ item: summary }: { item: ClusterListItem }) { const { cluster } = summary; + const clusterIds = { organizationId: cluster.organizationId, clusterId: cluster.id }; const syncLabel = describeSyncSummary(summary.syncSummary); const router = useRouter(); const queryClient = useQueryClient(); @@ -205,7 +207,7 @@ export function ClusterCard({ item: summary }: { item: ClusterListItem }) { { type: 'separator' as const, key: 'label-separator' }, isActive && update && !auth.isLoading && (!isDirectConnect || isFabricConnect) && { key: 'sign-in', - to: `${cluster.id}/sign-in`, + to: buildAbsoluteLinkToPage(clusterIds, 'sign-in'), disabled: signingOut, icon: , label: 'Direct Sign In', @@ -218,35 +220,35 @@ export function ClusterCard({ item: summary }: { item: ClusterListItem }) { }, isActive && update && { key: 'edit', - to: `${cluster.id}/edit`, + to: buildAbsoluteLinkToPage(clusterIds, 'edit'), disabled: signingOut, icon: , label: isSelfManaged ? 'Edit' : 'Edit Scaling', }, canEditVersion && { key: 'edit-version', - to: `${cluster.id}/edit/version`, + to: buildAbsoluteLinkToPage(clusterIds, 'edit/version'), disabled: signingOut, icon: , label: 'Edit Version', }, isActive && update && !isLocalStudio && !clusterIsSelfManaged(cluster) && { key: 'domains', - to: `${cluster.id}/domains`, + to: buildAbsoluteLinkToPage(clusterIds, 'domains'), disabled: signingOut, icon: , label: 'Domains', }, isActive && view && { key: 'instances', - to: `${cluster.id}/instances`, + to: buildAbsoluteLinkToPage(clusterIds, 'instances'), disabled: signingOut, icon: , label: 'Instances', }, isActive && view && !!auth.user && { key: 'deployments', - to: `${cluster.id}/config/deployments`, + to: buildAbsoluteLinkToPage(clusterIds, 'config/deployments'), disabled: signingOut, icon: , label: 'Deployments', diff --git a/src/features/clusters/components/clusterCardLinkLocationIndependence.test.tsx b/src/features/clusters/components/clusterCardLinkLocationIndependence.test.tsx new file mode 100644 index 000000000..86b1af0d1 --- /dev/null +++ b/src/features/clusters/components/clusterCardLinkLocationIndependence.test.tsx @@ -0,0 +1,144 @@ +/** @vitest-environment jsdom */ +import { describeCluster } from '@/features/clusters/lib/clusterListModel'; +import type { Cluster } from '@/integrations/api/api.patch'; +import { rootRouteTree } from '@/router/rootRouteTree'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/hooks/usePermissions', () => ({ + useOrganizationClusterPermissions: () => ({ view: true, create: true, update: true, remove: true }), + useContainerOpsPermission: () => false, +})); +vi.mock('@/hooks/useAuth', () => ({ useInstanceAuth: () => ({ user: { username: 'admin' }, isLoading: false }) })); +vi.mock('@/config/useInstanceClient', () => ({ useInstanceClient: () => ({}) })); +vi.mock('@/features/auth/store/authStore', () => ({ authStore: { checkForFabricConnect: () => true } })); +vi.mock('@/hooks/useLocalStorage', () => ({ useLocalStorage: () => [null, () => {}] })); +vi.mock('@/hooks/useCopyToClipboard', () => ({ useCopyToClipboard: () => [() => {}, () => {}] })); +vi.mock( + '@/hooks/useClusterContainerOps', + () => ({ useClusterContainerOps: () => ({ run: () => {}, isPending: false }) }), +); +vi.mock( + '@/features/clusters/mutations/terminateCluster', + () => ({ useTerminateClusterMutation: () => ({ mutate: () => {}, isPending: false }) }), +); +vi.mock('@/features/clusters/components/ClusterProgress', () => ({ ClusterProgress: () => null })); + +import { ClusterCard } from './ClusterCard'; + +const CLUSTER_ROUTE = '/_dashboardLayout/_orgsLayout/$organizationId/_clusterLayout/$clusterId'; +const MENU_TARGETS = { + 'Direct Sign In': ['/org-a/clu-c/sign-in', `${CLUSTER_ROUTE}/sign-in`, {}], + 'Edit Scaling': ['/org-a/clu-c/edit', `${CLUSTER_ROUTE}/edit`, {}], + 'Edit Version': ['/org-a/clu-c/edit/version', `${CLUSTER_ROUTE}/edit/$mode`, { mode: 'version' }], + 'Domains': ['/org-a/clu-c/domains', `${CLUSTER_ROUTE}/domains`, {}], + 'Instances': ['/org-a/clu-c/instances', `${CLUSTER_ROUTE}/instances`, {}], + 'Deployments': ['/org-a/clu-c/config/deployments', `${CLUSTER_ROUTE}/_instanceLayout/config/deployments`, {}], +} as const; +const MENU_HREFS = Object.fromEntries(Object.entries(MENU_TARGETS).map(([label, [href]]) => [label, href])); + +const cluster: Cluster = { + id: 'clu-c', + name: 'Production', + organizationId: 'org-a', + status: 'RUNNING', + fqdn: 'api.example.test', + plans: [{ planId: 'shared', region: 'US East' }], +}; + +function Card() { + return ; +} + +function renderCardAt(path: string) { + const rootRoute = createRootRoute({ component: Outlet }); + const routeTree = rootRoute.addChildren( + [ + '$organizationId', + '$organizationId/$clusterId', + '$organizationId/$clusterId/instances', + '$organizationId/$clusterId/edit', + ].map(routePath => createRoute({ getParentRoute: () => rootRoute, path: routePath, component: Card })), + ); + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [path] }), + trailingSlash: 'never', + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return router; +} + +async function openMenu() { + fireEvent.pointerDown(await screen.findByRole('button', { name: 'Cluster options' }), { button: 0, ctrlKey: false }); + await waitFor(() => expect(screen.getAllByRole('menuitem').length).toBeGreaterThan(0)); +} + +function menuHrefs() { + return Object.fromEntries( + screen.getAllByRole('menuitem') + .map(item => [item.textContent, item.closest('a')?.getAttribute('href')] as const) + .filter(([, href]) => href !== undefined), + ); +} + +beforeAll(() => { + Object.assign(Element.prototype, { + hasPointerCapture: () => false, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + scrollIntoView: () => {}, + }); + window.PointerEvent = class extends MouseEvent {} as typeof PointerEvent; +}); +afterEach(cleanup); + +describe('cluster card menu links', () => { + it.each([ + ['its own cluster list', '/org-a'], + ['a sibling cluster the router is moving into', '/org-a/clu-b'], + ['a page of an unrelated org and cluster', '/org-z/clu-z/instances'], + ])("resolve from the card's cluster alone while the router is at %s", async (_where, path) => { + renderCardAt(path); + await openMenu(); + + expect(menuHrefs()).toEqual(MENU_HREFS); + }); + + it('navigate to the target when a menu item is clicked', async () => { + const router = renderCardAt('/org-a/clu-b'); + await openMenu(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Edit Scaling' })); + + await waitFor(() => expect(router.state.location.pathname).toBe('/org-a/clu-c/edit')); + }); +}); + +describe('the asserted cluster card hrefs against the production route tree', () => { + it.each(Object.values(MENU_TARGETS))('%s matches %s', (href, routeId, extraParams) => { + const router = createRouter({ + routeTree: rootRouteTree, + history: createMemoryHistory({ initialEntries: [href] }), + trailingSlash: 'never', + context: { queryClient: new QueryClient(), authentication: {} }, + }); + const leaf = router.matchRoutes(router.state.location).at(-1)!; + + expect(leaf.routeId).toBe(routeId); + expect(leaf.params).toEqual({ organizationId: 'org-a', clusterId: 'clu-c', ...extraParams }); + }); +}); diff --git a/src/features/organizations/components/OrgCard.tsx b/src/features/organizations/components/OrgCard.tsx index 36621aaf8..f0b251c6d 100644 --- a/src/features/organizations/components/OrgCard.tsx +++ b/src/features/organizations/components/OrgCard.tsx @@ -8,6 +8,7 @@ import { useCopyToClipboard } from '@/hooks/useCopyToClipboard'; import { useOrganizationPermissions, useOrganizationRolePermissions } from '@/hooks/usePermissions'; import { excludeFalsy } from '@/lib/arrays/excludeFalsy'; import { capitalizeWords } from '@/lib/string/capitalizeWords'; +import { buildAbsoluteLinkToPage } from '@/lib/urls/buildAbsoluteLinkToPage'; import { Link } from '@tanstack/react-router'; import { ArrowRight, @@ -33,6 +34,8 @@ export function OrgCard({ }) { const { organizationId, organizationName, roleName } = organizationRole; const name = organizationName || organizationId; + const orgIds = { organizationId }; + const orgHref = buildAbsoluteLinkToPage(orgIds); const [copyId] = useCopyToClipboard(organizationId); const { remove, update: canUpdateOrganization } = useOrganizationPermissions(organizationId); const showBilling = canUpdateOrganization; @@ -50,31 +53,31 @@ export function OrgCard({ { type: 'separator' as const, key: 'sep-top' }, { key: 'clusters', - to: `${organizationId}`, + to: orgHref, icon: , label: 'Clusters', }, showOrgUsersAndRoles && { key: 'roles', - to: `${organizationId}/roles`, + to: buildAbsoluteLinkToPage(orgIds, 'roles'), icon: , label: 'Roles', }, showOrgUsersAndRoles && { key: 'users', - to: `${organizationId}/users`, + to: buildAbsoluteLinkToPage(orgIds, 'users'), icon: , label: 'Users', }, showBilling && { key: 'billing', - to: `${organizationId}/billing`, + to: buildAbsoluteLinkToPage(orgIds, 'billing'), icon: , label: 'Billing', }, canUpdateOrganization && { key: 'settings', - to: `${organizationId}/settings`, + to: buildAbsoluteLinkToPage(orgIds, 'settings'), icon: , label: 'Settings', }, @@ -102,7 +105,7 @@ export function OrgCard({ diff --git a/src/features/organizations/components/orgCardLinkLocationIndependence.test.tsx b/src/features/organizations/components/orgCardLinkLocationIndependence.test.tsx new file mode 100644 index 000000000..6d2cdea27 --- /dev/null +++ b/src/features/organizations/components/orgCardLinkLocationIndependence.test.tsx @@ -0,0 +1,136 @@ +/** @vitest-environment jsdom */ +import { rootRouteTree } from '@/router/rootRouteTree'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, +} from '@tanstack/react-router'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/hooks/useAuth', () => ({ useStaffPermission: () => true })); +vi.mock('@/hooks/usePermissions', () => ({ + useOrganizationPermissions: () => ({ update: true, remove: true }), + useOrganizationRolePermissions: () => ({ view: true }), +})); +vi.mock('@/hooks/useCopyToClipboard', () => ({ useCopyToClipboard: () => [() => {}] })); +vi.mock('@/features/organization/modals/AddCouponModal', () => ({ AddCouponModal: () => null })); + +import { OrgCard } from './OrgCard'; + +const ORG_ROUTE = '/_dashboardLayout/_orgsLayout/$organizationId'; +const CARD_HREF = '/org-a'; +const MENU_TARGETS = { + Clusters: ['/org-a', `${ORG_ROUTE}/_clusterLayout/`], + Roles: ['/org-a/roles', `${ORG_ROUTE}/roles`], + Users: ['/org-a/users', `${ORG_ROUTE}/users`], + Billing: ['/org-a/billing', `${ORG_ROUTE}/billing/`], + Settings: ['/org-a/settings', `${ORG_ROUTE}/settings`], +} as const; +const MENU_HREFS = Object.fromEntries(Object.entries(MENU_TARGETS).map(([label, [href]]) => [label, href])); + +function Card() { + return ( + {}} + /> + ); +} + +function renderCardAt(path: string) { + const rootRoute = createRootRoute({ component: Outlet }); + const routeTree = rootRoute.addChildren( + ['/', '$organizationId', '$organizationId/roles'].map(routePath => + createRoute({ getParentRoute: () => rootRoute, path: routePath, component: Card }) + ), + ); + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [path] }), + trailingSlash: 'never', + }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + return router; +} + +async function openMenu() { + fireEvent.pointerDown(await screen.findByRole('button', { name: 'Options' }), { button: 0, ctrlKey: false }); + await waitFor(() => expect(screen.getAllByRole('menuitem').length).toBeGreaterThan(0)); +} + +beforeAll(() => { + Object.assign(Element.prototype, { + hasPointerCapture: () => false, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + scrollIntoView: () => {}, + }); + window.PointerEvent = class extends MouseEvent {} as typeof PointerEvent; +}); +afterEach(cleanup); + +describe.each([ + ['its own org list', '/'], + ['a sibling org the router is moving into', '/org-b'], + ['a page of a sibling org', '/org-b/roles'], +])('org card links while the router is at %s', (_where, path) => { + it("open the card's org from the card itself", async () => { + renderCardAt(path); + + expect((await screen.findByRole('link', { name: 'Open Acme' })).getAttribute('href')).toBe(CARD_HREF); + }); + + it("resolve every menu target from the card's org alone", async () => { + renderCardAt(path); + await openMenu(); + const hrefs = Object.fromEntries( + screen.getAllByRole('menuitem') + .map(item => [item.textContent, item.closest('a')?.getAttribute('href')] as const) + .filter(([, href]) => href !== undefined), + ); + + expect(hrefs).toEqual(MENU_HREFS); + }); +}); + +describe('org card navigation', () => { + it("lands on the card's org when the card is clicked", async () => { + const router = renderCardAt('/org-b'); + fireEvent.click(await screen.findByRole('link', { name: 'Open Acme' })); + + await waitFor(() => expect(router.state.location.pathname).toBe(CARD_HREF)); + }); + + it('lands on the target when a menu item is clicked', async () => { + const router = renderCardAt('/org-b'); + await openMenu(); + fireEvent.click(screen.getByRole('menuitem', { name: 'Roles' })); + + await waitFor(() => expect(router.state.location.pathname).toBe('/org-a/roles')); + }); +}); + +describe('the asserted org card hrefs against the production route tree', () => { + it.each(Object.values(MENU_TARGETS))('%s matches %s', (href, routeId) => { + const router = createRouter({ + routeTree: rootRouteTree, + history: createMemoryHistory({ initialEntries: [href] }), + trailingSlash: 'never', + context: { queryClient: new QueryClient(), authentication: {} }, + }); + const leaf = router.matchRoutes(router.state.location).at(-1)!; + + expect(leaf.routeId).toBe(routeId); + expect(leaf.params).toEqual({ organizationId: 'org-a' }); + }); +}); diff --git a/src/lib/urls/buildAbsoluteLinkToPage.test.ts b/src/lib/urls/buildAbsoluteLinkToPage.test.ts new file mode 100644 index 000000000..8c18b0f9b --- /dev/null +++ b/src/lib/urls/buildAbsoluteLinkToPage.test.ts @@ -0,0 +1,30 @@ +import type { EntityMenuItem } from '@/components/ui/entityMenu'; +import { describe, expect, expectTypeOf, it } from 'vitest'; +import { type AbsolutePath, buildAbsoluteLinkToPage } from './buildAbsoluteLinkToPage'; + +describe('buildAbsoluteLinkToPage', () => { + it.each([ + [{}, undefined, '/'], + [{ organizationId: 'org-a' }, undefined, '/org-a'], + [{ organizationId: 'org-a' }, 'roles', '/org-a/roles'], + [{ organizationId: 'org-a', clusterId: 'clu-b' }, 'edit/version', '/org-a/clu-b/edit/version'], + [ + { organizationId: 'org-a', clusterId: 'clu-b', instanceId: 'ins-c' }, + 'sign-in', + '/org-a/clu-b/instance/ins-c/sign-in', + ], + ])('builds %o + %s as %s', (ids, page, expected) => { + expect(buildAbsoluteLinkToPage(ids, page)).toBe(expected); + }); + + it('is typed as an absolute path, the only target an entity menu item accepts', () => { + expectTypeOf(buildAbsoluteLinkToPage({ organizationId: 'org-a' })).toEqualTypeOf(); + const items: EntityMenuItem[] = [ + { key: 'absolute', label: 'Roles', to: buildAbsoluteLinkToPage({ organizationId: 'org-a' }, 'roles') }, + // @ts-expect-error a relative target re-resolves against wherever the router is when the menu renders + { key: 'relative', label: 'Roles', to: 'org-a/roles' }, + ]; + + expect(items).toHaveLength(2); + }); +}); diff --git a/src/lib/urls/buildAbsoluteLinkToPage.ts b/src/lib/urls/buildAbsoluteLinkToPage.ts index 359270518..7afc87c0d 100644 --- a/src/lib/urls/buildAbsoluteLinkToPage.ts +++ b/src/lib/urls/buildAbsoluteLinkToPage.ts @@ -1,5 +1,7 @@ import { excludeFalsy } from '@/lib/arrays/excludeFalsy'; +export type AbsolutePath = `/${string}`; + export function buildAbsoluteLinkToPage({ clusterId, instanceId, @@ -8,12 +10,7 @@ export function buildAbsoluteLinkToPage({ clusterId?: string; instanceId?: string; organizationId?: string; -}, page?: string): string { - return '/' + [ - organizationId, - clusterId, - instanceId && 'instance', - instanceId, - page, - ].filter(excludeFalsy).join('/'); +}, page?: string): AbsolutePath { + const segments = [organizationId, clusterId, instanceId && 'instance', instanceId, page].filter(excludeFalsy); + return `/${segments.join('/')}`; }