feat: complete catalog fleet management workflow
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import { Play, X } from "lucide-react";
|
||||
|
||||
import { Button } from "../../components/ui/button";
|
||||
|
||||
export interface FleetActionOption<ActionId extends string = string> {
|
||||
id: ActionId;
|
||||
label: string;
|
||||
group: string;
|
||||
scopeLabel?: string;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string;
|
||||
tone?: "default" | "destructive";
|
||||
runLabel?: string;
|
||||
}
|
||||
|
||||
interface FleetActionSelectorProps<ActionId extends string> {
|
||||
actions: readonly FleetActionOption<ActionId>[];
|
||||
selectedCount: number;
|
||||
hiddenSelectedCount?: number;
|
||||
busy?: boolean;
|
||||
busyLabel?: string;
|
||||
selectRef?: RefObject<HTMLSelectElement>;
|
||||
onRun: (action: ActionId) => void | Promise<void>;
|
||||
onClear: () => void;
|
||||
renderContext?: (action: ActionId | "") => ReactNode;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-aware command picker shared by all Fleet Ledger grids. Eligibility
|
||||
* remains data owned by the calling grid; this module owns only presentation,
|
||||
* explicit execution and accessible unavailable reasons.
|
||||
*/
|
||||
export function FleetActionSelector<ActionId extends string>({
|
||||
actions,
|
||||
selectedCount,
|
||||
hiddenSelectedCount = 0,
|
||||
busy = false,
|
||||
busyLabel = "Running…",
|
||||
selectRef,
|
||||
onRun,
|
||||
onClear,
|
||||
renderContext,
|
||||
label = "Batch action",
|
||||
}: FleetActionSelectorProps<ActionId>) {
|
||||
const [selectedActionId, setSelectedActionId] = useState<ActionId | "">("");
|
||||
const hintId = useId();
|
||||
const groups = useMemo(() => {
|
||||
const order: string[] = [];
|
||||
const grouped = new Map<string, FleetActionOption<ActionId>[]>();
|
||||
for (const action of actions) {
|
||||
if (!grouped.has(action.group)) {
|
||||
order.push(action.group);
|
||||
grouped.set(action.group, []);
|
||||
}
|
||||
grouped.get(action.group)!.push(action);
|
||||
}
|
||||
return order.map((group) => ({ group, actions: grouped.get(group)! }));
|
||||
}, [actions]);
|
||||
const selectedAction = actions.find((action) => action.id === selectedActionId);
|
||||
const unavailableReason = selectedAction?.disabled
|
||||
? selectedAction.disabledReason ?? "This action is not available for the current selection."
|
||||
: null;
|
||||
const actionScope = selectedAction?.scopeLabel;
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedActionId && !actions.some((action) => action.id === selectedActionId)) {
|
||||
setSelectedActionId("");
|
||||
}
|
||||
}, [actions, selectedActionId]);
|
||||
|
||||
const run = async () => {
|
||||
if (!selectedAction || selectedAction.disabled || busy) return;
|
||||
await onRun(selectedAction.id);
|
||||
setSelectedActionId("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="thot-fleet-action-selector">
|
||||
<div className="thot-fleet-action-selector__scope" aria-live="polite">
|
||||
<strong>{actionScope ?? `${selectedCount} selected`}</strong>
|
||||
{actionScope ? (
|
||||
<span>Action scope</span>
|
||||
) : hiddenSelectedCount > 0 ? (
|
||||
<span>{hiddenSelectedCount} hidden by filter</span>
|
||||
) : (
|
||||
<span>Selected rows</span>
|
||||
)}
|
||||
</div>
|
||||
<label className="sr-only" htmlFor={hintId}>{label}</label>
|
||||
<select
|
||||
ref={selectRef}
|
||||
id={hintId}
|
||||
className="thot-fleet-action-selector__select"
|
||||
aria-label={label}
|
||||
value={selectedActionId}
|
||||
disabled={busy}
|
||||
onChange={(event) => setSelectedActionId(event.target.value as ActionId | "")}
|
||||
>
|
||||
<option value="">Choose an action…</option>
|
||||
{groups.map(({ group, actions: groupActions }) => (
|
||||
<optgroup key={group} label={group}>
|
||||
{groupActions.map((action) => (
|
||||
<option key={action.id} value={action.id}>
|
||||
{action.label}
|
||||
{action.disabled && action.disabledReason
|
||||
? `, unavailable: ${action.disabledReason}`
|
||||
: ""}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{renderContext ? renderContext(selectedActionId) : null}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={selectedAction?.tone === "destructive" ? "destructive" : "default"}
|
||||
disabled={!selectedAction || selectedAction.disabled || busy}
|
||||
aria-describedby={unavailableReason ? `${hintId}-reason` : undefined}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
<Play aria-hidden="true" />
|
||||
{busy ? busyLabel : selectedAction?.runLabel ?? "Run action"}
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onClear}>
|
||||
<X aria-hidden="true" /> Clear
|
||||
</Button>
|
||||
{unavailableReason ? (
|
||||
<span id={`${hintId}-reason`} className="thot-fleet-action-selector__reason" role="status">
|
||||
{unavailableReason}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user