mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-17 14:08:19 +00:00
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:
@@ -20,6 +20,7 @@ import {
|
||||
SCHEDULED_ACTION_CATEGORIES,
|
||||
getActionById,
|
||||
resolveTaskAction,
|
||||
DEFAULT_SCHEDULED_ACTION_ID,
|
||||
} from '@/lib/scheduledActions';
|
||||
|
||||
const DEFAULT_PRUNE_TARGETS = ['containers', 'images', 'networks', 'volumes'];
|
||||
@@ -194,7 +195,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
const nodeId = prefillData?.nodeId ?? (filterNodeId != null ? String(filterNodeId) : '');
|
||||
setEditingTask(null);
|
||||
setFormName('');
|
||||
setFormAction(SCHEDULED_ACTIONS[0]?.id ?? 'restart');
|
||||
setFormAction(DEFAULT_SCHEDULED_ACTION_ID);
|
||||
setFormTargetId(prefillData?.stackName ?? '');
|
||||
setFormNodeId(nodeId);
|
||||
setFormCron('0 3 * * *');
|
||||
@@ -342,6 +343,15 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
const cronDescription = getCronDescription(formCron);
|
||||
const cronFieldError = getCronFieldError(formCron);
|
||||
const nodeOptions = useMemo(() => nodes.map(n => ({ value: String(n.id), label: n.name })), [nodes]);
|
||||
const actionOptions = useMemo(
|
||||
() =>
|
||||
SCHEDULED_ACTIONS.map(o => ({
|
||||
value: o.id,
|
||||
label: o.label,
|
||||
group: SCHEDULED_ACTION_CATEGORIES.find(c => c.key === o.category)?.label,
|
||||
})),
|
||||
[],
|
||||
);
|
||||
// Scan and prune run on the hub-local Docker daemon only; remote nodes are excluded from their pickers.
|
||||
const localNodeOptions = useMemo(
|
||||
() => nodes.filter(n => n.type === 'local').map(n => ({ value: String(n.id), label: n.name })),
|
||||
@@ -670,7 +680,7 @@ export default function ScheduledOperationsView({ filterNodeId, onClearFilter, p
|
||||
<div className="space-y-2">
|
||||
<Label>Action</Label>
|
||||
<Combobox
|
||||
options={SCHEDULED_ACTIONS.map(o => ({ value: o.id, label: o.label }))}
|
||||
options={actionOptions}
|
||||
value={formAction}
|
||||
onValueChange={(val) => { setFormAction(val); setFormTargetId(''); setFormNodeId(''); setFormTargetServices([]); setFormPruneLabelFilter(''); }}
|
||||
placeholder="Select action..."
|
||||
|
||||
@@ -149,9 +149,9 @@ describe('ScheduledOperationsView', () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'cleanup');
|
||||
|
||||
// The action selector is the first combobox; switch it to System Prune.
|
||||
// The action selector is the first combobox; switch it to Prune Node Resources.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Prune Node Resources' }));
|
||||
|
||||
// Prune is now node-scoped: pick the local node from its Node combobox.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
@@ -184,7 +184,7 @@ describe('ScheduledOperationsView', () => {
|
||||
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'cleanup');
|
||||
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Prune Node Resources' }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
|
||||
|
||||
@@ -209,7 +209,7 @@ describe('ScheduledOperationsView', () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'cleanup');
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Prune Node Resources' }));
|
||||
|
||||
// Prune targets default to all four, but with no node the gate must hold.
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeDisabled();
|
||||
@@ -219,12 +219,12 @@ describe('ScheduledOperationsView', () => {
|
||||
expect(screen.getByRole('button', { name: 'Create' })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('excludes remote nodes from the System Prune node picker', async () => {
|
||||
it('excludes remote nodes from the Prune Node Resources node picker', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'System Prune' }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Prune Node Resources' }));
|
||||
|
||||
// Open the Node combobox; only the local node should be listed.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
@@ -232,12 +232,12 @@ describe('ScheduledOperationsView', () => {
|
||||
expect(screen.queryByRole('button', { name: 'edge' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('excludes remote nodes from the Vulnerability Scan node picker', async () => {
|
||||
it('excludes remote nodes from the Scan Node Images node picker', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Vulnerability Scan' }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Scan Node Images' }));
|
||||
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
expect(await screen.findByRole('button', { name: 'hub' })).toBeInTheDocument();
|
||||
@@ -250,7 +250,7 @@ describe('ScheduledOperationsView', () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'scan-local');
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Vulnerability Scan' }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Scan Node Images' }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'hub' }));
|
||||
|
||||
@@ -276,12 +276,12 @@ describe('ScheduledOperationsView', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a read-only "Entire fleet" scope for Fleet Snapshot', async () => {
|
||||
it('shows a read-only "Entire fleet" scope for Create Fleet Snapshot', async () => {
|
||||
render(<ScheduledOperationsView />);
|
||||
|
||||
await userEvent.click(await screen.findByRole('button', { name: /New Schedule/ }));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Fleet Snapshot' }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Create Fleet Snapshot' }));
|
||||
|
||||
expect(await screen.findByText('Entire fleet')).toBeInTheDocument();
|
||||
});
|
||||
@@ -340,17 +340,17 @@ describe('ScheduledOperationsView', () => {
|
||||
expect(screen.queryByText('Prune Targets')).not.toBeInTheDocument();
|
||||
|
||||
// Node-only action: Node shown, Stack hidden.
|
||||
await selectAction('Auto-update All Stacks');
|
||||
await selectAction('Auto-update All Stacks on Node');
|
||||
expect(screen.getByText('Node')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Stack')).not.toBeInTheDocument();
|
||||
|
||||
// Fleet snapshot: no Node, no Stack.
|
||||
await selectAction('Fleet Snapshot');
|
||||
await selectAction('Create Fleet Snapshot');
|
||||
expect(screen.queryByText('Node')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Stack')).not.toBeInTheDocument();
|
||||
|
||||
// Prune: local-only Node plus Prune Targets.
|
||||
await selectAction('System Prune');
|
||||
await selectAction('Prune Node Resources');
|
||||
expect(screen.getByText('Prune Targets')).toBeInTheDocument();
|
||||
expect(screen.getByText('Node')).toBeInTheDocument();
|
||||
});
|
||||
@@ -396,7 +396,7 @@ describe('ScheduledOperationsView', () => {
|
||||
await userEvent.type(await screen.findByPlaceholderText('e.g. Nightly stack restart'), 'fleet-update');
|
||||
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Auto-update All Stacks' }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Auto-update All Stacks on Node' }));
|
||||
|
||||
// Node selector is the second combobox once the node-only field renders.
|
||||
await userEvent.click(screen.getAllByRole('combobox')[1]);
|
||||
@@ -434,7 +434,7 @@ describe('ScheduledOperationsView', () => {
|
||||
await userEvent.click(await screen.findByRole('button', { name: /All tasks/ }));
|
||||
await userEvent.click(await screen.findByTitle('Edit'));
|
||||
await userEvent.click(screen.getAllByRole('combobox')[0]);
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Fleet Snapshot' }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Create Fleet Snapshot' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Update' }));
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user