dashboard: edit-form target multi-select (#474 follow-up)
mara confirmed target editing on q #195 ("also let me edit targets"). Damocles wired `targets_add` / `targets_remove` onto PATCH /api/schedules/{id} in #478. UI side: the schedule edit form's read-only targets callout becomes a multi-select checkbox box (same chip styling as the new-schedule form) pre-checked for currently-active targets. Submit logic: - diff new selection against `originalActiveTargets` to populate `targets_add` (checked, not originally active) and `targets_remove` (originally active, now unchecked) - only include each key in the PATCH body when non-empty - guard against zero-target submission with a clear alert pointing the operator at `✕ cancel all` as the intended path - already-active targets that aren't in the live candidate list (e.g. a since-destroyed container) still surface so the operator can intentionally drop them Note (re-add semantics): backend's replace-on-conflict drops the cancelled-target tombstone + history on re-add — per the design discussion with damocles, operator intent on re-adding reads as "fresh start, target is active again." UI copy below the chip row reflects this. Carry persistence extended to the `targets` array so the checkbox state survives a state-poll re-render mid-edit; listens on `change` in addition to `input` for checkbox events.
This commit is contained in:
parent
062e2d3055
commit
befe5c0523
2 changed files with 87 additions and 24 deletions
|
|
@ -232,8 +232,11 @@ time, and per-target last-result. Per-row controls: a
|
||||||
every active target (#467 — recurring schedules keep their
|
every active target (#467 — recurring schedules keep their
|
||||||
cadence; one-shots are consumed after the manual fire), an
|
cadence; one-shots are consumed after the manual fire), an
|
||||||
`✎ edit` button opens an inline edit form (#474 — body /
|
`✎ edit` button opens an inline edit form (#474 — body /
|
||||||
description / interval / next-fire editable, targets stay
|
description / interval / next-fire / targets all editable;
|
||||||
immutable; submit PATCHes `/api/schedules/{id}`), and a
|
targets are a multi-select diff'd against the original active
|
||||||
|
set so unchecked-was-active = `targets_remove`, checked-not-
|
||||||
|
originally-active = `targets_add`; submit PATCHes
|
||||||
|
`/api/schedules/{id}`), and a
|
||||||
`CANC3L` button cancels the whole schedule
|
`CANC3L` button cancels the whole schedule
|
||||||
(`POST /api/schedules/{id}/cancel`). Individual target chips
|
(`POST /api/schedules/{id}/cancel`). Individual target chips
|
||||||
have their own cancel links. An inline creation form lets
|
have their own cancel links. An inline creation form lets
|
||||||
|
|
@ -498,18 +501,19 @@ not ours.
|
||||||
Agent-initiated schedules go through the approval queue instead
|
Agent-initiated schedules go through the approval queue instead
|
||||||
(manager MCP `request_schedule_prompt`).
|
(manager MCP `request_schedule_prompt`).
|
||||||
- `PATCH /api/schedules/{id}` — partial edit (#474). JSON body
|
- `PATCH /api/schedules/{id}` — partial edit (#474). JSON body
|
||||||
`{ body?, description?, interval_seconds?, next_fire_at_unix? }`.
|
`{ body?, description?, interval_seconds?, next_fire_at_unix?,
|
||||||
|
targets_add?, targets_remove? }`.
|
||||||
Missing key = "leave alone"; explicit `null` on
|
Missing key = "leave alone"; explicit `null` on
|
||||||
`description` / `interval_seconds` clears the field (so a
|
`description` / `interval_seconds` clears the field (so a
|
||||||
recurring schedule flips to one-shot when `interval_seconds`
|
recurring schedule flips to one-shot when `interval_seconds`
|
||||||
is sent as `null`). Targets stay immutable — cancel + new
|
is sent as `null`). `targets_add` is replace-on-conflict:
|
||||||
schedule is the retarget workaround. Refuses cancelled rows;
|
re-adding a previously-cancelled target drops the tombstone
|
||||||
returns the updated `WireSchedule` on success.
|
and the target starts fresh (operator intent on re-add =
|
||||||
- `POST /api/schedules/{id}/fire-now` — operator-initiated manual
|
"this target is active again"). `targets_remove` delegates
|
||||||
fire (#467). Runs the per-target fan-out once immediately.
|
to the same path as `cancel_targets` — tombstones preserve
|
||||||
Recurring schedules keep their cadence (manual fire is additive);
|
audit, parent schedule auto-cancels when no active targets
|
||||||
one-shot schedules are consumed (cancelled afterwards). Manager
|
remain. Refuses cancelled rows; returns the updated
|
||||||
surface: `fire_schedule_now(id)` MCP tool.
|
`WireSchedule` on success.
|
||||||
- `POST /api/schedules/{id}/cancel` — cancel a schedule. Body
|
- `POST /api/schedules/{id}/cancel` — cancel a schedule. Body
|
||||||
`{ targets?: ["name", …] }` cancels just those recipients;
|
`{ targets?: ["name", …] }` cancels just those recipients;
|
||||||
absent or empty body cancels the whole schedule.
|
absent or empty body cancels the whole schedule.
|
||||||
|
|
|
||||||
|
|
@ -2480,8 +2480,9 @@ window.marked = marked;
|
||||||
}
|
}
|
||||||
intervalCx.updatePreview();
|
intervalCx.updatePreview();
|
||||||
|
|
||||||
// Persist carry on every input so a refresh repaint preserves it.
|
// Persist carry on every input + checkbox change so a refresh
|
||||||
form_.addEventListener('input', () => {
|
// repaint preserves it.
|
||||||
|
const saveCarry = () => {
|
||||||
const fd = new FormData(form_);
|
const fd = new FormData(form_);
|
||||||
scheduleEditCarry.set(s.id, {
|
scheduleEditCarry.set(s.id, {
|
||||||
body: String(fd.get('body') || ''),
|
body: String(fd.get('body') || ''),
|
||||||
|
|
@ -2491,18 +2492,54 @@ window.marked = marked;
|
||||||
interval_h: String(fd.get('edit_interval_h') || ''),
|
interval_h: String(fd.get('edit_interval_h') || ''),
|
||||||
interval_m: String(fd.get('edit_interval_m') || ''),
|
interval_m: String(fd.get('edit_interval_m') || ''),
|
||||||
interval_s: String(fd.get('edit_interval_s') || ''),
|
interval_s: String(fd.get('edit_interval_s') || ''),
|
||||||
|
targets: fd.getAll('edit_targets').map(String),
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
form_.addEventListener('input', saveCarry);
|
||||||
|
form_.addEventListener('change', saveCarry);
|
||||||
|
|
||||||
// Targets read-only callout. Per #474 design: targets stay
|
// Targets multi-select (#474 fast-follow). Mirrors the new-schedule
|
||||||
// immutable, cancel+new is the retarget workaround.
|
// form's chip pattern: live container names + operator + manager,
|
||||||
const targetList = (s.targets || [])
|
// pre-checked for whichever targets are currently active. Submit
|
||||||
.filter((t) => !t.cancelled_at_unix)
|
// diffs against the original set to populate `targets_add` /
|
||||||
.map((t) => t.target)
|
// `targets_remove` on the PATCH body. Cancelled tombstones aren't
|
||||||
.join(', ');
|
// listed (re-adding them flows through `targets_add`, which the
|
||||||
|
// backend replace-on-conflict drops the tombstone for).
|
||||||
|
const originalActiveTargets = new Set(
|
||||||
|
(s.targets || []).filter((t) => !t.cancelled_at_unix).map((t) => t.target),
|
||||||
|
);
|
||||||
|
const targetsLabel = el('label', { class: 'schedule-field' },
|
||||||
|
el('span', { class: 'schedule-field-label' }, 'targets'));
|
||||||
|
const targetsBox = el('div', { class: 'schedule-targets' });
|
||||||
|
const candidateTargets = ['operator', 'manager'];
|
||||||
|
const containerNames = Array.from(containersState.values())
|
||||||
|
.map((c) => c.name)
|
||||||
|
.filter((n) => n !== 'manager' && n !== 'operator')
|
||||||
|
.sort();
|
||||||
|
for (const n of containerNames) candidateTargets.push(n);
|
||||||
|
// Any already-active target that's NOT in the live candidate list
|
||||||
|
// (operator's typo, a container that has since been destroyed,
|
||||||
|
// etc.) still shows so the operator can intentionally drop it.
|
||||||
|
for (const t of originalActiveTargets) {
|
||||||
|
if (!candidateTargets.includes(t)) candidateTargets.push(t);
|
||||||
|
}
|
||||||
|
const restoredTargets = carry.targets
|
||||||
|
? new Set(carry.targets)
|
||||||
|
: originalActiveTargets;
|
||||||
|
for (const name of candidateTargets) {
|
||||||
|
const id_ = 'se-' + s.id + '-' + name;
|
||||||
|
const cb = el('input', {
|
||||||
|
type: 'checkbox', name: 'edit_targets', value: name, id: id_,
|
||||||
|
});
|
||||||
|
if (restoredTargets.has(name)) cb.checked = true;
|
||||||
|
targetsBox.append(el('label', { class: 'schedule-target-chip', for: id_ },
|
||||||
|
cb, el('span', {}, name)));
|
||||||
|
}
|
||||||
|
targetsLabel.append(targetsBox);
|
||||||
|
form_.append(targetsLabel);
|
||||||
form_.append(el('p', { class: 'meta schedule-edit-targets-note' },
|
form_.append(el('p', { class: 'meta schedule-edit-targets-note' },
|
||||||
'targets (' + (targetList || 'none active') + ') are immutable — '
|
're-adding a previously cancelled target drops its history and starts fresh; '
|
||||||
+ 'cancel + new schedule if you need a different recipient set.'));
|
+ 'unchecking an active target cancels it.'));
|
||||||
|
|
||||||
const actions = el('div', { class: 'schedule-actions' });
|
const actions = el('div', { class: 'schedule-actions' });
|
||||||
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '✓ save changes');
|
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '✓ save changes');
|
||||||
|
|
@ -2554,9 +2591,29 @@ window.marked = marked;
|
||||||
const intervalTotal = partD + partH + partM + partS;
|
const intervalTotal = partD + partH + partM + partS;
|
||||||
const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null;
|
const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null;
|
||||||
|
|
||||||
|
// Compute target diff against the schedule's currently-active set
|
||||||
|
// (cancelled tombstones don't count). The backend treats
|
||||||
|
// `targets_add` as replace-on-conflict (re-adding a tombstoned
|
||||||
|
// target drops its history), so we can just blanket "send the
|
||||||
|
// checked list as add, send the unchecked-but-was-active list as
|
||||||
|
// remove."
|
||||||
|
const newTargets = new Set(fd.getAll('edit_targets').map(String));
|
||||||
|
const originalActive = new Set(
|
||||||
|
(s.targets || [])
|
||||||
|
.filter((t) => !t.cancelled_at_unix)
|
||||||
|
.map((t) => t.target),
|
||||||
|
);
|
||||||
|
if (!newTargets.size) {
|
||||||
|
alert('schedule must have at least one target — uncheck submit, or use ✕ cancel all instead');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const targetsAdd = [...newTargets].filter((t) => !originalActive.has(t));
|
||||||
|
const targetsRemove = [...originalActive].filter((t) => !newTargets.has(t));
|
||||||
|
|
||||||
// Build the PATCH body. Only include keys for fields that
|
// Build the PATCH body. Only include keys for fields that
|
||||||
// actually changed; explicit `null` clears description / flips
|
// actually changed; explicit `null` clears description / flips
|
||||||
// recurring→one-shot.
|
// recurring→one-shot. `targets_add` / `targets_remove` only
|
||||||
|
// populated when non-empty so the wire body stays minimal.
|
||||||
const patch = {};
|
const patch = {};
|
||||||
if (newBody !== s.body) patch.body = newBody;
|
if (newBody !== s.body) patch.body = newBody;
|
||||||
if (newDescription !== (s.description || '')) {
|
if (newDescription !== (s.description || '')) {
|
||||||
|
|
@ -2568,6 +2625,8 @@ window.marked = marked;
|
||||||
if (newIntervalSeconds !== (s.interval_seconds || null)) {
|
if (newIntervalSeconds !== (s.interval_seconds || null)) {
|
||||||
patch.interval_seconds = newIntervalSeconds;
|
patch.interval_seconds = newIntervalSeconds;
|
||||||
}
|
}
|
||||||
|
if (targetsAdd.length) patch.targets_add = targetsAdd;
|
||||||
|
if (targetsRemove.length) patch.targets_remove = targetsRemove;
|
||||||
if (!Object.keys(patch).length) {
|
if (!Object.keys(patch).length) {
|
||||||
// No-op submit. Treat as "close edit form".
|
// No-op submit. Treat as "close edit form".
|
||||||
editingSchedules.delete(s.id);
|
editingSchedules.delete(s.id);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue