feat(housekeeping): add global command deck search
CI / check (pull_request) Successful in 38s
CI / release (pull_request) Skipped
CI / deploy (pull_request) Skipped

This commit is contained in:
Simo committed 2026-08-30 15:59:48 +02:00
1 parent 2e95a106e0
commit bcb0ca977f
18 files changed
+1353 -23

No files matched your search

+54
View File
@@ -0,0 +1,54 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { HousekeepingCapabilityContext } from "@/features/housekeeping/foundation/contracts";
import { PERMS } from "@/lib/permission-slugs";
const { getContextMock, searchMock } = vi.hoisted(() => ({
getContextMock: vi.fn(),
searchMock: vi.fn(),
}));
vi.mock("@/features/housekeeping/foundation/server-capability-context", () => ({
getHousekeepingCapabilityContext: getContextMock,
}));
vi.mock("@/features/housekeeping/foundation/search/search-service", () => ({
searchHousekeeping: searchMock,
}));
import { executeHousekeepingSearch } from "./housekeeping-search";
const context: HousekeepingCapabilityContext = {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => slug === PERMS.USERS_VIEW,
hasAny: (...slugs) => slugs.includes(PERMS.USERS_VIEW),
hasAll: (...slugs) => slugs.every((slug) => slug === PERMS.USERS_VIEW),
};
describe("housekeeping search action", () => {
beforeEach(() => {
getContextMock.mockReset().mockResolvedValue(context);
searchMock.mockReset().mockResolvedValue({
navigation: [],
commands: [],
entities: [],
errors: [],
correlationId: "action-search",
});
});
it("binds search to the fresh server capability context", async () => {
const result = await executeHousekeepingSearch(" users ");
expect(searchMock).toHaveBeenCalledWith(" users ", context);
expect(result.correlationId).toBe("action-search");
});
it("rejects forged non-string terms without invoking dependencies", async () => {
const result = await executeHousekeepingSearch({ term: "users" });
expect(getContextMock).not.toHaveBeenCalled();
expect(searchMock).not.toHaveBeenCalled();
expect(result).toMatchObject({
errors: [{ providerId: "search", code: "VALIDATION" }],
});
});
});
+31
View File
@@ -0,0 +1,31 @@
"use server";
import type { HousekeepingErrorCode } from "@/features/housekeeping/foundation/contracts";
import { createCorrelationId } from "@/features/housekeeping/foundation/correlation";
import {
type HousekeepingSearchResponse,
searchHousekeeping,
} from "@/features/housekeeping/foundation/search/search-service";
import { getHousekeepingCapabilityContext } from "@/features/housekeeping/foundation/server-capability-context";
function failedSearch(code: HousekeepingErrorCode): HousekeepingSearchResponse {
return {
navigation: [],
commands: [],
entities: [],
errors: [{ providerId: "search", code }],
correlationId: createCorrelationId(),
};
}
export async function executeHousekeepingSearch(
term: unknown,
): Promise<HousekeepingSearchResponse> {
if (typeof term !== "string") return failedSearch("VALIDATION");
try {
const context = await getHousekeepingCapabilityContext();
return await searchHousekeeping(term, context);
} catch {
return failedSearch("INTERNAL");
}
}
+11
View File
@@ -54,7 +54,18 @@ export default async function HousekeepingPreviewDomainLayout({
command: translate("preview.commandDisabled"), command: translate("preview.commandDisabled"),
preview: translate("preview.badge"), preview: translate("preview.badge"),
backToSite: translate("preview.backToSite"), backToSite: translate("preview.backToSite"),
commandDeck: {
placeholder: translate("commandDeck.placeholder"),
navigation: translate("commandDeck.navigation"),
commands: translate("commandDeck.commands"),
entities: translate("commandDeck.entities"),
loading: translate("commandDeck.loading"),
empty: translate("commandDeck.empty"),
partial: translate("commandDeck.partial"),
close: translate("commandDeck.close"),
},
}} }}
surface="preview"
> >
{children} {children}
</HousekeepingShell> </HousekeepingShell>
@@ -105,6 +105,13 @@ export function getHousekeepingCommand(
return commands.get(id); return commands.get(id);
} }
export function listHousekeepingCommands(): readonly HousekeepingCommand<
unknown,
unknown
>[] {
return Object.freeze([...commands.values()]);
}
export function isHousekeepingCommandId(value: unknown): value is string { export function isHousekeepingCommandId(value: unknown): value is string {
return isNormalizedIdentifier(value) && commandIdPattern.test(value); return isNormalizedIdentifier(value) && commandIdPattern.test(value);
} }
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
import { join, posix } from "node:path"; import { join, posix } from "node:path";
import { createElement, type ReactElement } from "react"; import { createElement, type ReactElement } from "react";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { CONTENT_ROUTE_IDS } from "../domains/content/routes"; import { CONTENT_ROUTE_IDS } from "../domains/content/routes";
import { ECONOMY_ROUTE_IDS } from "../domains/economy/routes"; import { ECONOMY_ROUTE_IDS } from "../domains/economy/routes";
import { HOTEL_ROUTE_IDS } from "../domains/hotel/routes"; import { HOTEL_ROUTE_IDS } from "../domains/hotel/routes";
@@ -17,12 +17,20 @@ import { isHousekeepingPreviewEnabled } from "./preview-gate";
import { createHousekeepingRegistry } from "./registry"; import { createHousekeepingRegistry } from "./registry";
import { CommandTrigger } from "./shell/command-trigger"; import { CommandTrigger } from "./shell/command-trigger";
vi.mock("@/actions/housekeeping-search", () => ({
executeHousekeepingSearch: vi.fn(),
}));
const HOUSEKEEPING_ROOT = "src/features/housekeeping"; const HOUSEKEEPING_ROOT = "src/features/housekeeping";
const SERVER_CAPABILITY_CONTEXT = const SERVER_CAPABILITY_CONTEXT =
"src/features/housekeeping/foundation/server-capability-context.ts"; "src/features/housekeeping/foundation/server-capability-context.ts";
const PERMISSIONS_ADAPTER = "src/lib/permissions"; const PERMISSIONS_ADAPTER = "src/lib/permissions";
const DOMAIN_MODULE_ROOT = "src/features/housekeeping/domains"; const DOMAIN_MODULE_ROOT = "src/features/housekeeping/domains";
const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([ const approvedRuntimeImports = new Map<string, ReadonlySet<string>>([
[
"src/features/housekeeping/foundation/shell/command-deck.tsx",
new Set(["src/actions/housekeeping-search"]),
],
[ [
"src/features/housekeeping/domains/hotel/commands/studio-commands.ts", "src/features/housekeeping/domains/hotel/commands/studio-commands.ts",
new Set(["src/features/housekeeping/domains/hotel/queries/studio"]), new Set(["src/features/housekeeping/domains/hotel/queries/studio"]),
@@ -1553,16 +1561,30 @@ describe("housekeeping foundation completion contracts", () => {
expect(executablePropNames(mutatedTrigger)).toEqual(["onClick"]); expect(executablePropNames(mutatedTrigger)).toEqual(["onClick"]);
}); });
it("renders one inert localized command affordance", () => { it("renders one enabled localized command affordance", () => {
const sentinel = "HK::comando-localizzato-disabilitato"; const sentinel = "HK::ricerca-localizzata";
const trigger = CommandTrigger({ label: sentinel }); const trigger = CommandTrigger({
label: sentinel,
labels: {
placeholder: "HK::placeholder",
navigation: "HK::navigation",
commands: "HK::commands",
entities: "HK::entities",
loading: "HK::loading",
empty: "HK::empty",
partial: "HK::partial",
close: "HK::close",
},
surface: "preview",
});
const html = renderToStaticMarkup(trigger); const html = renderToStaticMarkup(trigger);
const buttons = html.match(/<button\b[^>]*>/g) ?? []; const buttons = html.match(/<button\b[^>]*>/g) ?? [];
expect(executablePropNames(trigger)).toEqual([]); expect(executablePropNames(trigger)).toEqual([]);
expect(buttons).toHaveLength(1); expect(buttons).toHaveLength(1);
expect(buttons[0]).toMatch(/\sdisabled(?:=""|(?=\s|>))/); expect(buttons[0]).not.toMatch(/\sdisabled(?:=""|(?=\s|>))/);
expect(html).toContain(`>${sentinel}</button>`); expect(buttons[0]).toContain('aria-haspopup="dialog"');
expect(html).toContain(`>${sentinel}</span>`);
}); });
it("validates the complete 137-row migration matrix without issues", () => { it("validates the complete 137-row migration matrix without issues", () => {
@@ -10,6 +10,14 @@ const requiredKeys = [
"pages.housekeeping.navigation.skipToContent", "pages.housekeeping.navigation.skipToContent",
"pages.housekeeping.navigation.primary", "pages.housekeeping.navigation.primary",
"pages.housekeeping.navigation.contextual", "pages.housekeeping.navigation.contextual",
"pages.housekeeping.commandDeck.placeholder",
"pages.housekeeping.commandDeck.navigation",
"pages.housekeeping.commandDeck.commands",
"pages.housekeeping.commandDeck.entities",
"pages.housekeeping.commandDeck.loading",
"pages.housekeeping.commandDeck.empty",
"pages.housekeeping.commandDeck.partial",
"pages.housekeeping.commandDeck.close",
"pages.housekeeping.states.loading.title", "pages.housekeeping.states.loading.title",
"pages.housekeeping.states.loading.description", "pages.housekeeping.states.loading.description",
"pages.housekeeping.states.empty.title", "pages.housekeeping.states.empty.title",
@@ -133,6 +141,7 @@ describe("housekeeping localization contract", () => {
expect(Object.keys(housekeeping as object)).toEqual([ expect(Object.keys(housekeeping as object)).toEqual([
"preview", "preview",
"navigation", "navigation",
"commandDeck",
"domains", "domains",
"routes", "routes",
"states", "states",
@@ -182,6 +191,9 @@ describe("housekeeping localization contract", () => {
expect( expect(
resolveMessage(itMessages, "pages.housekeeping.navigation.primary"), resolveMessage(itMessages, "pages.housekeeping.navigation.primary"),
).toBe("Aree housekeeping"); ).toBe("Aree housekeeping");
expect(
resolveMessage(itMessages, "pages.housekeeping.commandDeck.placeholder"),
).toBe("Cerca navigazione, comandi ed entità");
expect( expect(
resolveMessage(itMessages, "pages.housekeeping.states.loading.title"), resolveMessage(itMessages, "pages.housekeeping.states.loading.title"),
).toBe("Caricamento in corso"); ).toBe("Caricamento in corso");
@@ -15,6 +15,14 @@ const routeMocks = vi.hoisted(() => {
"navigation.skipToContent": "HK::skip-to-content", "navigation.skipToContent": "HK::skip-to-content",
"navigation.primary": "HK::primary-navigation", "navigation.primary": "HK::primary-navigation",
"navigation.contextual": "HK::contextual-navigation", "navigation.contextual": "HK::contextual-navigation",
"commandDeck.placeholder": "HK::command-placeholder",
"commandDeck.navigation": "HK::command-navigation",
"commandDeck.commands": "HK::command-commands",
"commandDeck.entities": "HK::command-entities",
"commandDeck.loading": "HK::command-loading",
"commandDeck.empty": "HK::command-empty",
"commandDeck.partial": "HK::command-partial",
"commandDeck.close": "HK::command-close",
"domains.people.title": "HK::people-title", "domains.people.title": "HK::people-title",
"domains.people.description": "Localized People description", "domains.people.description": "Localized People description",
"domains.economy.title": "Localized Economy", "domains.economy.title": "Localized Economy",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,15 +1,18 @@
import type { HousekeepingSurface } from "../contracts";
import { CommandDeck, type CommandDeckLabels } from "./command-deck";
interface CommandTriggerProps { interface CommandTriggerProps {
label: string; label: string;
labels: Omit<CommandDeckLabels, "trigger">;
surface: HousekeepingSurface;
} }
export function CommandTrigger({ label }: CommandTriggerProps) { export function CommandTrigger({
label,
labels,
surface,
}: CommandTriggerProps) {
return ( return (
<button <CommandDeck labels={{ trigger: label, ...labels }} surface={surface} />
type="button"
disabled
className="rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text-muted)]"
>
{label}
</button>
); );
} }
@@ -1,17 +1,31 @@
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server"; import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import type { HousekeepingNavigationDomain } from "../navigation"; import type { HousekeepingNavigationDomain } from "../navigation";
import { HousekeepingShell } from "./housekeeping-shell"; import { HousekeepingShell } from "./housekeeping-shell";
vi.mock("@/actions/housekeeping-search", () => ({
executeHousekeepingSearch: vi.fn(),
}));
const labels = { const labels = {
skipToContent: "Skip to housekeeping content", skipToContent: "Skip to housekeeping content",
primaryNavigation: "Primary housekeeping navigation", primaryNavigation: "Primary housekeeping navigation",
contextualNavigation: "Operations navigation", contextualNavigation: "Operations navigation",
command: "Commands are unavailable in preview", command: "Search housekeeping",
preview: "Preview", preview: "Preview",
backToSite: "Back to site", backToSite: "Back to site",
commandDeck: {
placeholder: "Search navigation, commands, and entities",
navigation: "Navigation",
commands: "Commands",
entities: "Entities",
loading: "Searching…",
empty: "No results",
partial: "Some providers are unavailable",
close: "Close",
},
}; };
const domains: readonly HousekeepingNavigationDomain[] = [ const domains: readonly HousekeepingNavigationDomain[] = [
@@ -185,9 +199,9 @@ describe("HousekeepingShell", () => {
expect(html).toContain(">Preview<"); expect(html).toContain(">Preview<");
expect(html).toContain('href="/"'); expect(html).toContain('href="/"');
expect(html).toContain(">Back to site<"); expect(html).toContain(">Back to site<");
expect(html).toMatch( expect(html).toContain('aria-haspopup="dialog"');
/<button type="button" disabled=""[^>]*>Commands are unavailable in preview<\/button>/, expect(html).toContain(">Search housekeeping<");
); expect(html).not.toContain('disabled=""');
}); });
it("maps all six authorized icons and exactly one active domain", () => { it("maps all six authorized icons and exactly one active domain", () => {
@@ -1,7 +1,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Link from "@/components/link"; import Link from "@/components/link";
import type { HousekeepingDomainId } from "../../migration/types"; import type { HousekeepingDomainId } from "../../migration/types";
import type { HousekeepingActor } from "../contracts"; import type { HousekeepingActor, HousekeepingSurface } from "../contracts";
import type { HousekeepingNavigationDomain } from "../navigation"; import type { HousekeepingNavigationDomain } from "../navigation";
import { CommandTrigger } from "./command-trigger"; import { CommandTrigger } from "./command-trigger";
import { ContextNav } from "./context-nav"; import { ContextNav } from "./context-nav";
@@ -19,7 +19,18 @@ interface HousekeepingShellProps {
command: string; command: string;
preview: string; preview: string;
backToSite: string; backToSite: string;
commandDeck: {
placeholder: string;
navigation: string;
commands: string;
entities: string;
loading: string;
empty: string;
partial: string;
close: string;
};
}; };
surface?: HousekeepingSurface;
children: ReactNode; children: ReactNode;
} }
@@ -28,6 +39,7 @@ export function HousekeepingShell({
activeDomainId, activeDomainId,
domains, domains,
labels, labels,
surface = "preview",
children, children,
}: HousekeepingShellProps) { }: HousekeepingShellProps) {
const activeDomain = domains.find((domain) => domain.id === activeDomainId); const activeDomain = domains.find((domain) => domain.id === activeDomainId);
@@ -48,7 +60,11 @@ export function HousekeepingShell({
<OperatorSummary actor={actor} /> <OperatorSummary actor={actor} />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CommandTrigger label={labels.command} /> <CommandTrigger
label={labels.command}
labels={labels.commandDeck}
surface={surface}
/>
<Link <Link
href="/" href="/"
className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]" className="rounded-md px-3 py-2 text-sm text-[var(--admin-text-muted)] hover:bg-[var(--admin-canvas)] hover:text-[var(--admin-text)]"
+11 -1
View File
@@ -3266,7 +3266,7 @@
"housekeeping": { "housekeeping": {
"preview": { "preview": {
"badge": "Foundation preview", "badge": "Foundation preview",
"commandDisabled": "Search and commands are enabled in a later subproject.", "commandDisabled": "Search housekeeping",
"backToSite": "Back to site" "backToSite": "Back to site"
}, },
"navigation": { "navigation": {
@@ -3274,6 +3274,16 @@
"primary": "Housekeeping domains", "primary": "Housekeeping domains",
"contextual": "Domain navigation" "contextual": "Domain navigation"
}, },
"commandDeck": {
"placeholder": "Search navigation, commands, and entities",
"navigation": "Navigation",
"commands": "Commands",
"entities": "Entities",
"loading": "Searching…",
"empty": "No results found",
"partial": "Some search providers are unavailable",
"close": "Close"
},
"domains": { "domains": {
"operations": { "operations": {
"title": "Operations", "title": "Operations",
+11 -1
View File
@@ -3266,7 +3266,7 @@
"housekeeping": { "housekeeping": {
"preview": { "preview": {
"badge": "Anteprima della struttura", "badge": "Anteprima della struttura",
"commandDisabled": "Ricerca e comandi saranno abilitati in un sottoprogetto successivo.", "commandDisabled": "Cerca in housekeeping",
"backToSite": "Torna al sito" "backToSite": "Torna al sito"
}, },
"navigation": { "navigation": {
@@ -3274,6 +3274,16 @@
"primary": "Aree housekeeping", "primary": "Aree housekeeping",
"contextual": "Navigazione del dominio" "contextual": "Navigazione del dominio"
}, },
"commandDeck": {
"placeholder": "Cerca navigazione, comandi ed entità",
"navigation": "Navigazione",
"commands": "Comandi",
"entities": "Entità",
"loading": "Ricerca in corso…",
"empty": "Nessun risultato",
"partial": "Alcuni provider di ricerca non sono disponibili",
"close": "Chiudi"
},
"domains": { "domains": {
"operations": { "operations": {
"title": "Operazioni", "title": "Operazioni",