Track HiveGrid Cloud planning reference

This commit is contained in:
david committed 2026-06-26 20:48:01 +02:00
1 parent b3d5ea6764
commit e341c5938a
5784 files changed
+1104929 -3

No files matched your search

@@ -0,0 +1,156 @@
import "@testing-library/jest-dom";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { UI_SURFACE_IDS } from "~/application/ui/uiRegistry";
import type { ScopedCommandAction } from "~/services/command-registry/commandRegistry";
import CommandPalette from "./CommandPalette";
type CommandPaletteTestState = {
navigate: ReturnType<typeof vi.fn>;
getCommandRegistryActions: ReturnType<typeof vi.fn>;
useAppOrAdminData: ReturnType<typeof vi.fn>;
useRootData: ReturnType<typeof vi.fn>;
};
declare global {
var __commandPaletteTest: CommandPaletteTestState;
}
globalThis.__commandPaletteTest = {
navigate: vi.fn(),
getCommandRegistryActions: vi.fn(),
useAppOrAdminData: vi.fn(),
useRootData: vi.fn(),
};
globalThis.ResizeObserver =
globalThis.ResizeObserver ??
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
};
HTMLElement.prototype.scrollIntoView = HTMLElement.prototype.scrollIntoView ?? vi.fn();
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (value: string) => value }),
}));
vi.mock("react-router", async () => {
const actual = await vi.importActual<typeof import("react-router")>("react-router");
return {
...actual,
useFetcher: () => ({ submit: vi.fn() }),
useNavigate: () => globalThis.__commandPaletteTest.navigate,
};
});
vi.mock("~/utils/data/useAppOrAdminData", () => ({
useAppOrAdminData: () => globalThis.__commandPaletteTest.useAppOrAdminData(),
}));
vi.mock("~/utils/data/useRootData", () => ({
useRootData: () => globalThis.__commandPaletteTest.useRootData(),
}));
vi.mock("~/services/command-registry/commandRegistry", () => ({
getCommandRegistryActions: (...args: unknown[]) => globalThis.__commandPaletteTest.getCommandRegistryActions(...args),
}));
const appData = { currentTenant: { slug: "acme" } };
const rootData = { appConfiguration: { app: { features: {} } } };
function renderPalette(props?: Partial<React.ComponentProps<typeof CommandPalette>>) {
return render(
<CommandPalette layout="app" {...props}>
<div>Palette child</div>
</CommandPalette>
);
}
describe("CommandPalette", () => {
// CommandPaletteDialog wird in der Komponente per React.lazy(() => import("./CommandPaletteDialog"))
// geladen. In der vollen Suite kann der KALTE erste dynamische Import unter CPU-Last die
// 1000ms-Default-Frist von findByText reissen (erster Test rot, danach Modul warm -> gruen).
// Vorwaermen macht den ersten Oeffnen-Pfad deterministisch synchron.
beforeAll(async () => {
await import("./CommandPaletteDialog");
});
beforeEach(() => {
const state = globalThis.__commandPaletteTest;
state.navigate.mockReset();
state.getCommandRegistryActions.mockReset();
state.useAppOrAdminData.mockReset();
state.useRootData.mockReset();
state.useAppOrAdminData.mockReturnValue(appData);
state.useRootData.mockReturnValue(rootData);
state.getCommandRegistryActions.mockReturnValue([
{ id: "registry-dashboard", name: "Dashboard", subtitle: "App", perform: vi.fn(), scope: { layout: "app", tenantSlug: "acme" } },
{ id: "registry-denied-looking", name: "Denied Looking", subtitle: "Filtered upstream", perform: vi.fn(), scope: { layout: "app", tenantSlug: "acme", permission: "entity.contacts.view" } },
]);
});
function openPalette() {
fireEvent.keyDown(document, { key: "k", metaKey: true });
}
it("passes the filtered registry-backed actions through for the active layout context", async () => {
renderPalette();
const state = globalThis.__commandPaletteTest;
expect(state.getCommandRegistryActions).not.toHaveBeenCalled();
openPalette();
await screen.findByText("Dashboard", undefined, { timeout: 4000 });
expect(state.getCommandRegistryActions).toHaveBeenCalledWith(expect.objectContaining({
layout: "app",
t: expect.any(Function),
navigate: state.navigate,
appData,
rootData,
}));
expect(screen.getByText("Denied Looking")).toBeInTheDocument();
expect(screen.getByText("Palette child")).toBeInTheDocument();
});
it("uses a custom actions prop instead of registry actions", async () => {
const customActions: ScopedCommandAction[] = [{ id: "custom-action", name: "Custom Action", perform: vi.fn(), scope: { layout: "app" } }];
renderPalette({ actions: customActions });
const state = globalThis.__commandPaletteTest;
expect(state.getCommandRegistryActions).not.toHaveBeenCalled();
openPalette();
expect(await screen.findByText("Custom Action")).toBeInTheDocument();
expect(state.getCommandRegistryActions).not.toHaveBeenCalled();
});
it("renders a safe empty path when app/root data is missing", async () => {
const state = globalThis.__commandPaletteTest;
state.useAppOrAdminData.mockReturnValue(undefined);
state.useRootData.mockReturnValue(undefined);
state.getCommandRegistryActions.mockReturnValue([]);
renderPalette();
openPalette();
await waitFor(() => {
expect(state.getCommandRegistryActions).toHaveBeenCalledWith(expect.objectContaining({ appData: undefined, rootData: undefined }));
});
expect(screen.getByText("Palette child")).toBeInTheDocument();
});
it("renders stable data-ui-id attributes for command palette surfaces and rows", async () => {
renderPalette();
openPalette();
await screen.findByText("Dashboard", undefined, { timeout: 4000 });
expect(document.querySelector(`[data-ui-id="${UI_SURFACE_IDS.commandPalettePortal}"]`)).toBeInTheDocument();
expect(document.querySelector(`[data-ui-id="${UI_SURFACE_IDS.commandPaletteSearch}"]`)).toBeInTheDocument();
expect(document.querySelector(`[data-ui-id="${UI_SURFACE_IDS.commandPaletteResults}"]`)).toBeInTheDocument();
expect(document.querySelectorAll(`[data-ui-id="${UI_SURFACE_IDS.commandPaletteRow}"]`)).toHaveLength(2);
expect(screen.getByText("Dashboard")).toBeInTheDocument();
expect(screen.getByText("Denied Looking")).toBeInTheDocument();
});
});
@@ -0,0 +1,58 @@
import { createContext, lazy, ReactNode, Suspense, useContext, useEffect, useMemo, useState } from "react";
import type { ScopedCommandAction } from "~/services/command-registry/commandRegistry";
type CommandPaletteContextValue = {
open: boolean;
setOpen: (open: boolean) => void;
toggle: () => void;
};
const CommandPaletteContext = createContext<CommandPaletteContextValue | null>(null);
const CommandPaletteDialog = lazy(() => import("./CommandPaletteDialog"));
export function useCommandPalette() {
const context = useContext(CommandPaletteContext);
return context ?? { open: false, setOpen: () => undefined, toggle: () => undefined };
}
interface Props {
layout?: "app" | "admin" | "docs";
children: ReactNode;
actions?: ScopedCommandAction[];
}
export default function CommandPalette({ layout, children, actions }: Props) {
const [open, setOpen] = useState(false);
const contextValue = useMemo(
() => ({
open,
setOpen,
toggle: () => setOpen((value) => !value),
}),
[open]
);
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setOpen((value) => !value);
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, []);
return (
<CommandPaletteContext.Provider value={contextValue}>
{open ? (
<Suspense fallback={null}>
<CommandPaletteDialog actions={actions} layout={layout} open={open} setOpen={setOpen} />
</Suspense>
) : null}
{children}
</CommandPaletteContext.Provider>
);
}
@@ -0,0 +1,88 @@
import { useTranslation } from "react-i18next";
import { useFetcher, useNavigate } from "react-router";
import { UI_SURFACE_IDS } from "~/application/ui/uiRegistry";
import { CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
import { getCommandRegistryActions, type CommandRegistryLayout, type ScopedCommandAction } from "~/services/command-registry/commandRegistry";
import type { AppLoaderData } from "~/utils/data/useAppData";
import { useAppOrAdminData } from "~/utils/data/useAppOrAdminData";
import { useRootData } from "~/utils/data/useRootData";
export default function CommandPaletteDialog({
actions,
layout,
open,
setOpen,
}: {
actions?: ScopedCommandAction[];
layout?: CommandRegistryLayout;
open: boolean;
setOpen: (open: boolean) => void;
}) {
const { t, i18n } = useTranslation();
const navigate = useNavigate();
const fetcher = useFetcher();
const appData = useAppOrAdminData() as AppLoaderData | undefined;
const rootData = useRootData();
const resolvedActions =
actions ??
(layout
? getCommandRegistryActions({
layout,
t,
i18n,
navigate,
submitLogout: () => {
const form = new FormData();
if (rootData?.csrf) {
form.set("csrf", rootData.csrf);
}
fetcher.submit(form, { method: "post", action: "/logout" });
},
appData,
rootData,
})
: []);
return (
<div>
<CommandDialog
open={open}
onOpenChange={setOpen}
dataUiId={UI_SURFACE_IDS.commandPalettePortal}
onCloseAutoFocus={(event) => {
// The palette opens via controlled state, not a Radix trigger, so Radix
// drops focus to <body> on close. Return focus to the search trigger.
const trigger = typeof document !== "undefined" ? document.querySelector<HTMLElement>('[data-ui-id="ui.action.shell.search"]') : null;
if (trigger) {
event.preventDefault();
trigger.focus();
}
}}
>
<CommandInput data-ui-id={UI_SURFACE_IDS.commandPaletteSearch} />
<CommandList data-ui-id={UI_SURFACE_IDS.commandPaletteResults}>
<CommandEmpty>{t("app.commands.empty.title")}</CommandEmpty>
<CommandGroup>
{resolvedActions.map((action) => (
<CommandItem
key={action.id}
data-ui-id={UI_SURFACE_IDS.commandPaletteRow}
value={[action.name, action.subtitle, action.keywords, action.id].filter(Boolean).join(" ")}
onSelect={() => {
action.perform?.(action);
setOpen(false);
}}
>
<div className="flex w-full items-center justify-between gap-2">
<div className="text-text-neutral-primary/80 text-sm font-medium">{action.name}</div>
{action.subtitle ? <div className="text-text-neutral-tertiary truncate text-xs">{action.subtitle}</div> : null}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
</div>
);
}