feat: edit target servers and tags together in the workflow modal
This commit is contained in:
@@ -14,14 +14,20 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
const router = useRouter();
|
||||
const [name, setName] = useState(workflow.name);
|
||||
const [targets, setTargets] = useState<string[]>(workflow.target_server_ids);
|
||||
// Rows rather than a map: a half-typed key is not a valid map entry, and
|
||||
// rebuilding the map on every keystroke would drop a row the moment its key
|
||||
// was cleared. Only complete pairs are written back on save.
|
||||
const [tagRows, setTagRows] = useState<[string, string][]>(Object.entries(workflow.target_tags ?? {}));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
|
||||
const { data: knownTags } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 });
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(workflow.name);
|
||||
setTargets(workflow.target_server_ids);
|
||||
setTagRows(Object.entries(workflow.target_tags ?? {}));
|
||||
}
|
||||
}, [open, workflow]);
|
||||
|
||||
@@ -29,7 +35,12 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await api.updateWorkflow(workflow.workflow_id, { ...workflow, name, target_server_ids: targets });
|
||||
const updated = await api.updateWorkflow(workflow.workflow_id, {
|
||||
...workflow,
|
||||
name,
|
||||
target_server_ids: targets,
|
||||
target_tags: Object.fromEntries(tagRows.filter(([k, v]) => k && v)),
|
||||
});
|
||||
onSaved(updated);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
@@ -74,7 +85,68 @@ export function EditWorkflowModal({ open, workflow, onSaved, onClose }: { open:
|
||||
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>
|
||||
<p className="mt-1.5 text-[11px] text-text-tertiary">Click to highlight, ctrl-click for several, double-click to move.</p>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border-soft pt-4">
|
||||
<label className="mb-1 block text-xs uppercase text-text-secondary">Target tags</label>
|
||||
<p className="mb-2 text-[11px] text-text-tertiary">
|
||||
Anything carrying <em>every</em> tag below runs too, on top of the servers named above. Leave empty to run only the named ones.
|
||||
</p>
|
||||
|
||||
<datalist id="workflow-tag-keys">
|
||||
{Object.keys(knownTags ?? {}).map((k) => (
|
||||
<option key={k} value={k} />
|
||||
))}
|
||||
</datalist>
|
||||
<datalist id="workflow-tag-values">
|
||||
{Object.values(knownTags ?? {})
|
||||
.flat()
|
||||
.map((v) => (
|
||||
<option key={v} value={v} />
|
||||
))}
|
||||
</datalist>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{tagRows.map(([k, v], i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input
|
||||
list="workflow-tag-keys"
|
||||
value={k}
|
||||
onChange={(e) => setTagRows(tagRows.map((row, j): [string, string] => (j === i ? [e.target.value, row[1]] : row)))}
|
||||
placeholder="env"
|
||||
className="w-32 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-signal focus:outline-none"
|
||||
/>
|
||||
<span className="font-mono text-text-tertiary">:</span>
|
||||
<input
|
||||
list="workflow-tag-values"
|
||||
value={v}
|
||||
onChange={(e) => setTagRows(tagRows.map((row, j): [string, string] => (j === i ? [row[0], e.target.value] : row)))}
|
||||
placeholder="prod"
|
||||
className="w-36 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-signal focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTagRows(tagRows.filter((_, j) => j !== i))}
|
||||
className="text-xs text-text-tertiary hover:text-danger focus:outline-none focus-visible:ring-2 focus-visible:ring-signal"
|
||||
aria-label={`Remove ${k || "tag"}`}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{tagRows.length === 0 && <p className="text-xs text-text-secondary">No tag selector — only the named servers will run.</p>}
|
||||
</div>
|
||||
|
||||
{tagRows.length < 20 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTagRows([...tagRows, ["", ""] as [string, string]])}
|
||||
className="mt-2 text-xs text-signal hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-signal"
|
||||
>
|
||||
Add tag
|
||||
</button>
|
||||
)}
|
||||
</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