feat(housekeeping): add global command deck search
This commit is contained in:
1 parent
2e95a106e0
commit
bcb0ca977f
18 files changed
+1353
-23
No files matched your search
@@ -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" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,18 @@ export default async function HousekeepingPreviewDomainLayout({
|
||||
command: translate("preview.commandDisabled"),
|
||||
preview: translate("preview.badge"),
|
||||
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}
|
||||
</HousekeepingShell>
|
||||
|
||||
@@ -105,6 +105,13 @@ export function getHousekeepingCommand(
|
||||
return commands.get(id);
|
||||
}
|
||||
|
||||
export function listHousekeepingCommands(): readonly HousekeepingCommand<
|
||||
unknown,
|
||||
unknown
|
||||
>[] {
|
||||
return Object.freeze([...commands.values()]);
|
||||
}
|
||||
|
||||
export function isHousekeepingCommandId(value: unknown): value is string {
|
||||
return isNormalizedIdentifier(value) && commandIdPattern.test(value);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
|
||||
import { join, posix } from "node:path";
|
||||
import { createElement, type ReactElement } from "react";
|
||||
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 { ECONOMY_ROUTE_IDS } from "../domains/economy/routes";
|
||||
import { HOTEL_ROUTE_IDS } from "../domains/hotel/routes";
|
||||
@@ -17,12 +17,20 @@ import { isHousekeepingPreviewEnabled } from "./preview-gate";
|
||||
import { createHousekeepingRegistry } from "./registry";
|
||||
import { CommandTrigger } from "./shell/command-trigger";
|
||||
|
||||
vi.mock("@/actions/housekeeping-search", () => ({
|
||||
executeHousekeepingSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
const HOUSEKEEPING_ROOT = "src/features/housekeeping";
|
||||
const SERVER_CAPABILITY_CONTEXT =
|
||||
"src/features/housekeeping/foundation/server-capability-context.ts";
|
||||
const PERMISSIONS_ADAPTER = "src/lib/permissions";
|
||||
const DOMAIN_MODULE_ROOT = "src/features/housekeeping/domains";
|
||||
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",
|
||||
new Set(["src/features/housekeeping/domains/hotel/queries/studio"]),
|
||||
@@ -1553,16 +1561,30 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
expect(executablePropNames(mutatedTrigger)).toEqual(["onClick"]);
|
||||
});
|
||||
|
||||
it("renders one inert localized command affordance", () => {
|
||||
const sentinel = "HK::comando-localizzato-disabilitato";
|
||||
const trigger = CommandTrigger({ label: sentinel });
|
||||
it("renders one enabled localized command affordance", () => {
|
||||
const sentinel = "HK::ricerca-localizzata";
|
||||
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 buttons = html.match(/<button\b[^>]*>/g) ?? [];
|
||||
expect(executablePropNames(trigger)).toEqual([]);
|
||||
|
||||
expect(buttons).toHaveLength(1);
|
||||
expect(buttons[0]).toMatch(/\sdisabled(?:=""|(?=\s|>))/);
|
||||
expect(html).toContain(`>${sentinel}</button>`);
|
||||
expect(buttons[0]).not.toMatch(/\sdisabled(?:=""|(?=\s|>))/);
|
||||
expect(buttons[0]).toContain('aria-haspopup="dialog"');
|
||||
expect(html).toContain(`>${sentinel}</span>`);
|
||||
});
|
||||
|
||||
it("validates the complete 137-row migration matrix without issues", () => {
|
||||
|
||||
@@ -10,6 +10,14 @@ const requiredKeys = [
|
||||
"pages.housekeeping.navigation.skipToContent",
|
||||
"pages.housekeeping.navigation.primary",
|
||||
"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.description",
|
||||
"pages.housekeeping.states.empty.title",
|
||||
@@ -133,6 +141,7 @@ describe("housekeeping localization contract", () => {
|
||||
expect(Object.keys(housekeeping as object)).toEqual([
|
||||
"preview",
|
||||
"navigation",
|
||||
"commandDeck",
|
||||
"domains",
|
||||
"routes",
|
||||
"states",
|
||||
@@ -182,6 +191,9 @@ describe("housekeeping localization contract", () => {
|
||||
expect(
|
||||
resolveMessage(itMessages, "pages.housekeeping.navigation.primary"),
|
||||
).toBe("Aree housekeeping");
|
||||
expect(
|
||||
resolveMessage(itMessages, "pages.housekeeping.commandDeck.placeholder"),
|
||||
).toBe("Cerca navigazione, comandi ed entità");
|
||||
expect(
|
||||
resolveMessage(itMessages, "pages.housekeeping.states.loading.title"),
|
||||
).toBe("Caricamento in corso");
|
||||
|
||||
@@ -15,6 +15,14 @@ const routeMocks = vi.hoisted(() => {
|
||||
"navigation.skipToContent": "HK::skip-to-content",
|
||||
"navigation.primary": "HK::primary-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.description": "Localized People description",
|
||||
"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 {
|
||||
label: string;
|
||||
labels: Omit<CommandDeckLabels, "trigger">;
|
||||
surface: HousekeepingSurface;
|
||||
}
|
||||
|
||||
export function CommandTrigger({ label }: CommandTriggerProps) {
|
||||
export function CommandTrigger({
|
||||
label,
|
||||
labels,
|
||||
surface,
|
||||
}: CommandTriggerProps) {
|
||||
return (
|
||||
<button
|
||||
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>
|
||||
<CommandDeck labels={{ trigger: label, ...labels }} surface={surface} />
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
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 { HousekeepingShell } from "./housekeeping-shell";
|
||||
|
||||
vi.mock("@/actions/housekeeping-search", () => ({
|
||||
executeHousekeepingSearch: vi.fn(),
|
||||
}));
|
||||
|
||||
const labels = {
|
||||
skipToContent: "Skip to housekeeping content",
|
||||
primaryNavigation: "Primary housekeeping navigation",
|
||||
contextualNavigation: "Operations navigation",
|
||||
command: "Commands are unavailable in preview",
|
||||
command: "Search housekeeping",
|
||||
preview: "Preview",
|
||||
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[] = [
|
||||
@@ -185,9 +199,9 @@ describe("HousekeepingShell", () => {
|
||||
expect(html).toContain(">Preview<");
|
||||
expect(html).toContain('href="/"');
|
||||
expect(html).toContain(">Back to site<");
|
||||
expect(html).toMatch(
|
||||
/<button type="button" disabled=""[^>]*>Commands are unavailable in preview<\/button>/,
|
||||
);
|
||||
expect(html).toContain('aria-haspopup="dialog"');
|
||||
expect(html).toContain(">Search housekeeping<");
|
||||
expect(html).not.toContain('disabled=""');
|
||||
});
|
||||
|
||||
it("maps all six authorized icons and exactly one active domain", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "@/components/link";
|
||||
import type { HousekeepingDomainId } from "../../migration/types";
|
||||
import type { HousekeepingActor } from "../contracts";
|
||||
import type { HousekeepingActor, HousekeepingSurface } from "../contracts";
|
||||
import type { HousekeepingNavigationDomain } from "../navigation";
|
||||
import { CommandTrigger } from "./command-trigger";
|
||||
import { ContextNav } from "./context-nav";
|
||||
@@ -19,7 +19,18 @@ interface HousekeepingShellProps {
|
||||
command: string;
|
||||
preview: string;
|
||||
backToSite: string;
|
||||
commandDeck: {
|
||||
placeholder: string;
|
||||
navigation: string;
|
||||
commands: string;
|
||||
entities: string;
|
||||
loading: string;
|
||||
empty: string;
|
||||
partial: string;
|
||||
close: string;
|
||||
};
|
||||
};
|
||||
surface?: HousekeepingSurface;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
@@ -28,6 +39,7 @@ export function HousekeepingShell({
|
||||
activeDomainId,
|
||||
domains,
|
||||
labels,
|
||||
surface = "preview",
|
||||
children,
|
||||
}: HousekeepingShellProps) {
|
||||
const activeDomain = domains.find((domain) => domain.id === activeDomainId);
|
||||
@@ -48,7 +60,11 @@ export function HousekeepingShell({
|
||||
<OperatorSummary actor={actor} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CommandTrigger label={labels.command} />
|
||||
<CommandTrigger
|
||||
label={labels.command}
|
||||
labels={labels.commandDeck}
|
||||
surface={surface}
|
||||
/>
|
||||
<Link
|
||||
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)]"
|
||||
|
||||
+11
-1
@@ -3266,7 +3266,7 @@
|
||||
"housekeeping": {
|
||||
"preview": {
|
||||
"badge": "Foundation preview",
|
||||
"commandDisabled": "Search and commands are enabled in a later subproject.",
|
||||
"commandDisabled": "Search housekeeping",
|
||||
"backToSite": "Back to site"
|
||||
},
|
||||
"navigation": {
|
||||
@@ -3274,6 +3274,16 @@
|
||||
"primary": "Housekeeping domains",
|
||||
"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": {
|
||||
"operations": {
|
||||
"title": "Operations",
|
||||
|
||||
+11
-1
@@ -3266,7 +3266,7 @@
|
||||
"housekeeping": {
|
||||
"preview": {
|
||||
"badge": "Anteprima della struttura",
|
||||
"commandDisabled": "Ricerca e comandi saranno abilitati in un sottoprogetto successivo.",
|
||||
"commandDisabled": "Cerca in housekeeping",
|
||||
"backToSite": "Torna al sito"
|
||||
},
|
||||
"navigation": {
|
||||
@@ -3274,6 +3274,16 @@
|
||||
"primary": "Aree housekeeping",
|
||||
"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": {
|
||||
"operations": {
|
||||
"title": "Operazioni",
|
||||
|
||||
Reference in new issue
Block a user