feat: dual list box for workflow target servers
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
/*
|
||||
* A two-pane picker: everything available on the left, everything chosen on the
|
||||
* right, moved across with the four buttons between them.
|
||||
*
|
||||
* The panes are divs rather than <select multiple>. A native multi-select draws
|
||||
* its selected rows with the platform's own highlight colour, which cannot be
|
||||
* restyled reliably across browsers — on a dark ground it renders as a pale
|
||||
* band that belongs to no palette. Rebuilding the widget is the only way to
|
||||
* keep it inside the token system.
|
||||
*/
|
||||
|
||||
export interface DualItem {
|
||||
id: string;
|
||||
label: string;
|
||||
hint?: string;
|
||||
}
|
||||
|
||||
function Pane({
|
||||
items,
|
||||
marked,
|
||||
onToggle,
|
||||
onCommit,
|
||||
empty,
|
||||
}: {
|
||||
items: DualItem[];
|
||||
marked: string[];
|
||||
onToggle: (id: string, additive: boolean) => void;
|
||||
onCommit: (id: string) => void;
|
||||
empty: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="h-56 overflow-y-auto rounded-lg border border-border bg-surface-2" role="listbox" aria-multiselectable>
|
||||
{items.length === 0 ? (
|
||||
<p className="px-3 py-2 text-xs text-text-tertiary">{empty}</p>
|
||||
) : (
|
||||
items.map((it) => {
|
||||
const on = marked.includes(it.id);
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={on}
|
||||
onClick={(e) => onToggle(it.id, e.ctrlKey || e.metaKey || e.shiftKey)}
|
||||
onDoubleClick={() => onCommit(it.id)}
|
||||
className={`flex w-full items-baseline gap-2 px-3 py-1.5 text-left text-sm transition-colors focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent ${
|
||||
on ? "bg-accent text-accent-ink" : "text-text-primary hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{it.label}</span>
|
||||
{it.hint && <span className={`truncate font-mono text-[11px] ${on ? "opacity-70" : "text-text-tertiary"}`}>{it.hint}</span>}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MoveButton({ children, onClick, disabled, label }: { children: React.ReactNode; onClick: () => void; disabled: boolean; label: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className="rounded-lg border border-border bg-surface-2 px-3 py-1.5 text-sm text-text-secondary transition-colors hover:border-accent/40 hover:text-text-primary focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:cursor-not-allowed disabled:opacity-40 disabled:hover:border-border disabled:hover:text-text-secondary"
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function DualListBox({
|
||||
items,
|
||||
selected,
|
||||
onChange,
|
||||
availableLabel = "Available",
|
||||
selectedLabel = "Selected",
|
||||
emptyAvailable = "Nothing left to add.",
|
||||
emptySelected = "Nothing selected.",
|
||||
}: {
|
||||
items: DualItem[];
|
||||
selected: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
availableLabel?: string;
|
||||
selectedLabel?: string;
|
||||
emptyAvailable?: string;
|
||||
emptySelected?: string;
|
||||
}) {
|
||||
// Which rows are highlighted in each pane, not which are chosen. Highlight
|
||||
// is transient and per-pane; membership is the `selected` prop.
|
||||
const [markedLeft, setMarkedLeft] = useState<string[]>([]);
|
||||
const [markedRight, setMarkedRight] = useState<string[]>([]);
|
||||
|
||||
const byLabel = (a: DualItem, b: DualItem) => a.label.localeCompare(b.label);
|
||||
const available = useMemo(() => items.filter((i) => !selected.includes(i.id)).sort(byLabel), [items, selected]);
|
||||
// Ordered by the same rule as the left pane rather than by the order things
|
||||
// were clicked, so a workflow's targets read the same way every time.
|
||||
const chosen = useMemo(() => items.filter((i) => selected.includes(i.id)).sort(byLabel), [items, selected]);
|
||||
|
||||
const mark = (setter: typeof setMarkedLeft) => (id: string, additive: boolean) =>
|
||||
setter((m) => (additive ? (m.includes(id) ? m.filter((x) => x !== id) : [...m, id]) : m.length === 1 && m[0] === id ? [] : [id]));
|
||||
|
||||
const add = (ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
onChange([...selected, ...ids.filter((id) => !selected.includes(id))]);
|
||||
setMarkedLeft([]);
|
||||
};
|
||||
|
||||
const remove = (ids: string[]) => {
|
||||
if (ids.length === 0) return;
|
||||
onChange(selected.filter((id) => !ids.includes(id)));
|
||||
setMarkedRight([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto_1fr] items-start gap-3">
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] uppercase tracking-wide text-text-tertiary">{availableLabel}</p>
|
||||
<Pane items={available} marked={markedLeft} onToggle={mark(setMarkedLeft)} onCommit={(id) => add([id])} empty={emptyAvailable} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 pt-6">
|
||||
<MoveButton label={`Add all to ${selectedLabel}`} disabled={available.length === 0} onClick={() => add(available.map((i) => i.id))}>
|
||||
»
|
||||
</MoveButton>
|
||||
<MoveButton label={`Add to ${selectedLabel}`} disabled={markedLeft.length === 0} onClick={() => add(markedLeft)}>
|
||||
›
|
||||
</MoveButton>
|
||||
<MoveButton label={`Remove from ${selectedLabel}`} disabled={markedRight.length === 0} onClick={() => remove(markedRight)}>
|
||||
‹
|
||||
</MoveButton>
|
||||
<MoveButton label={`Remove all from ${selectedLabel}`} disabled={chosen.length === 0} onClick={() => remove(chosen.map((i) => i.id))}>
|
||||
«
|
||||
</MoveButton>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-[11px] uppercase tracking-wide text-text-tertiary">
|
||||
{selectedLabel} · {chosen.length}
|
||||
</p>
|
||||
<Pane items={chosen} marked={markedRight} onToggle={mark(setMarkedRight)} onCommit={(id) => remove([id])} empty={emptySelected} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { api, Workflow } from "@/lib/api";
|
||||
import { Button, Modal } from "@/components/ui";
|
||||
import { ScheduleCard } from "./ScheduleCard";
|
||||
import { DualListBox } from "./DualListBox";
|
||||
|
||||
const inputClass = "w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-signal focus:outline-none focus:ring-1 focus:ring-signal";
|
||||
|
||||
@@ -24,8 +25,6 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
}
|
||||
}, [open, workflow]);
|
||||
|
||||
const toggle = (id: string) => setTargets((t) => (t.includes(id) ? t.filter((x) => x !== id) : [...t, id]));
|
||||
|
||||
const save = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -63,21 +62,19 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Target servers</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{servers?.map((s) => {
|
||||
const on = targets.includes(s.server_id);
|
||||
return (
|
||||
<label
|
||||
key={s.server_id}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-2 py-1 text-sm ${on ? "border-signal bg-signal/10 text-text-primary" : "border-border text-text-secondary"}`}
|
||||
>
|
||||
<input type="checkbox" className="accent-signal" checked={on} onChange={() => toggle(s.server_id)} />
|
||||
{s.hostname}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
{servers && servers.length === 0 && <p className="text-xs text-text-secondary">No servers registered.</p>}
|
||||
</div>
|
||||
{servers && servers.length === 0 ? (
|
||||
<p className="text-xs text-text-secondary">No servers registered.</p>
|
||||
) : (
|
||||
<DualListBox
|
||||
items={(servers ?? []).map((s) => ({ id: s.server_id, label: s.hostname, hint: s.status === "active" ? undefined : s.status }))}
|
||||
selected={targets}
|
||||
onChange={setTargets}
|
||||
selectedLabel="Targets"
|
||||
emptyAvailable="Every server is a target."
|
||||
emptySelected="No servers targeted."
|
||||
/>
|
||||
)}
|
||||
<p className="mt-1.5 text-[11px] text-text-tertiary">Click to highlight, ctrl-click for several, double-click to move. Tag selectors are set on the workflow page.</p>
|
||||
</div>
|
||||
{/* The schedule saves through its own endpoint, so it sits above
|
||||
the footer rather than under it — the footer's Save covers the
|
||||
|
||||
Reference in New Issue
Block a user