diff --git a/DESIGN.md b/DESIGN.md index c59b4dbe4..6bf071c25 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -64,7 +64,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`. - **Remembered instance/cluster sign-ins are checked on demand**: `Studio:PotentiallyAuthenticated` spans every organization ever visited, so the root auth subscription checks only the app sign-in; an entity is checked by `useInstanceAuth`/`listenToEntity` or the instance-layout guard (`entityAuthCheckScope.test.ts`, HarperFast/studio#1329). 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/cardLinksDuringPendingNavigation.test.tsx b/src/features/organizations/cardLinksDuringPendingNavigation.test.tsx new file mode 100644 index 000000000..cd2bf4d74 --- /dev/null +++ b/src/features/organizations/cardLinksDuringPendingNavigation.test.tsx @@ -0,0 +1,157 @@ +/** @vitest-environment jsdom */ +import { describeCluster } from '@/features/clusters/lib/clusterListModel'; +import type { Cluster } from '@/integrations/api/api.patch'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + type AnyRoute, + 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', () => ({ + useOrganizationPermissions: () => ({ remove: true, update: true }), + useOrganizationRolePermissions: () => ({ view: true }), + useOrganizationClusterPermissions: () => ({ view: true, create: true, update: true, remove: true }), + useContainerOpsPermission: () => false, +})); +vi.mock('@/hooks/useAuth', () => ({ + useStaffPermission: () => false, + useInstanceAuth: () => ({ user: null, isLoading: false }), +})); +vi.mock('@/config/useInstanceClient', () => ({ useInstanceClient: () => ({}) })); +vi.mock('@/features/auth/store/authStore', () => ({ authStore: { checkForFabricConnect: () => false } })); +vi.mock('@/hooks/useLocalStorage', () => ({ useLocalStorage: () => [null, vi.fn()] })); +vi.mock( + '@/hooks/useClusterContainerOps', + () => ({ useClusterContainerOps: () => ({ run: vi.fn(), isPending: false }) }), +); +vi.mock( + '@/features/clusters/mutations/terminateCluster', + () => ({ useTerminateClusterMutation: () => ({ mutate: vi.fn(), isPending: false }) }), +); +vi.mock('@/features/clusters/components/ClusterProgress', () => ({ ClusterProgress: () => null })); + +import { ClusterCard } from '@/features/clusters/components/ClusterCard'; +import { OrgCard } from './components/OrgCard'; + +const cluster: Cluster = { + id: 'clu-b', + name: 'Production', + organizationId: 'org-a', + status: 'RUNNING', + fqdn: 'api.example.test', + plans: [{ planId: 'shared', region: 'US East' }], +}; + +function Organizations() { + return ( + undefined} + /> + ); +} + +function Clusters() { + return ; +} + +/** Every page past the one on screen waits on a request that has not answered: the slow network in #1301. */ +function renderRouter(initialPath: string) { + const rootRoute = createRootRoute({ component: Outlet }); + const waitForever = () => new Promise(() => undefined); + const organizationRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '$organizationId', + component: Outlet, + }); + const clusterRoute = createRoute({ + getParentRoute: () => organizationRoute, + path: '$clusterId', + beforeLoad: waitForever, + component: Outlet, + }); + const leaf = (parent: AnyRoute, path: string) => + createRoute({ getParentRoute: () => parent, path, component: Outlet }); + const routeTree = rootRoute.addChildren([ + createRoute({ getParentRoute: () => rootRoute, path: '/', component: Organizations }), + organizationRoute.addChildren([ + createRoute({ + getParentRoute: () => organizationRoute, + path: '/', + beforeLoad: initialPath === '/' ? waitForever : undefined, + component: Clusters, + }), + leaf(organizationRoute, 'roles'), + clusterRoute.addChildren([leaf(clusterRoute, 'sign-in')]), + ]), + ]); + const router = createRouter({ routeTree, history: createMemoryHistory({ initialEntries: [initialPath] }) }); + render( + + + , + ); + return router; +} + +async function openOptions(name: string) { + const trigger = await screen.findByRole('button', { name }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false }); +} + +beforeAll(() => { + Object.assign(Element.prototype, { + hasPointerCapture: () => false, + setPointerCapture: () => {}, + releasePointerCapture: () => {}, + scrollIntoView: () => {}, + }); + window.PointerEvent = class extends MouseEvent {} as typeof PointerEvent; +}); + +afterEach(cleanup); + +describe('a card clicked again while its first navigation is still loading (#1301)', () => { + it('opens the same organization, not the organization nested under itself', async () => { + const router = renderRouter('/'); + const card = await screen.findByRole('link', { name: 'Open Acme' }); + + fireEvent.click(card); + await waitFor(() => expect(router.latestLocation.pathname).toBe('/org-a')); + // Still the organizations page: the organization has not finished loading. + fireEvent.click(screen.getByRole('link', { name: 'Open Acme' })); + + expect(router.latestLocation.pathname).toBe('/org-a'); + }); + + it('links every organization menu item to that organization, from the pending location', async () => { + const router = renderRouter('/'); + fireEvent.click(await screen.findByRole('link', { name: 'Open Acme' })); + await waitFor(() => expect(router.latestLocation.pathname).toBe('/org-a')); + + await openOptions('Options'); + + const hrefs = screen.getAllByRole('menuitem').map(item => item.closest('a')?.getAttribute('href')).filter(Boolean); + expect(hrefs).toEqual(['/org-a', '/org-a/roles', '/org-a/users', '/org-a/billing', '/org-a/settings']); + }); + + it('signs in to the same cluster, not a sign-in page nested under the first', async () => { + const router = renderRouter('/org-a'); + await openOptions('Cluster options'); + fireEvent.click(await screen.findByRole('menuitem', { name: 'Direct Sign In' })); + await waitFor(() => expect(router.latestLocation.pathname).toBe('/org-a/clu-b/sign-in')); + + // Still the cluster list: the cluster has not finished loading. + await openOptions('Cluster options'); + fireEvent.click(await screen.findByRole('menuitem', { name: 'Direct Sign In' })); + + expect(router.latestLocation.pathname).toBe('/org-a/clu-b/sign-in'); + }); +}); 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('/')}`; }