mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user