mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-21 03:05:44 +00:00
Register shared search control governance
This commit is contained in:
@@ -2752,7 +2752,11 @@ keyboard-selectable. The button, slash-triggered help, dialog search, and
|
||||
keyboard selection must use the same `ASSISTANT_SLASH_COMMANDS` registry,
|
||||
`filterAssistantSlashCommands`, `AssistantCommandHelpDialog`, and
|
||||
`executeSlashCommand` path as `/help` and `/commands`; they must not introduce
|
||||
a second command registry or a separate provider-bound prompt path.
|
||||
a second command registry or a separate provider-bound prompt path. The command
|
||||
dialog search box must compose the frontend-primitives `SearchField` so the
|
||||
shared search-control registry owns clear-button behavior, input chrome,
|
||||
keyboard forwarding, and native-search-input drift prevention while AI runtime
|
||||
keeps ownership of command filtering and execution semantics.
|
||||
|
||||
Primary nav moved to governed platform/runtime destinations on 2026-05-16 and
|
||||
was clarified on 2026-05-25 through `frontend-modern/src/App.tsx` and
|
||||
|
||||
@@ -2416,6 +2416,12 @@ small leading status-dot glyph used by filter options. Page and feature
|
||||
surfaces must not copy the chip-dot `<span>` factory or import the legacy
|
||||
`PageControls` deck for resource-list filtering; those drift checks live in
|
||||
`shared-template-registry.json` and run through `shared-template-audit.mjs`.
|
||||
Search controls are registry-backed too. `SearchField` owns simple search
|
||||
input chrome, clear affordance, keyboard forwarding, focus handling, aria
|
||||
labels, and trailing-control padding, while `SearchInput` owns resource-list
|
||||
search enhancements such as history, tips, and type-to-search wiring. Product
|
||||
surfaces must compose those primitives instead of rendering native
|
||||
`type="search"` inputs or recreating search icon/clear/input classes locally.
|
||||
Segmented selectors are registry-backed too. `FilterButtonGroup` owns the
|
||||
settings, prominent, compact, and equal segmented selector shells, including
|
||||
active-button tone, disabled-option behavior, pressed-state semantics,
|
||||
|
||||
@@ -281,6 +281,51 @@
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "search-field-shell",
|
||||
"category": "search-control",
|
||||
"summary": "Product surfaces with simple search fields must compose SearchField for the shared icon, clear button, keyboard, focus, aria, and input chrome instead of rendering native search inputs.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/SearchField.tsx",
|
||||
"export": "SearchField"
|
||||
},
|
||||
"requiredConsumers": [
|
||||
{ "path": "src/components/AI/Chat/AssistantCommandHelpDialog.tsx" },
|
||||
{ "path": "src/components/AI/Chat/index.tsx" },
|
||||
{ "path": "src/components/Settings/InfrastructureSourcePicker.tsx" },
|
||||
{ "path": "src/components/Settings/ResourcePicker.tsx" },
|
||||
{ "path": "src/components/Settings/UserAssignmentsPanel.tsx" },
|
||||
{ "path": "src/components/shared/AIModelPicker.tsx" },
|
||||
{ "path": "src/components/shared/CommandPaletteModal.tsx" },
|
||||
{ "path": "src/components/shared/SearchInput.tsx" }
|
||||
],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/__tests__/SearchField.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "search-input-shell",
|
||||
"category": "search-control",
|
||||
"summary": "Resource-list and drawer search boxes that need history, tips, or type-to-search behavior must compose SearchInput instead of rebuilding search-enhancement controls locally.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/SearchInput.tsx",
|
||||
"export": "SearchInput"
|
||||
},
|
||||
"requiredConsumers": [
|
||||
{ "path": "src/components/Docker/SwarmServicesDrawer.tsx" },
|
||||
{ "path": "src/components/Kubernetes/K8sDeploymentsDrawer.tsx" },
|
||||
{ "path": "src/components/Kubernetes/K8sNamespacesDrawer.tsx" },
|
||||
{ "path": "src/components/Settings/SettingsPageShell.tsx" },
|
||||
{ "path": "src/components/shared/FilterBar/FilterBar.tsx" }
|
||||
],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"src/components/shared/__tests__/SearchInput.test.tsx",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "filter-button-group-shell",
|
||||
"category": "segmented-selector",
|
||||
@@ -597,6 +642,25 @@
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "search-control-native-search-input",
|
||||
"category": "search-control",
|
||||
"summary": "Product surfaces must not render native type=search inputs; they must compose SearchField or SearchInput so search chrome, clear behavior, keyboard handling, and aria labels stay canonical.",
|
||||
"canonical": {
|
||||
"path": "src/components/shared/SearchField.tsx",
|
||||
"export": "SearchField"
|
||||
},
|
||||
"scopes": ["src/components", "src/features", "src/pages"],
|
||||
"extensions": [".tsx"],
|
||||
"allPatterns": ["type=\"search\""],
|
||||
"legacyReason": "Retired migration debt. Search controls must use SearchField or SearchInput; native search inputs are not allowed in product surfaces.",
|
||||
"allowedPaths": [],
|
||||
"ignoredPaths": ["src/components/shared/SharedPrimitives.guardrails.test.ts"],
|
||||
"proof": [
|
||||
"src/components/shared/SharedPrimitives.guardrails.test.ts",
|
||||
"scripts/shared-template-audit.mjs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "filter-chip-status-dot-local-factory",
|
||||
"category": "filter-bar",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER,
|
||||
AI_CHAT_COMMAND_HELP_TITLE,
|
||||
} from '@/utils/aiChatPresentation';
|
||||
import { SearchField } from '@/components/shared/SearchField';
|
||||
|
||||
interface AssistantCommandHelpDialogProps {
|
||||
availability?: AssistantSlashCommandAvailability;
|
||||
@@ -139,14 +140,15 @@ export function AssistantCommandHelpDialog(props: AssistantCommandHelpDialogProp
|
||||
</button>
|
||||
</div>
|
||||
<div class="border-b border-border px-3 py-2">
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
<SearchField
|
||||
inputRef={(element) => {
|
||||
searchInputRef = element;
|
||||
}}
|
||||
value={commandSearchQuery()}
|
||||
onInput={(event) => setCommandSearchQuery(event.currentTarget.value)}
|
||||
type="search"
|
||||
aria-label={AI_CHAT_COMMAND_HELP_SEARCH_LABEL}
|
||||
onChange={setCommandSearchQuery}
|
||||
title={AI_CHAT_COMMAND_HELP_SEARCH_LABEL}
|
||||
placeholder={AI_CHAT_COMMAND_HELP_SEARCH_PLACEHOLDER}
|
||||
class="h-9 w-full rounded-md border border-border bg-surface px-3 text-sm text-base-content placeholder:text-muted focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -10,9 +10,12 @@ describe('AssistantCommandHelpDialog', () => {
|
||||
|
||||
const search = screen.getByLabelText('Search Assistant commands');
|
||||
await waitFor(() => expect(document.activeElement).toBe(search));
|
||||
expect(search).toHaveAttribute('type', 'text');
|
||||
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.input(search, { target: { value: 'runtime' } });
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Clear search' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('listbox', { name: 'Assistant commands' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /\/status/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: /\/models/ })).not.toBeInTheDocument();
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
InfrastructureSourcePickerItemPresentation,
|
||||
InfrastructureSourcePickerRouteStep,
|
||||
} from '@/utils/infrastructureOnboardingPresentation';
|
||||
import { SearchField } from '@/components/shared/SearchField';
|
||||
import {
|
||||
getInfrastructureGovernanceBadgeLabel,
|
||||
getInfrastructureSourcePickerItems,
|
||||
@@ -18,9 +19,6 @@ interface InfrastructureSourcePickerProps {
|
||||
const readinessBadgeClass =
|
||||
'inline-flex items-center rounded-full border border-blue-200 bg-blue-100 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-blue-800 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-200';
|
||||
|
||||
const searchInputClass =
|
||||
'h-10 w-full rounded-md border border-border bg-surface py-2 pl-9 pr-3 text-sm text-base-content outline-none transition-colors placeholder:text-muted focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20';
|
||||
|
||||
// Primary-path cards present the main onboarding journeys up front so users
|
||||
// pick a path before scanning the per-source card grid. The grid below stays
|
||||
// as a direct alternative for users who already know which source they want.
|
||||
@@ -150,20 +148,12 @@ export const InfrastructureSourcePicker: Component<InfrastructureSourcePickerPro
|
||||
</Show>
|
||||
|
||||
<div class="border-b border-border pb-4">
|
||||
<label class="relative block">
|
||||
<span class="sr-only">Search infrastructure type</span>
|
||||
<Search
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted"
|
||||
/>
|
||||
<input
|
||||
type="search"
|
||||
value={query()}
|
||||
onInput={(event) => setQuery(event.currentTarget.value)}
|
||||
class={searchInputClass}
|
||||
placeholder="Search sources, devices, services..."
|
||||
/>
|
||||
</label>
|
||||
<SearchField
|
||||
value={query()}
|
||||
onChange={setQuery}
|
||||
title="Search infrastructure type"
|
||||
placeholder="Search sources, devices, services..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section class="space-y-2">
|
||||
|
||||
@@ -2,6 +2,8 @@ import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import calloutCardSource from '@/components/shared/CalloutCard.tsx?raw';
|
||||
import assistantCommandHelpDialogSource from '@/components/AI/Chat/AssistantCommandHelpDialog.tsx?raw';
|
||||
import aiChatSource from '@/components/AI/Chat/index.tsx?raw';
|
||||
import aiModelPickerSource from '@/components/shared/AIModelPicker.tsx?raw';
|
||||
import commandPaletteModalSource from '@/components/shared/CommandPaletteModal.tsx?raw';
|
||||
import commandPaletteModelSource from '@/components/shared/commandPaletteModel.ts?raw';
|
||||
@@ -155,6 +157,9 @@ import reportingPanelSource from '@/components/Settings/ReportingPanel.tsx?raw';
|
||||
import rolesPanelSource from '@/components/Settings/RolesPanel.tsx?raw';
|
||||
import updatesSettingsPanelSource from '@/components/Settings/UpdatesSettingsPanel.tsx?raw';
|
||||
import userAssignmentsPanelSource from '@/components/Settings/UserAssignmentsPanel.tsx?raw';
|
||||
import infrastructureSourcePickerSource from '@/components/Settings/InfrastructureSourcePicker.tsx?raw';
|
||||
import resourcePickerSource from '@/components/Settings/ResourcePicker.tsx?raw';
|
||||
import settingsPageShellSource from '@/components/Settings/SettingsPageShell.tsx?raw';
|
||||
import patrolIntelligenceHeaderSource from '@/features/patrol/PatrolIntelligenceHeader.tsx?raw';
|
||||
import filterBarSource from '@/components/shared/FilterBar/FilterBar.tsx?raw';
|
||||
import filterChipSource from '@/components/shared/FilterBar/FilterChip.tsx?raw';
|
||||
@@ -1871,6 +1876,49 @@ describe('shared primitive guardrails', () => {
|
||||
});
|
||||
|
||||
it('keeps search field on shell, runtime, and model owners', () => {
|
||||
const registry = JSON.parse(sharedTemplateRegistrySource) as {
|
||||
rules?: Array<{
|
||||
id: string;
|
||||
canonical?: { path?: string; export?: string };
|
||||
requiredConsumers?: Array<{ path?: string }>;
|
||||
}>;
|
||||
patternGuards?: Array<{
|
||||
id: string;
|
||||
canonical?: { path?: string; export?: string };
|
||||
allPatterns?: string[];
|
||||
scopes?: string[];
|
||||
allowedPaths?: string[];
|
||||
ignoredPaths?: string[];
|
||||
}>;
|
||||
};
|
||||
const registeredRule = registry.rules?.find((rule) => rule.id === 'search-field-shell');
|
||||
const registeredGuard = registry.patternGuards?.find(
|
||||
(guard) => guard.id === 'search-control-native-search-input',
|
||||
);
|
||||
|
||||
expect(registeredRule?.canonical?.path).toBe('src/components/shared/SearchField.tsx');
|
||||
expect(registeredRule?.canonical?.export).toBe('SearchField');
|
||||
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
|
||||
'src/components/AI/Chat/AssistantCommandHelpDialog.tsx',
|
||||
'src/components/AI/Chat/index.tsx',
|
||||
'src/components/Settings/InfrastructureSourcePicker.tsx',
|
||||
'src/components/Settings/ResourcePicker.tsx',
|
||||
'src/components/Settings/UserAssignmentsPanel.tsx',
|
||||
'src/components/shared/AIModelPicker.tsx',
|
||||
'src/components/shared/CommandPaletteModal.tsx',
|
||||
'src/components/shared/SearchInput.tsx',
|
||||
]);
|
||||
expect(registeredGuard?.canonical?.path).toBe('src/components/shared/SearchField.tsx');
|
||||
expect(registeredGuard?.canonical?.export).toBe('SearchField');
|
||||
expect(registeredGuard?.allPatterns).toEqual(['type="search"']);
|
||||
expect(registeredGuard?.allowedPaths ?? []).toHaveLength(0);
|
||||
expect(registeredGuard?.ignoredPaths).toEqual([
|
||||
'src/components/shared/SharedPrimitives.guardrails.test.ts',
|
||||
]);
|
||||
expect(registeredGuard?.scopes).toEqual(
|
||||
expect.arrayContaining(['src/components', 'src/features', 'src/pages']),
|
||||
);
|
||||
|
||||
expect(searchFieldSource).toContain('useSearchFieldState');
|
||||
expect(searchFieldSource).not.toContain('let inputEl: HTMLInputElement');
|
||||
expect(searchFieldSource).not.toContain(
|
||||
@@ -1888,9 +1936,42 @@ describe('shared primitive guardrails', () => {
|
||||
expect(searchFieldModelSource).toContain('shouldShowSearchFieldClearButton');
|
||||
expect(searchFieldModelSource).toContain('getSearchFieldInputPaddingRightClass');
|
||||
expect(searchFieldModelSource).toContain("return 'pr-14 sm:pr-20'");
|
||||
|
||||
for (const source of [
|
||||
assistantCommandHelpDialogSource,
|
||||
aiChatSource,
|
||||
infrastructureSourcePickerSource,
|
||||
resourcePickerSource,
|
||||
userAssignmentsPanelSource,
|
||||
aiModelPickerSource,
|
||||
commandPaletteModalSource,
|
||||
searchInputSource,
|
||||
]) {
|
||||
expect(source).toContain('SearchField');
|
||||
expect(source).not.toContain('type="search"');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps search input on shell, runtime, and model owners', () => {
|
||||
const registry = JSON.parse(sharedTemplateRegistrySource) as {
|
||||
rules?: Array<{
|
||||
id: string;
|
||||
canonical?: { path?: string; export?: string };
|
||||
requiredConsumers?: Array<{ path?: string }>;
|
||||
}>;
|
||||
};
|
||||
const registeredRule = registry.rules?.find((rule) => rule.id === 'search-input-shell');
|
||||
|
||||
expect(registeredRule?.canonical?.path).toBe('src/components/shared/SearchInput.tsx');
|
||||
expect(registeredRule?.canonical?.export).toBe('SearchInput');
|
||||
expect(registeredRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([
|
||||
'src/components/Docker/SwarmServicesDrawer.tsx',
|
||||
'src/components/Kubernetes/K8sDeploymentsDrawer.tsx',
|
||||
'src/components/Kubernetes/K8sNamespacesDrawer.tsx',
|
||||
'src/components/Settings/SettingsPageShell.tsx',
|
||||
'src/components/shared/FilterBar/FilterBar.tsx',
|
||||
]);
|
||||
|
||||
expect(searchInputSource).toContain('useSearchInputState');
|
||||
expect(searchInputSource).not.toContain('let searchInputEl: HTMLInputElement');
|
||||
expect(searchInputSource).not.toContain('useTypeToSearch');
|
||||
@@ -1928,6 +2009,16 @@ describe('shared primitive guardrails', () => {
|
||||
expect(searchInputEnhancementsModelSource).toContain('getSearchHistoryToggleTitle');
|
||||
expect(searchInputEnhancementsModelSource).toContain('SEARCH_HISTORY_CLEAR_LABEL');
|
||||
expect(searchInputEnhancementsModelSource).toContain('SEARCH_HISTORY_MENU_CLASS');
|
||||
|
||||
for (const source of [
|
||||
swarmServicesDrawerSource,
|
||||
k8sDeploymentsDrawerSource,
|
||||
k8sNamespacesDrawerSource,
|
||||
settingsPageShellSource,
|
||||
filterBarSource,
|
||||
]) {
|
||||
expect(source).toContain('SearchInput');
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps search tips popover on shell, runtime, and model owners', () => {
|
||||
|
||||
Reference in New Issue
Block a user