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
|
||||
cadence; one-shots are consumed after the manual fire), an
|
||||
`✎ edit` button opens an inline edit form (#474 — body /
|
||||
description / interval / next-fire editable, targets stay
|
||||
immutable; submit PATCHes `/api/schedules/{id}`), and a
|
||||
description / interval / next-fire / targets all editable;
|
||||
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
|
||||
(`POST /api/schedules/{id}/cancel`). Individual target chips
|
||||
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
|
||||
(manager MCP `request_schedule_prompt`).
|
||||
- `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
|
||||
`description` / `interval_seconds` clears the field (so a
|
||||
recurring schedule flips to one-shot when `interval_seconds`
|
||||
is sent as `null`). Targets stay immutable — cancel + new
|
||||
schedule is the retarget workaround. Refuses cancelled rows;
|
||||
returns the updated `WireSchedule` on success.
|
||||
- `POST /api/schedules/{id}/fire-now` — operator-initiated manual
|
||||
fire (#467). Runs the per-target fan-out once immediately.
|
||||
Recurring schedules keep their cadence (manual fire is additive);
|
||||
one-shot schedules are consumed (cancelled afterwards). Manager
|
||||
surface: `fire_schedule_now(id)` MCP tool.
|
||||
is sent as `null`). `targets_add` is replace-on-conflict:
|
||||
re-adding a previously-cancelled target drops the tombstone
|
||||
and the target starts fresh (operator intent on re-add =
|
||||
"this target is active again"). `targets_remove` delegates
|
||||
to the same path as `cancel_targets` — tombstones preserve
|
||||
audit, parent schedule auto-cancels when no active targets
|
||||
remain. Refuses cancelled rows; returns the updated
|
||||
`WireSchedule` on success.
|
||||
- `POST /api/schedules/{id}/cancel` — cancel a schedule. Body
|
||||
`{ targets?: ["name", …] }` cancels just those recipients;
|
||||
absent or empty body cancels the whole schedule.
|
||||
|
|
|
|||
|
|
@ -2480,8 +2480,9 @@ window.marked = marked;
|
|||
}
|
||||
intervalCx.updatePreview();
|
||||
|
||||
// Persist carry on every input so a refresh repaint preserves it.
|
||||
form_.addEventListener('input', () => {
|
||||
// Persist carry on every input + checkbox change so a refresh
|
||||
// repaint preserves it.
|
||||
const saveCarry = () => {
|
||||
const fd = new FormData(form_);
|
||||
scheduleEditCarry.set(s.id, {
|
||||
body: String(fd.get('body') || ''),
|
||||
|
|
@ -2491,18 +2492,54 @@ window.marked = marked;
|
|||
interval_h: String(fd.get('edit_interval_h') || ''),
|
||||
interval_m: String(fd.get('edit_interval_m') || ''),
|
||||
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
|
||||
// immutable, cancel+new is the retarget workaround.
|
||||
const targetList = (s.targets || [])
|
||||
.filter((t) => !t.cancelled_at_unix)
|
||||
.map((t) => t.target)
|
||||
.join(', ');
|
||||
// Targets multi-select (#474 fast-follow). Mirrors the new-schedule
|
||||
// form's chip pattern: live container names + operator + manager,
|
||||
// pre-checked for whichever targets are currently active. Submit
|
||||
// diffs against the original set to populate `targets_add` /
|
||||
// `targets_remove` on the PATCH body. Cancelled tombstones aren't
|
||||
// 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' },
|
||||
'targets (' + (targetList || 'none active') + ') are immutable — '
|
||||
+ 'cancel + new schedule if you need a different recipient set.'));
|
||||
're-adding a previously cancelled target drops its history and starts fresh; '
|
||||
+ 'unchecking an active target cancels it.'));
|
||||
|
||||
const actions = el('div', { class: 'schedule-actions' });
|
||||
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 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
|
||||
// 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 = {};
|
||||
if (newBody !== s.body) patch.body = newBody;
|
||||
if (newDescription !== (s.description || '')) {
|
||||
|
|
@ -2568,6 +2625,8 @@ window.marked = marked;
|
|||
if (newIntervalSeconds !== (s.interval_seconds || null)) {
|
||||
patch.interval_seconds = newIntervalSeconds;
|
||||
}
|
||||
if (targetsAdd.length) patch.targets_add = targetsAdd;
|
||||
if (targetsRemove.length) patch.targets_remove = targetsRemove;
|
||||
if (!Object.keys(patch).length) {
|
||||
// No-op submit. Treat as "close edit form".
|
||||
editingSchedules.delete(s.id);
|
||||
|
|
|
|||
Loading…
Reference in a new issue