fix(web): allow editing existing routines from RoutinesSection (#1884)

The Routines list only exposed Run now / Pause / History / Delete, so any
change to a routine required deleting and recreating it. The daemon already
serves PATCH /api/routines/:id; only the web entry point was missing.

Add an Edit button on each routine row that pre-populates the create form
from the stored schedule and target via a new formFromRoutine helper, and
branch the submit handler on editingId so it PATCHes /api/routines/:id
with the updated fields instead of POSTing a new routine.

Fixes #1373
This commit is contained in:
Ethan Guo
2026-05-17 15:02:54 +08:00
committed by GitHub
parent fb57ef2fc3
commit a6288cec3f
2 changed files with 130 additions and 5 deletions
+55 -5
View File
@@ -193,6 +193,34 @@ function emptyForm(): FormState {
};
}
function formFromRoutine(routine: Routine): FormState {
const base = emptyForm();
const schedule = routine.schedule;
if (schedule.kind === 'hourly') {
base.kind = 'hourly';
base.minute = schedule.minute;
} else if (schedule.kind === 'weekly') {
base.kind = 'weekly';
base.weekday = schedule.weekday;
base.time = schedule.time;
base.timezone = schedule.timezone;
} else {
base.kind = schedule.kind;
base.time = schedule.time;
base.timezone = schedule.timezone;
}
if (routine.target.mode === 'reuse') {
base.mode = 'reuse';
base.projectId = routine.target.projectId;
} else {
base.mode = 'create_each_run';
base.projectId = '';
}
base.name = routine.name;
base.prompt = routine.prompt;
return base;
}
function buildSchedule(form: FormState): RoutineSchedule {
if (form.kind === 'hourly') {
return { kind: 'hourly', minute: form.minute };
@@ -382,6 +410,7 @@ export function RoutinesSection({ onClose }: RoutinesSectionProps) {
const [error, setError] = useState<string | null>(null);
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState<FormState>(emptyForm);
const [editingId, setEditingId] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
@@ -450,16 +479,22 @@ export function RoutinesSection({ onClose }: RoutinesSectionProps) {
target,
enabled: true,
};
const res = await fetch('/api/routines', {
method: 'POST',
const isEdit = editingId !== null;
const url = isEdit ? `/api/routines/${editingId}` : '/api/routines';
const payload = isEdit
? { name: body.name, prompt: body.prompt, schedule: body.schedule, target: body.target }
: body;
const res = await fetch(url, {
method: isEdit ? 'PATCH' : 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
body: JSON.stringify(payload),
});
if (!res.ok) {
const j = await res.json().catch(() => ({}));
throw new Error(j.error || `create failed: ${res.status}`);
throw new Error(j.error || `${isEdit ? 'update' : 'create'} failed: ${res.status}`);
}
setShowForm(false);
setEditingId(null);
setForm(emptyForm());
void refresh();
} catch (err) {
@@ -629,13 +664,16 @@ export function RoutinesSection({ onClose }: RoutinesSectionProps) {
className="btn"
onClick={() => {
setShowForm(false);
setEditingId(null);
setForm(emptyForm());
}}
>
Cancel
</button>
<button type="submit" className="btn btn-primary" disabled={submitting}>
{submitting ? 'Creating…' : 'Create'}
{editingId
? submitting ? 'Saving…' : 'Save'
: submitting ? 'Creating…' : 'Create'}
</button>
</div>
</form>
@@ -692,6 +730,18 @@ export function RoutinesSection({ onClose }: RoutinesSectionProps) {
>
Run now
</button>
<button
type="button"
className="btn"
onClick={() => {
setForm(formFromRoutine(r));
setEditingId(r.id);
setShowForm(true);
}}
disabled={isBusy}
>
Edit
</button>
<button
type="button"
className="btn"
@@ -678,6 +678,81 @@ describe('RoutinesSection', () => {
expect(within(card).queryByRole('button', { name: 'Resume' })).toBeNull();
});
it('edits an existing routine and PATCHes the updated fields', async () => {
let routines: Routine[] = [{
id: 'routine-1',
name: 'Morning briefing',
prompt: 'Morning summary',
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
target: { mode: 'reuse', projectId: 'proj-1' },
skillId: null,
agentId: null,
enabled: true,
nextRunAt: Date.now() + 3600_000,
lastRun: null,
createdAt: Date.now(),
updatedAt: Date.now(),
}];
const patchBodies: unknown[] = [];
globalThis.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input.toString();
if (url === '/api/routines' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ routines }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/projects' && (!init || init.method === undefined)) {
return new Response(JSON.stringify({ projects: [{ id: 'proj-1', name: 'Routine Test Project' }] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/routines/routine-1' && init?.method === 'PATCH') {
const body = JSON.parse(String(init.body));
patchBodies.push(body);
const current = routines[0]!;
routines = [{
...current,
name: body.name ?? current.name,
prompt: body.prompt ?? current.prompt,
schedule: body.schedule ?? current.schedule,
target: body.target ?? current.target,
updatedAt: Date.now(),
}];
return new Response(JSON.stringify({ routine: routines[0] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
render(<RoutinesSection />);
const row = (await screen.findByText('Morning briefing')).closest('li')!;
fireEvent.click(within(row).getByRole('button', { name: 'Edit' }));
const nameInput = screen.getByLabelText('Name') as HTMLInputElement;
expect(nameInput.value).toBe('Morning briefing');
const promptInput = screen.getByLabelText('Prompt') as HTMLTextAreaElement;
expect(promptInput.value).toBe('Morning summary');
fireEvent.change(nameInput, { target: { value: 'Renamed briefing' } });
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(screen.getByText('Renamed briefing')).toBeTruthy();
});
expect(patchBodies).toHaveLength(1);
const body = patchBodies[0] as Record<string, unknown>;
expect(body.name).toBe('Renamed briefing');
expect(body.prompt).toBe('Morning summary');
expect(body.schedule).toEqual({ kind: 'daily', time: '09:00', timezone: 'UTC' });
expect(body.target).toEqual({ mode: 'reuse', projectId: 'proj-1' });
});
it('shows an error alert when deleting a routine fails', async () => {
const routines: Routine[] = [{
id: 'routine-1',