diff --git a/src/actions/housekeeping-search.test.ts b/src/actions/housekeeping-search.test.ts new file mode 100644 index 0000000000..9dad845a7f --- /dev/null +++ b/src/actions/housekeeping-search.test.ts @@ -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" }], + }); + }); +}); diff --git a/src/actions/housekeeping-search.ts b/src/actions/housekeeping-search.ts new file mode 100644 index 0000000000..1e6f72c9a8 --- /dev/null +++ b/src/actions/housekeeping-search.ts @@ -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 { + if (typeof term !== "string") return failedSearch("VALIDATION"); + try { + const context = await getHousekeepingCapabilityContext(); + return await searchHousekeeping(term, context); + } catch { + return failedSearch("INTERNAL"); + } +} diff --git a/src/app/ase-next/[domain]/layout.tsx b/src/app/ase-next/[domain]/layout.tsx index c23291b8c3..3e534e13f5 100644 --- a/src/app/ase-next/[domain]/layout.tsx +++ b/src/app/ase-next/[domain]/layout.tsx @@ -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} diff --git a/src/features/housekeeping/foundation/commands/registry.ts b/src/features/housekeeping/foundation/commands/registry.ts index d456d2af2f..680f9dad04 100644 --- a/src/features/housekeeping/foundation/commands/registry.ts +++ b/src/features/housekeeping/foundation/commands/registry.ts @@ -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); } diff --git a/src/features/housekeeping/foundation/foundation-source-contract.test.ts b/src/features/housekeeping/foundation/foundation-source-contract.test.ts index eb0366b17d..f7b990ef09 100644 --- a/src/features/housekeeping/foundation/foundation-source-contract.test.ts +++ b/src/features/housekeeping/foundation/foundation-source-contract.test.ts @@ -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>([ + [ + "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(/]*>/g) ?? []; expect(executablePropNames(trigger)).toEqual([]); expect(buttons).toHaveLength(1); - expect(buttons[0]).toMatch(/\sdisabled(?:=""|(?=\s|>))/); - expect(html).toContain(`>${sentinel}`); + expect(buttons[0]).not.toMatch(/\sdisabled(?:=""|(?=\s|>))/); + expect(buttons[0]).toContain('aria-haspopup="dialog"'); + expect(html).toContain(`>${sentinel}`); }); it("validates the complete 137-row migration matrix without issues", () => { diff --git a/src/features/housekeeping/foundation/localization-contract.test.ts b/src/features/housekeeping/foundation/localization-contract.test.ts index 72a3b347ca..4e57efe6ef 100644 --- a/src/features/housekeeping/foundation/localization-contract.test.ts +++ b/src/features/housekeeping/foundation/localization-contract.test.ts @@ -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"); diff --git a/src/features/housekeeping/foundation/preview-route-contract.test.ts b/src/features/housekeeping/foundation/preview-route-contract.test.ts index 3632f24d9d..d7f54b72d5 100644 --- a/src/features/housekeeping/foundation/preview-route-contract.test.ts +++ b/src/features/housekeeping/foundation/preview-route-contract.test.ts @@ -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", diff --git a/src/features/housekeeping/foundation/search/navigation-search.test.ts b/src/features/housekeeping/foundation/search/navigation-search.test.ts new file mode 100644 index 0000000000..e8587623ed --- /dev/null +++ b/src/features/housekeeping/foundation/search/navigation-search.test.ts @@ -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, 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 = { + "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", + ]); + }); +}); diff --git a/src/features/housekeeping/foundation/search/navigation-search.ts b/src/features/housekeeping/foundation/search/navigation-search.ts new file mode 100644 index 0000000000..d58919ba4e --- /dev/null +++ b/src/features/housekeeping/foundation/search/navigation-search.ts @@ -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 { + readonly type: "navigation"; +} + +export interface HousekeepingCommandHit + extends Omit { + 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), + }; +} diff --git a/src/features/housekeeping/foundation/search/search-service.test.ts b/src/features/housekeeping/foundation/search/search-service.test.ts new file mode 100644 index 0000000000..b23f495165 --- /dev/null +++ b/src/features/housekeeping/foundation/search/search-service.test.ts @@ -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(() => 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" }, + ]); + }); +}); diff --git a/src/features/housekeeping/foundation/search/search-service.ts b/src/features/housekeeping/foundation/search/search-service.ts new file mode 100644 index 0000000000..b68c185615 --- /dev/null +++ b/src/features/housekeeping/foundation/search/search-service.ts @@ -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 { + let timeout: ReturnType | undefined; + const timedOut = new Promise((resolve) => { + timeout = setTimeout( + () => + resolve({ + providerId: provider.id, + items: [], + error: "TIMEOUT", + }), + timeoutMs, + ); + }); + const searched = provider + .search(context, { term, limit: SEARCH_POLICY.providerLimit }) + .then((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(() => ({ + 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 { + 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(); + 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 { + 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); +} diff --git a/src/features/housekeeping/foundation/shell/command-deck.test.tsx b/src/features/housekeeping/foundation/shell/command-deck.test.tsx new file mode 100644 index 0000000000..572ea7a60a --- /dev/null +++ b/src/features/housekeeping/foundation/shell/command-deck.test.tsx @@ -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( + , + ); + 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"); + }); +}); diff --git a/src/features/housekeeping/foundation/shell/command-deck.tsx b/src/features/housekeeping/foundation/shell/command-deck.tsx new file mode 100644 index 0000000000..2e1da2ba51 --- /dev/null +++ b/src/features/housekeeping/foundation/shell/command-deck.tsx @@ -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(EMPTY_RESPONSE); + const [loading, setLoading] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const triggerRef = useRef(null); + const inputRef = useRef(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) => { + 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 ( + + {groupedEntries.map((entry) => ( + activate(entry)} + > + {entry.hit.title} + + {entry.hit.domain} + + + ))} + + ); + }; + + return ( + <> + + {open ? ( +
+
+
+ +
+ { + const index = entries.findIndex( + (entry) => entry.value === value, + ); + if (index >= 0) setSelectedIndex(index); + }} + onKeyDownCapture={onKeyDown} + > + + + {loading ?

{labels.loading}

: null} + {response.errors.length > 0 ? ( +

+ {labels.partial} +

+ ) : null} + {!loading ? {labels.empty} : null} + {renderGroup("navigation", labels.navigation)} + {renderGroup("commands", labels.commands)} + {renderGroup("entities", labels.entities)} +
+
+
+
+ ) : null} + + ); +} diff --git a/src/features/housekeeping/foundation/shell/command-trigger.tsx b/src/features/housekeeping/foundation/shell/command-trigger.tsx index 80065ae65e..1099500cd3 100644 --- a/src/features/housekeeping/foundation/shell/command-trigger.tsx +++ b/src/features/housekeeping/foundation/shell/command-trigger.tsx @@ -1,15 +1,18 @@ +import type { HousekeepingSurface } from "../contracts"; +import { CommandDeck, type CommandDeckLabels } from "./command-deck"; + interface CommandTriggerProps { label: string; + labels: Omit; + surface: HousekeepingSurface; } -export function CommandTrigger({ label }: CommandTriggerProps) { +export function CommandTrigger({ + label, + labels, + surface, +}: CommandTriggerProps) { return ( - + ); } diff --git a/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx b/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx index f462514a0d..885e65a2ac 100644 --- a/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx +++ b/src/features/housekeeping/foundation/shell/housekeeping-shell.test.tsx @@ -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( - /