Skip to content
Draft
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
3 changes: 2 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<form>` `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`.
Expand Down
4 changes: 2 additions & 2 deletions src/components/ui/entityMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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<HTMLDivElement>;
disabled?: boolean;
className?: string;
Expand Down
14 changes: 8 additions & 6 deletions src/features/clusters/components/ClusterCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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: <KeyIcon className="text-green" />,
label: 'Direct Sign In',
Expand All @@ -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: <ScaleIcon className="text-purple-600" />,
label: isSelfManaged ? 'Edit' : 'Edit Scaling',
},
canEditVersion && {
key: 'edit-version',
to: `${cluster.id}/edit/version`,
to: buildAbsoluteLinkToPage(clusterIds, 'edit/version'),
disabled: signingOut,
icon: <GitGraphIcon className="text-fuchsia-300" />,
label: 'Edit Version',
},
isActive && update && !isLocalStudio && !clusterIsSelfManaged(cluster) && {
key: 'domains',
to: `${cluster.id}/domains`,
to: buildAbsoluteLinkToPage(clusterIds, 'domains'),
disabled: signingOut,
icon: <GlobeIcon className="text-cyan-400" />,
label: 'Domains',
},
isActive && view && {
key: 'instances',
to: `${cluster.id}/instances`,
to: buildAbsoluteLinkToPage(clusterIds, 'instances'),
disabled: signingOut,
icon: <ServerIcon className="text-orange-300" />,
label: 'Instances',
},
isActive && view && !!auth.user && {
key: 'deployments',
to: `${cluster.id}/config/deployments`,
to: buildAbsoluteLinkToPage(clusterIds, 'config/deployments'),
disabled: signingOut,
icon: <RocketIcon className="text-sky-400" />,
label: 'Deployments',
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <ClusterCard item={describeCluster(cluster, undefined, undefined)} />;
}

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(
<QueryClientProvider client={client}>
<RouterProvider router={router as never} />
</QueryClientProvider>,
);
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 });
});
});
15 changes: 9 additions & 6 deletions src/features/organizations/components/OrgCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -50,31 +53,31 @@ export function OrgCard({
{ type: 'separator' as const, key: 'sep-top' },
{
key: 'clusters',
to: `${organizationId}`,
to: orgHref,
icon: <ServerIcon className="size-4 mr-2 text-blue-500" />,
label: 'Clusters',
},
showOrgUsersAndRoles && {
key: 'roles',
to: `${organizationId}/roles`,
to: buildAbsoluteLinkToPage(orgIds, 'roles'),
icon: <ShieldCheckIcon className="size-4 mr-2 text-purple" />,
label: 'Roles',
},
showOrgUsersAndRoles && {
key: 'users',
to: `${organizationId}/users`,
to: buildAbsoluteLinkToPage(orgIds, 'users'),
icon: <UsersIcon className="size-4 mr-2 text-orange-500" />,
label: 'Users',
},
showBilling && {
key: 'billing',
to: `${organizationId}/billing`,
to: buildAbsoluteLinkToPage(orgIds, 'billing'),
icon: <CreditCardIcon className="size-4 mr-2 text-green-500" />,
label: 'Billing',
},
canUpdateOrganization && {
key: 'settings',
to: `${organizationId}/settings`,
to: buildAbsoluteLinkToPage(orgIds, 'settings'),
icon: <KeyRoundIcon className="size-4 mr-2 text-yellow-500" />,
label: 'Settings',
},
Expand Down Expand Up @@ -102,7 +105,7 @@ export function OrgCard({
<EntityContextMenu items={showMenu ? menuItems : []}>
<Card className="group relative gap-0 py-0 h-full cursor-pointer justify-between overflow-hidden border border-primary/15 transition-[border-color,box-shadow] duration-200 hover:border-primary/60 hover:shadow-lg hover:ring-2 hover:ring-primary/40 dark:hover:ring-violet-400/60">
<Link
to={organizationId}
to={orgHref}
aria-label={`Open ${name}`}
className="absolute inset-0 z-10 cursor-pointer rounded-[inherit] focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring focus-visible:outline-none"
/>
Expand Down
Loading
Loading