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",
|
||||
|
||||
@@ -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
@@ -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