feat(scheduler): group schedule action picker by operator intent (#1446)

* feat(scheduler): group schedule action picker by operator intent

Reorganize the New Schedule action picker from a flat dropdown to a
category-grouped list (Lifecycle, Updates, Security, Maintenance, Backups).

- Extend Combobox component with optional group field on ComboboxOption,
  rendering grouped sections with non-interactive headers when groups are
  present. Flat rendering is unchanged for all other callers.
- Reorder SCHEDULED_ACTIONS by category group and update seven action
  labels per the operator-intent spec.
- Add DEFAULT_SCHEDULED_ACTION_ID constant so picker order and form
  defaults are independently controllable.
- Wire grouped actionOptions into ScheduledOperationsView.
- Update all label references in docs and tests.
- Add Combobox grouping tests, registry order test, and default-constant
  test.

* fix(scheduler): correct Combobox grouping for interleaved groups, docs labels

- Replace last-group-append with Map-based group partitioning so
  interleaved or mixed-group options land in the correct group.
- Add interleaved-groups test and restore non-interactivity test.
- Update stale "Start Stack" references to "Start / Bring Up Stack"
  in doc action-label contexts.
- Update action-picker alt text to describe the new grouped order.
This commit is contained in:
Anso
2026-06-25 01:40:31 -04:00
committed by GitHub
parent 3a22f59057
commit cc78873e65
11 changed files with 267 additions and 62 deletions
@@ -0,0 +1,115 @@
/**
* Lock the Combobox component: flat-rendering regression guard, grouped-option
* rendering, search filtering with groups, selection callback, and group-header
* non-interactivity.
*/
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Combobox, type ComboboxOption } from '../combobox';
const FLAT_OPTIONS: ComboboxOption[] = [
{ value: 'a', label: 'Alpha' },
{ value: 'b', label: 'Beta' },
{ value: 'c', label: 'Gamma' },
];
const GROUPED_OPTIONS: ComboboxOption[] = [
{ value: 'r', label: 'Restart Stack', group: 'Lifecycle' },
{ value: 's', label: 'Stop Stack', group: 'Lifecycle' },
{ value: 'u', label: 'Auto-update Stack', group: 'Updates' },
{ value: 'p', label: 'Prune Node Resources', group: 'Maintenance' },
];
describe('Combobox', () => {
it('renders flat options unchanged when no group field is present', async () => {
const onChange = vi.fn();
render(<Combobox options={FLAT_OPTIONS} value="" onValueChange={onChange} placeholder="Pick..." />);
await userEvent.click(screen.getByRole('combobox'));
expect(screen.getByRole('button', { name: 'Alpha' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Beta' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Gamma' })).toBeInTheDocument();
});
it('renders group headers in order of first appearance', async () => {
const onChange = vi.fn();
render(<Combobox options={GROUPED_OPTIONS} value="" onValueChange={onChange} placeholder="Pick..." />);
await userEvent.click(screen.getByRole('combobox'));
// Group headers rendered as non-interactive text.
const headers = document.querySelectorAll('.text-xs.font-medium.text-muted-foreground');
expect(headers).toHaveLength(3);
expect(headers[0].textContent).toBe('Lifecycle');
expect(headers[1].textContent).toBe('Updates');
expect(headers[2].textContent).toBe('Maintenance');
});
it('search hides groups with no matching options', async () => {
const onChange = vi.fn();
render(<Combobox options={GROUPED_OPTIONS} value="" onValueChange={onChange} placeholder="Pick..." />);
await userEvent.click(screen.getByRole('combobox'));
// The inline search input appears when open; type "stop".
const input = screen.getByRole('textbox');
await userEvent.type(input, 'stop');
// Only the Lifecycle header should remain visible.
const headers = document.querySelectorAll('.text-xs.font-medium.text-muted-foreground');
expect(headers).toHaveLength(1);
expect(headers[0].textContent).toBe('Lifecycle');
// Only Stop Stack should be visible.
expect(screen.getByRole('button', { name: 'Stop Stack' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Restart Stack' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Auto-update Stack' })).not.toBeInTheDocument();
});
it('selecting a grouped option calls onValueChange', async () => {
const onChange = vi.fn();
render(<Combobox options={GROUPED_OPTIONS} value="" onValueChange={onChange} placeholder="Pick..." />);
await userEvent.click(screen.getByRole('combobox'));
await userEvent.click(screen.getByRole('button', { name: 'Prune Node Resources' }));
expect(onChange).toHaveBeenCalledWith('p');
});
it('group headers are not interactive elements', async () => {
const onChange = vi.fn();
render(<Combobox options={GROUPED_OPTIONS} value="" onValueChange={onChange} placeholder="Pick..." />);
await userEvent.click(screen.getByRole('combobox'));
const headers = document.querySelectorAll('.text-xs.font-medium.text-muted-foreground');
for (const h of headers) {
expect(h.tagName).toBe('DIV');
expect(h.getAttribute('role')).toBeNull();
}
});
it('correctly partitions interleaved groups by first appearance', async () => {
const MIXED: ComboboxOption[] = [
{ value: 'a1', label: 'A1', group: 'Lifecycle' },
{ value: 'u1', label: 'U1', group: 'Updates' },
{ value: 'a2', label: 'A2', group: 'Lifecycle' },
];
const onChange = vi.fn();
render(<Combobox options={MIXED} value="" onValueChange={onChange} placeholder="Pick..." />);
await userEvent.click(screen.getByRole('combobox'));
const headers = document.querySelectorAll('.text-xs.font-medium.text-muted-foreground');
expect(headers).toHaveLength(2);
expect(headers[0].textContent).toBe('Lifecycle');
expect(headers[1].textContent).toBe('Updates');
// A1 and A2 should both be under Lifecycle, not split.
const lifecycleSection = headers[0].parentElement!;
expect(lifecycleSection.querySelectorAll('button')).toHaveLength(2);
expect(lifecycleSection.querySelector('button')?.textContent).toContain('A1');
});
});
+48
View File
@@ -6,6 +6,9 @@ import { cn } from "@/lib/utils"
export interface ComboboxOption {
value: string
label: string
/** Optional category group. When any option has a group, options render in
* grouped sections with non-interactive headers between groups. */
group?: string
}
interface ComboboxProps {
@@ -75,6 +78,23 @@ export function Combobox({
setSearch("")
}
const hasGroups = filtered.some((o) => o.group)
const groupedOptions = React.useMemo(() => {
if (!hasGroups) return null
const groupMap = new Map<string, ComboboxOption[]>()
const groupOrder: string[] = []
for (const o of filtered) {
const g = o.group!
if (!groupMap.has(g)) {
groupMap.set(g, [])
groupOrder.push(g)
}
groupMap.get(g)!.push(o)
}
return groupOrder.map(label => ({ label, options: groupMap.get(label)! }))
}, [filtered, hasGroups])
return (
<div ref={wrapperRef} className={cn("relative w-full", className)}>
{/* Trigger: static button when closed, inline search input when open */}
@@ -121,6 +141,34 @@ export function Combobox({
<div className="py-4 text-center text-sm text-muted-foreground">
{emptyText}
</div>
) : hasGroups && groupedOptions ? (
groupedOptions.map((group) => (
<div key={group.label}>
<div className="px-2 pt-2 pb-1 text-xs font-medium text-muted-foreground select-none">
{group.label}
</div>
{group.options.map((option) => (
<button
key={option.value}
type="button"
onClick={() => handleSelect(option)}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground",
value === option.value && "bg-accent/50"
)}
>
<Check
className={cn(
"mr-2 h-4 w-4 shrink-0",
value === option.value ? "opacity-100" : "opacity-0"
)}
strokeWidth={1.5}
/>
{option.label}
</button>
))}
</div>
))
) : (
filtered.map((option) => (
<button