Track HiveGrid Cloud planning reference
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user