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"),
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",
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import { PERMS } from "@/lib/permission-slugs";
import type { HousekeepingCommand } from "../commands/registry";
import type {
HousekeepingCapabilityContext,
HousekeepingDomainManifest,
} from "../contracts";
import type { HousekeepingRegistry } from "../registry";
import { searchNavigationAndCommands } from "./navigation-search";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
const peopleManifest = {
id: "people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
iconId: "users",
canonicalHref: "/ase/people",
capability: { mode: "any", slugs: [PERMS.USERS_VIEW] },
routes: [
{
id: "people.users",
href: "/ase/people/users",
labelKey: "pages.housekeeping.routes.people.users",
capability: { mode: "any", slugs: [PERMS.USERS_VIEW] },
},
{
id: "people.moderation",
href: "/ase/people/moderation",
labelKey: "pages.housekeeping.routes.people.moderation",
capability: { mode: "any", slugs: [PERMS.MODERATION_VIEW] },
},
],
searchProviders: [],
inboxSources: [],
widgets: [],
} as const satisfies HousekeepingDomainManifest;
const registry: HousekeepingRegistry = { domains: [peopleManifest] };
function command(
id: string,
permission: string,
): HousekeepingCommand<Record<string, never>, never> {
return {
id,
owner: "people",
risk: "safe",
capability: { mode: "any", slugs: [permission] },
input: z.object({}),
requiresReason: false,
rateLimit: { attempts: 3, windowMs: 60_000 },
execute: async () => ({
ok: true,
data: undefined as never,
correlationId: "command",
}),
};
}
const translations: Record<string, string> = {
"pages.housekeeping.domains.people.title": "People",
"pages.housekeeping.domains.people.description": "Accounts and moderation",
"pages.housekeeping.routes.people.users": "User accounts",
"pages.housekeeping.routes.people.moderation": "Moderation queue",
};
describe("navigation and command search", () => {
it("matches permitted domain/routes/commands and omits forbidden entries", () => {
const result = searchNavigationAndCommands({
registry,
commands: [
command("people.user.refresh", PERMS.USERS_VIEW),
command("people.moderation.ban", PERMS.MODERATION_VIEW),
],
context: context([PERMS.USERS_VIEW]),
term: "user",
translate: (key) => translations[key] ?? key,
});
expect(result.navigation.map((hit) => hit.id)).toEqual([
"route:people.users",
]);
expect(result.commands.map((hit) => hit.commandId)).toEqual([
"people.user.refresh",
]);
expect(result.navigation[0]?.href).toBe("/ase/people/users");
expect(result.commands[0]?.href).toBe("/ase/people");
});
it("normalizes terms and keeps registry/command order stable", () => {
const result = searchNavigationAndCommands({
registry,
commands: [
command("people.user.refresh", PERMS.USERS_VIEW),
command("people.user.inspect", PERMS.USERS_VIEW),
],
context: context([PERMS.USERS_VIEW]),
term: " PEOPLE ",
translate: (key) => translations[key] ?? key,
});
expect(result.navigation.map((hit) => hit.id)).toEqual(["domain:people"]);
expect(result.commands.map((hit) => hit.commandId)).toEqual([
"people.user.refresh",
"people.user.inspect",
]);
});
});
@@ -0,0 +1,143 @@
import { satisfiesCapability } from "../capability-context";
import type {
CanonicalHousekeepingHref,
CapabilityRequirement,
HousekeepingCapabilityContext,
HousekeepingSearchResult,
} from "../contracts";
import type { HousekeepingRegistry } from "../registry";
export interface HousekeepingNavigationHit
extends Omit<HousekeepingSearchResult, "type"> {
readonly type: "navigation";
}
export interface HousekeepingCommandHit
extends Omit<HousekeepingSearchResult, "type"> {
readonly type: "command";
readonly commandId: string;
readonly risk: "safe" | "sensitive";
readonly requiresReason: boolean;
}
export interface DiscoverableHousekeepingCommand {
readonly id: string;
readonly owner: HousekeepingCommandHit["domain"];
readonly risk: HousekeepingCommandHit["risk"];
readonly capability: CapabilityRequirement;
readonly requiresReason: boolean;
}
export interface NavigationAndCommandSearchInput {
readonly registry: HousekeepingRegistry;
readonly commands: readonly DiscoverableHousekeepingCommand[];
readonly context: HousekeepingCapabilityContext;
readonly term: string;
readonly translate: (key: string) => string;
}
export interface NavigationAndCommandSearchResult {
readonly navigation: readonly HousekeepingNavigationHit[];
readonly commands: readonly HousekeepingCommandHit[];
}
export function normalizeHousekeepingSearchTerm(term: string): string {
return term.normalize("NFC").trim().slice(0, 128);
}
function searchTokens(term: string): readonly string[] {
return normalizeHousekeepingSearchTerm(term)
.toLocaleLowerCase()
.split(/\s+/u)
.filter(Boolean);
}
function matches(tokens: readonly string[], ...values: readonly string[]) {
if (tokens.length === 0) return true;
const haystack = values.join(" ").normalize("NFC").toLocaleLowerCase();
return tokens.every((token) => haystack.includes(token));
}
function commandTitle(commandId: string): string {
return commandId
.split(".")
.slice(1)
.flatMap((segment) => segment.split("-"))
.map((segment) =>
segment.length === 0
? segment
: `${segment[0]?.toLocaleUpperCase()}${segment.slice(1)}`,
)
.join(" ");
}
export function searchNavigationAndCommands({
registry,
commands,
context,
term,
translate,
}: NavigationAndCommandSearchInput): NavigationAndCommandSearchResult {
const tokens = searchTokens(term);
const navigation: HousekeepingNavigationHit[] = [];
for (const domain of registry.domains) {
if (!satisfiesCapability(context, domain.capability)) continue;
const domainTitle = translate(domain.labelKey);
const domainDescription = translate(domain.descriptionKey);
if (matches(tokens, domain.id, domainTitle, domainDescription)) {
navigation.push({
id: `domain:${domain.id}`,
domain: domain.id,
type: "navigation",
title: domainTitle,
description: domainDescription,
href: domain.canonicalHref,
capability: domain.capability,
});
}
for (const route of domain.routes) {
if (!satisfiesCapability(context, route.capability)) continue;
const title = translate(route.labelKey);
const localRouteId = route.id.split(".").slice(1).join(" ");
if (!matches(tokens, localRouteId, title)) continue;
navigation.push({
id: `route:${route.id}`,
domain: domain.id,
type: "navigation",
title,
href: route.href,
capability: route.capability,
});
}
}
const commandsFound: HousekeepingCommandHit[] = [];
for (const command of commands) {
if (!satisfiesCapability(context, command.capability)) continue;
const domain = registry.domains.find(
(candidate) => candidate.id === command.owner,
);
if (!domain || !satisfiesCapability(context, domain.capability)) continue;
const title = commandTitle(command.id);
if (!matches(tokens, command.id, command.owner, title)) continue;
commandsFound.push({
id: `command:${command.id}`,
commandId: command.id,
domain: command.owner,
type: "command",
title,
description: command.id,
href: domain.canonicalHref as CanonicalHousekeepingHref,
capability: command.capability,
risk: command.risk,
requiresReason: command.requiresReason,
});
}
return {
navigation: navigation.slice(0, 50),
commands: commandsFound.slice(0, 50),
};
}
@@ -0,0 +1,198 @@
import { describe, expect, it, vi } from "vitest";
import { PERMS } from "@/lib/permission-slugs";
import {
anyCapability,
fail,
type HousekeepingCapabilityContext,
type HousekeepingDomainManifest,
type HousekeepingSearchProvider,
ok,
} from "../contracts";
import type { HousekeepingRegistry } from "../registry";
import {
createHousekeepingSearchService,
SEARCH_POLICY,
} from "./search-service";
function context(granted: readonly string[]): HousekeepingCapabilityContext {
const permissions = new Set(granted);
return {
actor: { id: 42, username: "operator", rank: 7 },
isSuperAdmin: false,
has: (slug) => permissions.has(slug),
hasAny: (...slugs) => slugs.some((slug) => permissions.has(slug)),
hasAll: (...slugs) => slugs.every((slug) => permissions.has(slug)),
};
}
function provider(
id: string,
permission: string,
search: HousekeepingSearchProvider["search"],
): HousekeepingSearchProvider {
return {
id,
owner: "people",
capability: anyCapability(permission),
search,
};
}
function registry(
searchProviders: readonly HousekeepingSearchProvider[],
): HousekeepingRegistry {
return {
domains: [
{
id: "people",
labelKey: "pages.housekeeping.domains.people.title",
descriptionKey: "pages.housekeeping.domains.people.description",
iconId: "users",
canonicalHref: "/ase/people",
capability: anyCapability(PERMS.USERS_VIEW),
routes: [
{
id: "people.users",
href: "/ase/people/users",
labelKey: "pages.housekeeping.routes.people.users",
capability: anyCapability(PERMS.USERS_VIEW),
},
],
searchProviders,
inboxSources: [],
widgets: [],
} satisfies HousekeepingDomainManifest,
],
};
}
function entity(id: string) {
return {
id,
domain: "people" as const,
type: "entity" as const,
title: `User ${id}`,
href: "/ase/people/users" as const,
capability: anyCapability(PERMS.USERS_VIEW),
};
}
describe("Housekeeping search service", () => {
it("locks the provider timeout and result limits", () => {
expect(SEARCH_POLICY).toEqual({
minimumEntityTermLength: 2,
timeoutMs: 2_000,
providerLimit: 25,
combinedEntityLimit: 50,
});
});
it("searches navigation/commands only below two trimmed characters", async () => {
const search = vi.fn(async () => ok([entity("1")], "provider"));
const service = createHousekeepingSearchService({
registry: registry([provider("people.users", PERMS.USERS_VIEW, search)]),
commands: [],
translate: (key) =>
key.endsWith("people.users") ? "Accounts" : "People",
createCorrelationId: () => "search-short",
});
const response = await service(" a ", context([PERMS.USERS_VIEW]));
expect(search).not.toHaveBeenCalled();
expect(response.navigation.map((hit) => hit.title)).toContain("Accounts");
expect(response.entities).toEqual([]);
expect(response.correlationId).toBe("search-short");
});
it("invokes only permitted providers for two-character terms", async () => {
const permitted = vi.fn(async () => ok([entity("1")], "permitted"));
const forbidden = vi.fn(async () => ok([entity("2")], "forbidden"));
const service = createHousekeepingSearchService({
registry: registry([
provider("people.users", PERMS.USERS_VIEW, permitted),
provider("people.bans", PERMS.BANS_VIEW, forbidden),
]),
commands: [],
translate: (key) => key,
createCorrelationId: () => "search-two",
});
const response = await service("ab", context([PERMS.USERS_VIEW]));
expect(permitted).toHaveBeenCalledWith(expect.any(Object), {
term: "ab",
limit: 25,
});
expect(forbidden).not.toHaveBeenCalled();
expect(response.entities.map((item) => item.id)).toEqual(["1"]);
});
it("does not invoke providers owned by a forbidden domain", async () => {
const search = vi.fn(async () => ok([entity("1")], "forbidden-domain"));
const service = createHousekeepingSearchService({
registry: registry([provider("people.bans", PERMS.BANS_VIEW, search)]),
commands: [],
translate: (key) => key,
createCorrelationId: () => "search-forbidden-domain",
});
const response = await service("ab", context([PERMS.BANS_VIEW]));
expect(search).not.toHaveBeenCalled();
expect(response.entities).toEqual([]);
});
it("caps providers at 25, deduplicates stably, and caps combined entities at 50", async () => {
const ranges = [
Array.from({ length: 30 }, (_, index) => entity(String(index))),
Array.from({ length: 30 }, (_, index) => entity(String(index + 20))),
Array.from({ length: 30 }, (_, index) => entity(String(index + 45))),
];
const service = createHousekeepingSearchService({
registry: registry(
ranges.map((items, index) =>
provider(`people.${index}`, PERMS.USERS_VIEW, async () =>
ok(items, `provider-${index}`),
),
),
),
commands: [],
translate: (key) => key,
createCorrelationId: () => "search-limits",
});
const response = await service("user", context([PERMS.USERS_VIEW]));
expect(response.entities).toHaveLength(50);
expect(response.entities.map((item) => item.id)).toEqual(
Array.from({ length: 50 }, (_, index) => String(index)),
);
});
it("returns successful entities with stable typed timeout/provider errors", async () => {
const never = new Promise<never>(() => undefined);
const service = createHousekeepingSearchService({
registry: registry([
provider("people.timeout", PERMS.USERS_VIEW, () => never),
provider("people.failed", PERMS.USERS_VIEW, async () =>
fail(
"DEPENDENCY_UNAVAILABLE",
"errors.housekeeping.dependencyUnavailable",
"failed",
),
),
provider("people.ready", PERMS.USERS_VIEW, async () =>
ok([entity("ready")], "ready"),
),
]),
commands: [],
translate: (key) => key,
createCorrelationId: () => "search-partial",
timeoutMs: 5,
});
const response = await service("user", context([PERMS.USERS_VIEW]));
expect(response.entities.map((item) => item.id)).toEqual(["ready"]);
expect(response.errors).toEqual([
{ providerId: "people.timeout", code: "TIMEOUT" },
{ providerId: "people.failed", code: "DEPENDENCY_UNAVAILABLE" },
]);
});
});
@@ -0,0 +1,211 @@
import "server-only";
import { satisfiesCapability } from "../capability-context";
import { listHousekeepingCommands } from "../commands/registry";
import type {
HousekeepingCapabilityContext,
HousekeepingErrorCode,
HousekeepingSearchProvider,
HousekeepingSearchResult,
} from "../contracts";
import { createCorrelationId } from "../correlation";
import { isSafeHousekeepingHref } from "../housekeeping-href";
import {
createHousekeepingRegistry,
type HousekeepingRegistry,
} from "../registry";
import {
type DiscoverableHousekeepingCommand,
type HousekeepingCommandHit,
type HousekeepingNavigationHit,
normalizeHousekeepingSearchTerm,
searchNavigationAndCommands,
} from "./navigation-search";
export const SEARCH_POLICY = Object.freeze({
minimumEntityTermLength: 2,
timeoutMs: 2_000,
providerLimit: 25,
combinedEntityLimit: 50,
} as const);
export interface HousekeepingSearchResponse {
readonly navigation: readonly HousekeepingNavigationHit[];
readonly commands: readonly HousekeepingCommandHit[];
readonly entities: readonly HousekeepingSearchResult[];
readonly errors: readonly {
providerId: string;
code: HousekeepingErrorCode;
}[];
readonly correlationId: string;
}
export interface HousekeepingSearchServiceDependencies {
readonly registry: HousekeepingRegistry;
readonly commands: readonly DiscoverableHousekeepingCommand[];
readonly translate: (key: string) => string;
readonly createCorrelationId?: () => string;
readonly timeoutMs?: number;
}
type ProviderOutcome = {
readonly providerId: string;
readonly items: readonly HousekeepingSearchResult[];
readonly error?: HousekeepingErrorCode;
};
function validEntityResult(
item: HousekeepingSearchResult,
provider: HousekeepingSearchProvider,
context: HousekeepingCapabilityContext,
): boolean {
return (
item !== null &&
typeof item === "object" &&
item.type === "entity" &&
item.domain === provider.owner &&
typeof item.id === "string" &&
item.id.trim().length > 0 &&
typeof item.title === "string" &&
item.title.trim().length > 0 &&
typeof item.href === "string" &&
isSafeHousekeepingHref(item.href) &&
satisfiesCapability(context, item.capability)
);
}
async function runProvider(
provider: HousekeepingSearchProvider,
context: HousekeepingCapabilityContext,
term: string,
timeoutMs: number,
): Promise<ProviderOutcome> {
let timeout: ReturnType<typeof setTimeout> | undefined;
const timedOut = new Promise<ProviderOutcome>((resolve) => {
timeout = setTimeout(
() =>
resolve({
providerId: provider.id,
items: [],
error: "TIMEOUT",
}),
timeoutMs,
);
});
const searched = provider
.search(context, { term, limit: SEARCH_POLICY.providerLimit })
.then<ProviderOutcome>((result) => {
if (!result.ok) {
return {
providerId: provider.id,
items: [],
error: result.error.code,
};
}
return {
providerId: provider.id,
items: result.data
.filter((item) => validEntityResult(item, provider, context))
.slice(0, SEARCH_POLICY.providerLimit),
};
})
.catch<ProviderOutcome>(() => ({
providerId: provider.id,
items: [],
error: "INTERNAL",
}));
try {
return await Promise.race([searched, timedOut]);
} finally {
if (timeout !== undefined) clearTimeout(timeout);
}
}
export function createHousekeepingSearchService({
registry,
commands,
translate,
createCorrelationId: correlationIdFactory = createCorrelationId,
timeoutMs = SEARCH_POLICY.timeoutMs,
}: HousekeepingSearchServiceDependencies) {
return async function runSearch(
term: string,
context: HousekeepingCapabilityContext,
): Promise<HousekeepingSearchResponse> {
const normalizedTerm = normalizeHousekeepingSearchTerm(term);
const navigationAndCommands = searchNavigationAndCommands({
registry,
commands,
context,
term: normalizedTerm,
translate,
});
if (normalizedTerm.length < SEARCH_POLICY.minimumEntityTermLength) {
return {
...navigationAndCommands,
entities: [],
errors: [],
correlationId: correlationIdFactory(),
};
}
const providers = registry.domains.flatMap((domain) => {
if (!satisfiesCapability(context, domain.capability)) return [];
return domain.searchProviders.filter((provider) =>
satisfiesCapability(context, provider.capability),
);
});
const outcomes = await Promise.all(
providers.map((provider) =>
runProvider(provider, context, normalizedTerm, timeoutMs),
),
);
const seen = new Set<string>();
const entities: HousekeepingSearchResult[] = [];
for (const outcome of outcomes) {
for (const item of outcome.items) {
const key = `${item.domain}:${item.type}:${item.id}`;
if (seen.has(key)) continue;
seen.add(key);
entities.push(item);
if (entities.length === SEARCH_POLICY.combinedEntityLimit) break;
}
if (entities.length === SEARCH_POLICY.combinedEntityLimit) break;
}
return {
...navigationAndCommands,
entities,
errors: outcomes.flatMap((outcome) =>
outcome.error
? [{ providerId: outcome.providerId, code: outcome.error }]
: [],
),
correlationId: correlationIdFactory(),
};
};
}
const MESSAGE_PREFIX = "pages.housekeeping.";
export async function searchHousekeeping(
term: string,
context: HousekeepingCapabilityContext,
): Promise<HousekeepingSearchResponse> {
await import("../commands/bootstrap");
const [{ getTranslations }, { HOUSEKEEPING_MANIFESTS }] = await Promise.all([
import("next-intl/server"),
import("../../manifests"),
]);
const translate = await getTranslations("pages.housekeeping");
const service = createHousekeepingSearchService({
registry: createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS),
commands: listHousekeepingCommands(),
translate: (key) => {
if (!key.startsWith(MESSAGE_PREFIX)) return key;
return translate(key.slice(MESSAGE_PREFIX.length) as never);
},
});
return service(term, context);
}
@@ -0,0 +1,124 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import {
CommandDeck,
commandDeckKeyAction,
flattenHousekeepingSearchResults,
isCommandDeckShortcut,
nextCommandDeckIndex,
projectHousekeepingSearchHref,
restoreCommandDeckFocus,
} from "./command-deck";
vi.mock("@/actions/housekeeping-search", () => ({
executeHousekeepingSearch: vi.fn(),
}));
const labels = {
trigger: "Search housekeeping",
placeholder: "Search navigation, commands, and entities",
navigation: "Navigation",
commands: "Commands",
entities: "Entities",
loading: "Searching…",
empty: "No results",
partial: "Some providers are unavailable",
close: "Close",
};
describe("CommandDeck", () => {
it("renders an enabled accessible cmdk trigger", () => {
const html = renderToStaticMarkup(
<CommandDeck labels={labels} surface="preview" />,
);
expect(html).toContain("Search housekeeping");
expect(html).toContain('aria-haspopup="dialog"');
expect(html).not.toContain("disabled");
expect(html).toContain("Ctrl+K");
});
it("models shortcut, arrows, Enter, and Escape deterministically", () => {
expect(
isCommandDeckShortcut({ key: "k", ctrlKey: true, metaKey: false }),
).toBe(true);
expect(
isCommandDeckShortcut({ key: "K", ctrlKey: false, metaKey: true }),
).toBe(true);
expect(
isCommandDeckShortcut({
key: "k",
ctrlKey: true,
metaKey: false,
altKey: true,
}),
).toBe(false);
expect(commandDeckKeyAction("Escape", true)).toBe("close");
expect(commandDeckKeyAction("ArrowDown", true)).toBe("next");
expect(commandDeckKeyAction("ArrowUp", true)).toBe("previous");
expect(commandDeckKeyAction("Enter", true)).toBe("activate");
expect(commandDeckKeyAction("Enter", false)).toBe(null);
expect(nextCommandDeckIndex(-1, 3, "next")).toBe(0);
expect(nextCommandDeckIndex(2, 3, "next")).toBe(0);
expect(nextCommandDeckIndex(0, 3, "previous")).toBe(2);
});
it("restores trigger focus and projects canonical results to preview", () => {
const focus = vi.fn();
restoreCommandDeckFocus({ focus });
expect(focus).toHaveBeenCalledOnce();
expect(projectHousekeepingSearchHref("/ase/people/users", "preview")).toBe(
"/ase-next/people/users",
);
expect(
projectHousekeepingSearchHref("/ase/people/users", "canonical"),
).toBe("/ase/people/users");
});
it("keeps grouped result order and uses cmdk input/items in the client module", () => {
const flattened = flattenHousekeepingSearchResults({
navigation: [
{
id: "route:people.users",
domain: "people",
type: "navigation",
title: "Users",
href: "/ase/people/users",
capability: { mode: "any", slugs: ["users.view"] },
},
],
commands: [
{
id: "command:people.user.refresh",
commandId: "people.user.refresh",
domain: "people",
type: "command",
title: "User Refresh",
href: "/ase/people",
capability: { mode: "any", slugs: ["users.view"] },
risk: "safe",
requiresReason: false,
},
],
entities: [],
errors: [],
correlationId: "deck",
});
expect(flattened.map((entry) => entry.group)).toEqual([
"navigation",
"commands",
]);
const source = readFileSync(
resolve(
process.cwd(),
"src/features/housekeeping/foundation/shell/command-deck.tsx",
),
"utf8",
);
expect(source).toContain("CommandInput");
expect(source).toContain("CommandItem");
expect(source).toContain("onSelect");
});
});
@@ -0,0 +1,337 @@
"use client";
import {
type KeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { executeHousekeepingSearch } from "@/actions/housekeeping-search";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import type {
HousekeepingSearchResult,
HousekeepingSurface,
} from "../contracts";
import { toHousekeepingHref } from "../routing/href";
import type {
HousekeepingCommandHit,
HousekeepingNavigationHit,
} from "../search/navigation-search";
import type { HousekeepingSearchResponse } from "../search/search-service";
export interface CommandDeckLabels {
readonly trigger: string;
readonly placeholder: string;
readonly navigation: string;
readonly commands: string;
readonly entities: string;
readonly loading: string;
readonly empty: string;
readonly partial: string;
readonly close: string;
}
type CommandDeckHit =
| HousekeepingNavigationHit
| HousekeepingCommandHit
| HousekeepingSearchResult;
type CommandDeckGroup = "navigation" | "commands" | "entities";
export type CommandDeckKeyboardAction =
| "close"
| "next"
| "previous"
| "activate"
| null;
export interface CommandDeckEntry {
readonly group: CommandDeckGroup;
readonly value: string;
readonly hit: CommandDeckHit;
}
const EMPTY_RESPONSE: HousekeepingSearchResponse = {
navigation: [],
commands: [],
entities: [],
errors: [],
correlationId: "initial-search",
};
export function isCommandDeckShortcut(input: {
readonly key: string;
readonly ctrlKey: boolean;
readonly metaKey: boolean;
readonly altKey?: boolean;
readonly shiftKey?: boolean;
}): boolean {
return (
input.key.toLocaleLowerCase() === "k" &&
(input.ctrlKey || input.metaKey) &&
input.altKey !== true &&
input.shiftKey !== true
);
}
export function commandDeckKeyAction(
key: string,
open: boolean,
): CommandDeckKeyboardAction {
if (!open) return null;
if (key === "Escape") return "close";
if (key === "ArrowDown") return "next";
if (key === "ArrowUp") return "previous";
if (key === "Enter") return "activate";
return null;
}
export function nextCommandDeckIndex(
current: number,
count: number,
direction: "next" | "previous",
): number {
if (count <= 0) return -1;
if (direction === "next") return (current + 1 + count) % count;
return (current - 1 + count) % count;
}
export function restoreCommandDeckFocus(
target: {
focus(): void;
} | null,
): void {
target?.focus();
}
export function projectHousekeepingSearchHref(
href: HousekeepingNavigationHit["href"],
surface: HousekeepingSurface,
) {
return toHousekeepingHref(href, surface);
}
export function flattenHousekeepingSearchResults(
response: HousekeepingSearchResponse,
): readonly CommandDeckEntry[] {
const group = (
name: CommandDeckGroup,
hits: readonly CommandDeckHit[],
): readonly CommandDeckEntry[] =>
hits.map((hit) => ({
group: name,
value: `${name}:${hit.domain}:${hit.id}`,
hit,
}));
return [
...group("navigation", response.navigation),
...group("commands", response.commands),
...group("entities", response.entities),
];
}
export function CommandDeck({
labels,
surface,
}: {
readonly labels: CommandDeckLabels;
readonly surface: HousekeepingSurface;
}) {
const [open, setOpen] = useState(false);
const [term, setTerm] = useState("");
const [response, setResponse] =
useState<HousekeepingSearchResponse>(EMPTY_RESPONSE);
const [loading, setLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const requestSequence = useRef(0);
const wasOpen = useRef(false);
const entries = useMemo(
() => flattenHousekeepingSearchResults(response),
[response],
);
const close = useCallback(() => setOpen(false), []);
useEffect(() => {
const onShortcut = (event: globalThis.KeyboardEvent) => {
if (!isCommandDeckShortcut(event)) return;
event.preventDefault();
setOpen((current) => !current);
};
document.addEventListener("keydown", onShortcut);
return () => document.removeEventListener("keydown", onShortcut);
}, []);
useEffect(() => {
if (open) {
wasOpen.current = true;
inputRef.current?.focus();
return;
}
if (wasOpen.current) {
wasOpen.current = false;
restoreCommandDeckFocus(triggerRef.current);
}
}, [open]);
useEffect(() => {
if (!open) return;
const sequence = ++requestSequence.current;
let active = true;
setLoading(true);
const timer = setTimeout(() => {
executeHousekeepingSearch(term)
.then((nextResponse) => {
if (active && sequence === requestSequence.current) {
setResponse(nextResponse);
}
})
.catch(() => {
if (active && sequence === requestSequence.current) {
setResponse({
...EMPTY_RESPONSE,
errors: [{ providerId: "search", code: "INTERNAL" }],
correlationId: "client-search",
});
}
})
.finally(() => {
if (active && sequence === requestSequence.current) setLoading(false);
});
}, 120);
return () => {
active = false;
clearTimeout(timer);
};
}, [open, term]);
useEffect(() => {
setSelectedIndex(entries.length === 0 ? -1 : 0);
}, [entries.length]);
const activate = useCallback(
(entry: CommandDeckEntry) => {
const href = projectHousekeepingSearchHref(entry.hit.href, surface);
close();
window.location.assign(href);
},
[close, surface],
);
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const action = commandDeckKeyAction(event.key, open);
if (action === null) return;
event.preventDefault();
event.stopPropagation();
if (action === "close") {
close();
return;
}
if (action === "activate") {
const selected = entries[selectedIndex];
if (selected) activate(selected);
return;
}
setSelectedIndex((current) =>
nextCommandDeckIndex(current, entries.length, action),
);
};
const selectedValue = entries[selectedIndex]?.value ?? "";
const renderGroup = (group: CommandDeckGroup, heading: string) => {
const groupedEntries = entries.filter((entry) => entry.group === group);
if (groupedEntries.length === 0) return null;
return (
<CommandGroup heading={heading}>
{groupedEntries.map((entry) => (
<CommandItem
key={entry.value}
value={entry.value}
onSelect={() => activate(entry)}
>
<span className="min-w-0 flex-1 truncate">{entry.hit.title}</span>
<span className="text-xs text-[var(--admin-text-muted)]">
{entry.hit.domain}
</span>
</CommandItem>
))}
</CommandGroup>
);
};
return (
<>
<button
ref={triggerRef}
type="button"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
className="flex items-center gap-2 rounded-md border border-[var(--admin-border)] bg-[var(--admin-surface)] px-3 py-2 text-sm text-[var(--admin-text)]"
>
<span>{labels.trigger}</span>
<kbd className="text-xs text-[var(--admin-text-muted)]">Ctrl+K</kbd>
</button>
{open ? (
<div
role="dialog"
aria-modal="true"
aria-label={labels.trigger}
className="fixed inset-0 z-50 flex items-start justify-center bg-[var(--admin-canvas)]/80 p-4 pt-[10vh]"
>
<div className="w-full max-w-2xl rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl">
<div className="flex justify-end border-b border-[var(--admin-border)] p-2">
<button
type="button"
onClick={close}
className="rounded px-2 py-1 text-sm text-[var(--admin-text-muted)]"
>
{labels.close}
</button>
</div>
<Command
shouldFilter={false}
value={selectedValue}
onValueChange={(value) => {
const index = entries.findIndex(
(entry) => entry.value === value,
);
if (index >= 0) setSelectedIndex(index);
}}
onKeyDownCapture={onKeyDown}
>
<CommandInput
ref={inputRef}
value={term}
onValueChange={setTerm}
placeholder={labels.placeholder}
aria-label={labels.placeholder}
/>
<CommandList aria-busy={loading}>
{loading ? <p role="status">{labels.loading}</p> : null}
{response.errors.length > 0 ? (
<p role="status" className="px-3 py-2 text-sm">
{labels.partial}
</p>
) : null}
{!loading ? <CommandEmpty>{labels.empty}</CommandEmpty> : null}
{renderGroup("navigation", labels.navigation)}
{renderGroup("commands", labels.commands)}
{renderGroup("entities", labels.entities)}
</CommandList>
</Command>
</div>
</div>
) : null}
</>
);
}
@@ -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
View File
@@ -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
View File
@@ -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",