dashboard: inline edit form on schedule rows (closes #474)
Per damocles's PATCH /api/schedules/{id} backend (#475), each
non-cancelled schedule row gets a `✎ edit` button that toggles
an inline form pre-filled with current values. Editable:
- body (textarea)
- description (blank to clear)
- next-fire (datetime-local)
- interval (shared composer from #466 — all-zero flips to one-shot)
Targets stay immutable per the design call with damocles:
per-target last-result history is keyed on them; cancel + new
schedule is the documented retarget workaround. The form
surfaces the active target list as a read-only note explaining
this.
Submit semantics — the form computes a PATCH diff against the
original schedule and only includes keys for fields that
actually changed. Blank description → `null` (clear), all-zero
interval on a recurring schedule → `null` (flip to one-shot).
No-op submit (no fields changed) just closes the edit form.
Refactored the interval composer (#466) into a shared
`buildIntervalComposer({ label, namePrefix, initialSeconds })`
helper so the new-schedule and edit-schedule forms use the same
chip/d/h/m/s/preview widget. New-schedule form behavior
unchanged; edit-form input names are prefixed `edit_interval_`
to avoid FormData collisions when both happen to mount.
State survives state-poll re-renders via `editingSchedules`
(Set of ids being edited) + `scheduleEditCarry` (per-id
mid-edit values) — same pattern the new-schedule form uses
with `readScheduleFormCarry`.
This commit is contained in:
parent
d0a1ee037b
commit
dd92ef07fa
3 changed files with 356 additions and 95 deletions
|
|
@ -227,10 +227,14 @@ they share enough conceptual ground to live together).
|
|||
**N3W SCH3DUL3 / QU3U3D SCH3DUL3S** — operator-managed
|
||||
scheduled prompts (#444 / #459). Lists every schedule with
|
||||
its description, targets, body, recurrence interval, next-fire
|
||||
time, and per-target last-result; a `CANC3L` button cancels
|
||||
the whole schedule (`POST /api/schedules/{id}/cancel`), and
|
||||
individual target chips have their own cancel links. An inline
|
||||
creation form lets the operator queue a new schedule directly:
|
||||
time, and per-target last-result. Per-row controls: 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
|
||||
`CANC3L` button cancels the whole schedule
|
||||
(`POST /api/schedules/{id}/cancel`). Individual target chips
|
||||
have their own cancel links. An inline creation form lets
|
||||
the operator queue a new schedule directly:
|
||||
targets (multi-select checkboxes drawn from live container
|
||||
names + `operator` + `manager`), prompt body (textarea),
|
||||
first-fire datetime-local (pre-filled to 5 minutes from now),
|
||||
|
|
@ -485,6 +489,14 @@ not ours.
|
|||
`{ targets, body, first_fire_at_unix, interval_seconds?, description? }`.
|
||||
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? }`.
|
||||
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}/cancel` — cancel a schedule. Body
|
||||
`{ targets?: ["name", …] }` cancels just those recipients;
|
||||
absent or empty body cancels the whole schedule.
|
||||
|
|
|
|||
|
|
@ -1926,6 +1926,13 @@ window.marked = marked;
|
|||
// Local cache lets `refreshTabCounts` show the active count without
|
||||
// re-fetching every second.
|
||||
let schedulesState = [];
|
||||
// #474 — schedule ids whose inline edit form is currently open.
|
||||
// The set survives across refreshSchedules() calls so a state
|
||||
// poll doesn't yank the form out from under the operator. Per-id
|
||||
// mid-edit carry sits in `scheduleEditCarry` so unsaved typing
|
||||
// also rides the refresh.
|
||||
const editingSchedules = new Set();
|
||||
const scheduleEditCarry = new Map();
|
||||
async function refreshSchedules() {
|
||||
const listRoot = $('schedules-section');
|
||||
if (!listRoot) return;
|
||||
|
|
@ -1955,6 +1962,106 @@ window.marked = marked;
|
|||
}
|
||||
return n;
|
||||
}
|
||||
// Interval composer (#466) — shared between the new-schedule form
|
||||
// and the edit-schedule form (#474). Builds the preset chip row +
|
||||
// d/h/m/s sub-fields + live preview, and returns helpers to read
|
||||
// and write the value.
|
||||
//
|
||||
// The composer carries no opinion on what "0 / blank" means at the
|
||||
// semantic layer: it just reports the integer total. Callers decide
|
||||
// whether to treat 0 as `null` (one-shot) for their own submit path.
|
||||
function buildIntervalComposer({
|
||||
label = 'interval (blank / all-zero = one-shot)',
|
||||
namePrefix = 'interval_',
|
||||
initialSeconds = 0,
|
||||
} = {}) {
|
||||
const wrapper = el('label', { class: 'schedule-field' },
|
||||
el('span', { class: 'schedule-field-label' }, label));
|
||||
const presets = [
|
||||
['1m', 60], ['5m', 300], ['15m', 900], ['30m', 1800],
|
||||
['1h', 3600], ['6h', 21600], ['12h', 43200],
|
||||
['1d', 86400], ['7d', 604800],
|
||||
];
|
||||
const presetsRow = el('div', { class: 'schedule-interval-presets' });
|
||||
for (const [presetLabel, secs] of presets) {
|
||||
presetsRow.append(el('button', {
|
||||
type: 'button',
|
||||
class: 'btn btn-interval-preset',
|
||||
'data-secs': String(secs),
|
||||
}, presetLabel));
|
||||
}
|
||||
presetsRow.append(el('button', {
|
||||
type: 'button',
|
||||
class: 'btn btn-interval-preset btn-interval-oneshot',
|
||||
'data-secs': '0',
|
||||
}, 'one-shot'));
|
||||
wrapper.append(presetsRow);
|
||||
|
||||
const partsRow = el('div', { class: 'schedule-interval-parts' });
|
||||
const mkPart = (suffix, unit) => {
|
||||
const inp = el('input', {
|
||||
type: 'number', name: namePrefix + suffix, min: '0', step: '1', placeholder: '0',
|
||||
class: 'schedule-interval-num',
|
||||
});
|
||||
partsRow.append(inp, el('span', { class: 'schedule-interval-unit' }, unit));
|
||||
return inp;
|
||||
};
|
||||
const dInp = mkPart('d', 'd');
|
||||
const hInp = mkPart('h', 'h');
|
||||
const mInp = mkPart('m', 'm');
|
||||
const sInp = mkPart('s', 's');
|
||||
wrapper.append(partsRow);
|
||||
|
||||
const preview = el('div', { class: 'schedule-interval-preview', 'aria-live': 'polite' });
|
||||
wrapper.append(preview);
|
||||
|
||||
function getSeconds() {
|
||||
const n = (v) => {
|
||||
const x = parseInt(v, 10);
|
||||
return Number.isFinite(x) && x > 0 ? x : 0;
|
||||
};
|
||||
return n(dInp.value) * 86400 + n(hInp.value) * 3600
|
||||
+ n(mInp.value) * 60 + n(sInp.value);
|
||||
}
|
||||
function fillFromSeconds(total) {
|
||||
const t = Math.max(0, Math.floor(total));
|
||||
const d = Math.floor(t / 86400);
|
||||
const h = Math.floor((t % 86400) / 3600);
|
||||
const m = Math.floor((t % 3600) / 60);
|
||||
const s = t % 60;
|
||||
dInp.value = d > 0 ? String(d) : '';
|
||||
hInp.value = h > 0 ? String(h) : '';
|
||||
mInp.value = m > 0 ? String(m) : '';
|
||||
sInp.value = s > 0 ? String(s) : '';
|
||||
}
|
||||
function setParts({ d, h, m, s }) {
|
||||
if (d !== undefined) dInp.value = d;
|
||||
if (h !== undefined) hInp.value = h;
|
||||
if (m !== undefined) mInp.value = m;
|
||||
if (s !== undefined) sInp.value = s;
|
||||
}
|
||||
function updatePreview() {
|
||||
const total = getSeconds();
|
||||
preview.textContent = total > 0
|
||||
? '↻ every ' + fmtDuration(total)
|
||||
: 'one-shot (fires once at first-fire time)';
|
||||
preview.classList.toggle('schedule-interval-preview-oneshot', total === 0);
|
||||
}
|
||||
for (const inp of [dInp, hInp, mInp, sInp]) {
|
||||
inp.addEventListener('input', updatePreview);
|
||||
}
|
||||
for (const b of presetsRow.querySelectorAll('button[data-secs]')) {
|
||||
b.addEventListener('click', () => {
|
||||
fillFromSeconds(parseInt(b.getAttribute('data-secs'), 10) || 0);
|
||||
updatePreview();
|
||||
});
|
||||
}
|
||||
if (initialSeconds > 0) fillFromSeconds(initialSeconds);
|
||||
updatePreview();
|
||||
|
||||
return { wrapper, getSeconds, fillFromSeconds, setParts, updatePreview };
|
||||
}
|
||||
|
||||
function renderScheduleNewForm() {
|
||||
const root = $('schedule-new-section');
|
||||
if (!root) return;
|
||||
|
|
@ -2019,104 +2126,29 @@ window.marked = marked;
|
|||
firstFireLabel.append(firstFireInput);
|
||||
form_.append(firstFireLabel);
|
||||
|
||||
// Interval composer (#466). Replaces the raw seconds input with a
|
||||
// preset chip row + d/h/m/s sub-fields + a live preview, so the
|
||||
// operator never has to multiply hours-to-seconds by hand. Total
|
||||
// of zero (all fields blank or 0) means one-shot — same semantic
|
||||
// the backend already expects when interval_seconds is null.
|
||||
const intervalLabel = el('label', { class: 'schedule-field' },
|
||||
el('span', { class: 'schedule-field-label' }, 'interval (blank / all-zero = one-shot)'));
|
||||
const presets = [
|
||||
['1m', 60], ['5m', 300], ['15m', 900], ['30m', 1800],
|
||||
['1h', 3600], ['6h', 21600], ['12h', 43200],
|
||||
['1d', 86400], ['7d', 604800],
|
||||
];
|
||||
const presetsRow = el('div', { class: 'schedule-interval-presets' });
|
||||
for (const [label, secs] of presets) {
|
||||
const b = el('button', {
|
||||
type: 'button',
|
||||
class: 'btn btn-interval-preset',
|
||||
'data-secs': String(secs),
|
||||
}, label);
|
||||
presetsRow.append(b);
|
||||
}
|
||||
const oneShotBtn = el('button', {
|
||||
type: 'button',
|
||||
class: 'btn btn-interval-preset btn-interval-oneshot',
|
||||
'data-secs': '0',
|
||||
}, 'one-shot');
|
||||
presetsRow.append(oneShotBtn);
|
||||
intervalLabel.append(presetsRow);
|
||||
|
||||
const partsRow = el('div', { class: 'schedule-interval-parts' });
|
||||
const mkPart = (name, unit) => {
|
||||
const inp = el('input', {
|
||||
type: 'number', name, min: '0', step: '1', placeholder: '0',
|
||||
class: 'schedule-interval-num',
|
||||
});
|
||||
partsRow.append(inp, el('span', { class: 'schedule-interval-unit' }, unit));
|
||||
return inp;
|
||||
};
|
||||
const dInp = mkPart('interval_d', 'd');
|
||||
const hInp = mkPart('interval_h', 'h');
|
||||
const mInp = mkPart('interval_m', 'm');
|
||||
const sInp = mkPart('interval_s', 's');
|
||||
intervalLabel.append(partsRow);
|
||||
|
||||
const preview = el('div', { class: 'schedule-interval-preview', 'aria-live': 'polite' });
|
||||
intervalLabel.append(preview);
|
||||
form_.append(intervalLabel);
|
||||
|
||||
function readPartsSecs() {
|
||||
const n = (v) => {
|
||||
const x = parseInt(v, 10);
|
||||
return Number.isFinite(x) && x > 0 ? x : 0;
|
||||
};
|
||||
return n(dInp.value) * 86400 + n(hInp.value) * 3600
|
||||
+ n(mInp.value) * 60 + n(sInp.value);
|
||||
}
|
||||
function fillParts(total) {
|
||||
const t = Math.max(0, Math.floor(total));
|
||||
const d = Math.floor(t / 86400);
|
||||
const h = Math.floor((t % 86400) / 3600);
|
||||
const m = Math.floor((t % 3600) / 60);
|
||||
const s = t % 60;
|
||||
dInp.value = d > 0 ? String(d) : '';
|
||||
hInp.value = h > 0 ? String(h) : '';
|
||||
mInp.value = m > 0 ? String(m) : '';
|
||||
sInp.value = s > 0 ? String(s) : '';
|
||||
}
|
||||
function updatePreview() {
|
||||
const total = readPartsSecs();
|
||||
preview.textContent = total > 0
|
||||
? '↻ every ' + fmtDuration(total)
|
||||
: 'one-shot (fires once at first-fire time)';
|
||||
preview.classList.toggle('schedule-interval-preview-oneshot', total === 0);
|
||||
}
|
||||
for (const inp of [dInp, hInp, mInp, sInp]) {
|
||||
inp.addEventListener('input', updatePreview);
|
||||
}
|
||||
for (const b of presetsRow.querySelectorAll('button[data-secs]')) {
|
||||
b.addEventListener('click', () => {
|
||||
fillParts(parseInt(b.getAttribute('data-secs'), 10) || 0);
|
||||
updatePreview();
|
||||
});
|
||||
}
|
||||
|
||||
// Interval composer (#466). See `buildIntervalComposer` below —
|
||||
// shared between the new-schedule form and the edit-schedule form
|
||||
// (#474) so the same chip/d/h/m/s widget works for both flows.
|
||||
const intervalCx = buildIntervalComposer({
|
||||
label: 'interval (blank / all-zero = one-shot)',
|
||||
namePrefix: 'interval_',
|
||||
initialSeconds: 0,
|
||||
});
|
||||
form_.append(intervalCx.wrapper);
|
||||
// Restore from carry. Prefer the new split fields; fall back to
|
||||
// legacy single seconds value if the page was reloaded mid-edit
|
||||
// against an older bundle.
|
||||
if (carry.interval_d || carry.interval_h
|
||||
|| carry.interval_m || carry.interval_s) {
|
||||
dInp.value = carry.interval_d;
|
||||
hInp.value = carry.interval_h;
|
||||
mInp.value = carry.interval_m;
|
||||
sInp.value = carry.interval_s;
|
||||
intervalCx.setParts({
|
||||
d: carry.interval_d, h: carry.interval_h,
|
||||
m: carry.interval_m, s: carry.interval_s,
|
||||
});
|
||||
} else if (carry.interval) {
|
||||
const legacy = parseInt(carry.interval, 10);
|
||||
if (Number.isFinite(legacy) && legacy > 0) fillParts(legacy);
|
||||
if (Number.isFinite(legacy) && legacy > 0) intervalCx.fillFromSeconds(legacy);
|
||||
}
|
||||
updatePreview();
|
||||
intervalCx.updatePreview();
|
||||
|
||||
const descLabel = el('label', { class: 'schedule-field' },
|
||||
el('span', { class: 'schedule-field-label' }, 'description (optional)'));
|
||||
|
|
@ -2313,14 +2345,212 @@ window.marked = marked;
|
|||
|
||||
if (!cancelled) {
|
||||
const actions = el('div', { class: 'schedule-actions' });
|
||||
const editBtn = el('button', { type: 'button', class: 'btn btn-edit-schedule' },
|
||||
editingSchedules.has(s.id) ? '✎ close edit' : '✎ edit');
|
||||
editBtn.title = 'edit body / description / interval / next-fire (targets stay immutable)';
|
||||
editBtn.addEventListener('click', () => {
|
||||
if (editingSchedules.has(s.id)) {
|
||||
editingSchedules.delete(s.id);
|
||||
scheduleEditCarry.delete(s.id);
|
||||
} else {
|
||||
editingSchedules.add(s.id);
|
||||
}
|
||||
renderSchedulesList();
|
||||
});
|
||||
actions.append(editBtn);
|
||||
const cancelAll = el('button', { type: 'button', class: 'btn btn-deny' }, '✕ cancel all');
|
||||
cancelAll.title = 'cancel the whole schedule';
|
||||
cancelAll.addEventListener('click', () => cancelScheduleAll(s.id));
|
||||
actions.append(cancelAll);
|
||||
li.append(actions);
|
||||
if (editingSchedules.has(s.id)) {
|
||||
li.append(renderScheduleEditForm(s));
|
||||
}
|
||||
}
|
||||
return li;
|
||||
}
|
||||
|
||||
// #474 — inline edit form. Renders inside the schedule row when the
|
||||
// row's `✎ edit` button is toggled on. Pre-filled with current
|
||||
// values; submit PATCHes /api/schedules/{id}. Targets stay
|
||||
// immutable (per damocles's backend; the workaround for retargeting
|
||||
// is cancel + new schedule). Mid-edit field values survive a
|
||||
// state-poll refresh via `scheduleEditCarry`.
|
||||
function renderScheduleEditForm(s) {
|
||||
const wrapper = el('div', { class: 'schedule-edit-form-wrapper' });
|
||||
const form_ = el('form', { class: 'schedule-edit-form' });
|
||||
form_.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
submitEditSchedule(s, form_);
|
||||
});
|
||||
|
||||
const carry = scheduleEditCarry.get(s.id) || {};
|
||||
|
||||
const bodyLabel = el('label', { class: 'schedule-field' },
|
||||
el('span', { class: 'schedule-field-label' }, 'body'));
|
||||
const bodyInput = el('textarea', { name: 'body', rows: '4', required: 'required' });
|
||||
bodyInput.value = carry.body !== undefined ? carry.body : s.body;
|
||||
bodyLabel.append(bodyInput);
|
||||
form_.append(bodyLabel);
|
||||
|
||||
const descLabel = el('label', { class: 'schedule-field' },
|
||||
el('span', { class: 'schedule-field-label' }, 'description (blank to clear)'));
|
||||
const descInput = el('input', { type: 'text', name: 'description' });
|
||||
descInput.value = carry.description !== undefined
|
||||
? carry.description
|
||||
: (s.description || '');
|
||||
descLabel.append(descInput);
|
||||
form_.append(descLabel);
|
||||
|
||||
const firstFireLabel = el('label', { class: 'schedule-field' },
|
||||
el('span', { class: 'schedule-field-label' }, 'next fire'));
|
||||
const firstFireInput = el('input', {
|
||||
type: 'datetime-local', name: 'next_fire', required: 'required',
|
||||
});
|
||||
firstFireInput.value = carry.next_fire !== undefined
|
||||
? carry.next_fire
|
||||
: isoForDatetimeLocal(new Date(s.next_fire_at_unix * 1000));
|
||||
firstFireLabel.append(firstFireInput);
|
||||
form_.append(firstFireLabel);
|
||||
|
||||
const intervalCx = buildIntervalComposer({
|
||||
label: 'interval (blank / all-zero = flip to one-shot)',
|
||||
namePrefix: 'edit_interval_',
|
||||
initialSeconds: 0,
|
||||
});
|
||||
form_.append(intervalCx.wrapper);
|
||||
if (carry.interval_d !== undefined || carry.interval_h !== undefined
|
||||
|| carry.interval_m !== undefined || carry.interval_s !== undefined) {
|
||||
intervalCx.setParts({
|
||||
d: carry.interval_d || '',
|
||||
h: carry.interval_h || '',
|
||||
m: carry.interval_m || '',
|
||||
s: carry.interval_s || '',
|
||||
});
|
||||
} else if (s.interval_seconds) {
|
||||
intervalCx.fillFromSeconds(s.interval_seconds);
|
||||
}
|
||||
intervalCx.updatePreview();
|
||||
|
||||
// Persist carry on every input so a refresh repaint preserves it.
|
||||
form_.addEventListener('input', () => {
|
||||
const fd = new FormData(form_);
|
||||
scheduleEditCarry.set(s.id, {
|
||||
body: String(fd.get('body') || ''),
|
||||
description: String(fd.get('description') || ''),
|
||||
next_fire: String(fd.get('next_fire') || ''),
|
||||
interval_d: String(fd.get('edit_interval_d') || ''),
|
||||
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 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(', ');
|
||||
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.'));
|
||||
|
||||
const actions = el('div', { class: 'schedule-actions' });
|
||||
const submit = el('button', { type: 'submit', class: 'btn btn-spawn' }, '✓ save changes');
|
||||
const cancelEdit = el('button', { type: 'button', class: 'btn' }, 'cancel');
|
||||
cancelEdit.addEventListener('click', () => {
|
||||
editingSchedules.delete(s.id);
|
||||
scheduleEditCarry.delete(s.id);
|
||||
renderSchedulesList();
|
||||
});
|
||||
actions.append(submit, cancelEdit);
|
||||
form_.append(actions);
|
||||
|
||||
wrapper.append(form_);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
async function submitEditSchedule(originalSchedule, form_) {
|
||||
const s = originalSchedule;
|
||||
const fd = new FormData(form_);
|
||||
const newBody = String(fd.get('body') || '').trim();
|
||||
const newDescription = String(fd.get('description') || '').trim();
|
||||
const newNextFireStr = String(fd.get('next_fire') || '');
|
||||
|
||||
if (!newBody) { alert('body must be non-empty'); return; }
|
||||
if (!newNextFireStr) { alert('next-fire timestamp is required'); return; }
|
||||
const newNextFireDate = new Date(newNextFireStr);
|
||||
if (Number.isNaN(newNextFireDate.getTime())) {
|
||||
alert('next-fire is not a valid datetime'); return;
|
||||
}
|
||||
const newNextFireUnix = Math.floor(newNextFireDate.getTime() / 1000);
|
||||
|
||||
// Compute interval total from the d/h/m/s fields (composer uses
|
||||
// `edit_interval_*` names in this form).
|
||||
const partSecs = (name, mult) => {
|
||||
const raw = String(fd.get(name) || '').trim();
|
||||
if (!raw) return 0;
|
||||
const n = parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 0) return NaN;
|
||||
return n * mult;
|
||||
};
|
||||
const partD = partSecs('edit_interval_d', 86400);
|
||||
const partH = partSecs('edit_interval_h', 3600);
|
||||
const partM = partSecs('edit_interval_m', 60);
|
||||
const partS = partSecs('edit_interval_s', 1);
|
||||
if ([partD, partH, partM, partS].some((v) => Number.isNaN(v))) {
|
||||
alert('interval fields must be non-negative integers');
|
||||
return;
|
||||
}
|
||||
const intervalTotal = partD + partH + partM + partS;
|
||||
const newIntervalSeconds = intervalTotal > 0 ? intervalTotal : null;
|
||||
|
||||
// Build the PATCH body. Only include keys for fields that
|
||||
// actually changed; explicit `null` clears description / flips
|
||||
// recurring→one-shot.
|
||||
const patch = {};
|
||||
if (newBody !== s.body) patch.body = newBody;
|
||||
if (newDescription !== (s.description || '')) {
|
||||
patch.description = newDescription || null;
|
||||
}
|
||||
if (newNextFireUnix !== s.next_fire_at_unix) {
|
||||
patch.next_fire_at_unix = newNextFireUnix;
|
||||
}
|
||||
if (newIntervalSeconds !== (s.interval_seconds || null)) {
|
||||
patch.interval_seconds = newIntervalSeconds;
|
||||
}
|
||||
if (!Object.keys(patch).length) {
|
||||
// No-op submit. Treat as "close edit form".
|
||||
editingSchedules.delete(s.id);
|
||||
scheduleEditCarry.delete(s.id);
|
||||
renderSchedulesList();
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = form_.querySelector('button[type="submit"]');
|
||||
const originalLabel = submitBtn ? submitBtn.textContent : '';
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'saving…'; }
|
||||
try {
|
||||
const resp = await fetch('/api/schedules/' + encodeURIComponent(s.id), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
alert('edit failed: http ' + resp.status + (text ? '\n\n' + text : ''));
|
||||
return;
|
||||
}
|
||||
editingSchedules.delete(s.id);
|
||||
scheduleEditCarry.delete(s.id);
|
||||
await refreshSchedules();
|
||||
} catch (err) {
|
||||
alert('edit failed: ' + err);
|
||||
} finally {
|
||||
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = originalLabel; }
|
||||
}
|
||||
}
|
||||
async function cancelScheduleAll(id) {
|
||||
if (!confirm(`cancel schedule #${id}? this stops all future fires for every target.`)) return;
|
||||
await postScheduleCancel(id, null);
|
||||
|
|
|
|||
|
|
@ -850,6 +850,10 @@ ul form.inline { display: inline-block; }
|
|||
.btn-start { color: var(--green); border-color: var(--green); font-size: 0.75em; padding: 0.15em 0.5em; margin-left: 0.6em; }
|
||||
.btn-talk { color: var(--cyan); border-color: var(--cyan); }
|
||||
.btn-spawn { color: var(--amber); border-color: var(--amber); }
|
||||
/* #474: inline edit button on each schedule row. Yellow reads as a
|
||||
parallel destructive-adjacent action (edit changes state, but
|
||||
isn't deletion). */
|
||||
.btn-edit-schedule { color: var(--yellow, #f9e2af); border-color: var(--yellow, #f9e2af); }
|
||||
.spawnform { display: flex; gap: 0.6em; align-items: stretch; margin: 0.5em 0; }
|
||||
.spawnform input {
|
||||
font-family: inherit;
|
||||
|
|
@ -1604,7 +1608,8 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
Cards show: id + source + due-in + cancel-all in the header,
|
||||
the prompt body, then a targets table with per-row cancel. */
|
||||
|
||||
.schedule-new-form {
|
||||
.schedule-new-form,
|
||||
.schedule-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6em;
|
||||
|
|
@ -1614,6 +1619,20 @@ body.flow-shell .tabbar .tab.active.tab-link {
|
|||
padding: 0.8em 1em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
/* #474 — inline edit form opens directly under the schedule row's
|
||||
actions strip, indented slightly so it visually nests under the
|
||||
row it edits. */
|
||||
.schedule-edit-form-wrapper {
|
||||
margin-top: 0.5em;
|
||||
padding-left: 0.5em;
|
||||
border-left: 2px solid var(--yellow, #f9e2af);
|
||||
}
|
||||
.schedule-edit-targets-note {
|
||||
color: var(--muted);
|
||||
font-style: italic;
|
||||
font-size: 0.85em;
|
||||
margin: 0.2em 0 0 0;
|
||||
}
|
||||
.schedule-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
Loading…
Reference in a new issue