diff --git a/packages/views/autopilots/components/autopilot-detail-page.tsx b/packages/views/autopilots/components/autopilot-detail-page.tsx index fc59d5667..6c7f36dbd 100644 --- a/packages/views/autopilots/components/autopilot-detail-page.tsx +++ b/packages/views/autopilots/components/autopilot-detail-page.tsx @@ -64,6 +64,7 @@ import type { AgentTask } from "@multica/core/types/agent"; import { ReadonlyContent } from "../../editor"; import { TranscriptButton } from "../../common/task-transcript"; import { AutopilotDialog } from "./autopilot-dialog"; +import { EditScheduleTriggerDialog } from "./edit-schedule-trigger-dialog"; import { runNowToastKind, runNowBlockedKey } from "./run-now-toast"; import { WebhookPayloadPreview } from "./webhook-payload-preview"; import { WebhookDeliveriesSection } from "./webhook-deliveries-section"; @@ -255,13 +256,14 @@ function SkippedRunsGroup({ ); } -function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrigger; autopilotId: string; canWrite: boolean }) { +export function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrigger; autopilotId: string; canWrite: boolean }) { const { t, i18n } = useT("autopilots"); const describeSchedule = useDescribeSchedule(); const deleteTrigger = useDeleteAutopilotTrigger(); const rotateToken = useRotateAutopilotTriggerWebhookToken(); const [confirmOpen, setConfirmOpen] = useState(false); const [rotateOpen, setRotateOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); const [deleting, setDeleting] = useState(false); const handleDelete = async () => { @@ -335,6 +337,23 @@ function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrig ) : null; + // Schedule rows only: cron and timezone are the fields this dialog edits, and + // the API rejects them on any other kind. It rides alongside Delete so the + // row that states a schedule is also the row that can change it — without it + // an autopilot with two schedules has no editable schedule at all (MUL-7478). + const editButton = + canWrite && trigger.kind === "schedule" ? ( + + ) : null; + return (
@@ -372,7 +391,11 @@ function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrig )}
)} - {trigger.next_run_at && ( + {/* A disabled trigger keeps the next_run_at it had — the dispatcher + filters on `enabled` instead of clearing it — so the row would + otherwise carry the Disabled badge and a promise to run at 09:00 + in the same breath. The badge is the true one. */} + {trigger.next_run_at && trigger.enabled && (
{t(($) => $.trigger_row.next_label, { date: formatInTimeZone( @@ -408,7 +431,12 @@ function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrig
)} - {!showWebhookUrlRow && deleteButton} + {!showWebhookUrlRow && ( +
+ {editButton} + {deleteButton} +
+ )} { if (!v && !deleting) setConfirmOpen(false); }}> @@ -433,6 +461,12 @@ function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrig + { if (!v && !rotateToken.isPending) setRotateOpen(false); }}> diff --git a/packages/views/autopilots/components/autopilot-dialog.schedule.test.tsx b/packages/views/autopilots/components/autopilot-dialog.schedule.test.tsx index 8f3acfa37..432c7bbad 100644 --- a/packages/views/autopilots/components/autopilot-dialog.schedule.test.tsx +++ b/packages/views/autopilots/components/autopilot-dialog.schedule.test.tsx @@ -262,3 +262,66 @@ describe("AutopilotDialog schedule section on an autopilot that has one", () => expect(mockCreateTrigger).not.toHaveBeenCalled(); }); }); + +// Regression cover for MUL-7478: the schedule panel counted triggers of every +// kind, so a 1 schedule + 1 webhook autopilot — where exactly one row can take +// a cron and the write below names it — was locked out of schedule editing +// entirely, with a notice pointing at a detail page that had no editor either. +describe("AutopilotDialog schedule section on an autopilot with several triggers", () => { + beforeEach(() => { + mockUpdateAutopilot.mockReset().mockResolvedValue({ id: AUTOPILOT_ID }); + mockCreateTrigger.mockReset().mockResolvedValue({ id: "trg-new" }); + mockUpdateTrigger.mockReset().mockResolvedValue({ id: "trg-sched" }); + }); + + it("edits the one schedule of an autopilot that also has a webhook", async () => { + const user = userEvent.setup(); + renderEditDialog([ + trigger({ id: "trg-sched" }), + trigger({ id: "trg-hook", kind: "webhook", cron_expression: null, timezone: null }), + ]); + + // The stored schedule, live — not the locked notice a second trigger of any + // kind used to produce. + expect(screen.getByTestId("timezone-picker")).toHaveTextContent("Asia/Shanghai"); + expect(screen.queryByText(/Close this dialog/)).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "At an interval" })); + await user.click(saveButton()); + + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + // The schedule row, never the webhook one: the API rejects a cron on any + // other kind, and rotating the webhook's URL out from under its callers + // would be the wrong write to guess at. + expect(mockUpdateTrigger.mock.calls[0]?.[0]).toMatchObject({ + autopilotId: AUTOPILOT_ID, + triggerId: "trg-sched", + }); + expect(mockCreateTrigger).not.toHaveBeenCalled(); + }); + + it("states which schedules to go and edit instead of showing the first of them", async () => { + const user = userEvent.setup(); + renderEditDialog([ + trigger({ id: "trg-morning" }), + trigger({ id: "trg-evening", cron_expression: "TZ=Asia/Shanghai 0 18 * * *" }), + ]); + + expect( + screen.getByText( + "This autopilot has 2 schedules. Close this dialog and edit each one under Triggers below.", + ), + ).toBeInTheDocument(); + // A single-value editor cannot show two schedules, and a disabled one + // showing the first is still the half-truth a reader would set a clock by. + expect(screen.queryByTestId("timezone-picker")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "At an interval" })).not.toBeInTheDocument(); + + await user.click(saveButton()); + + // Other fields still save; the schedules are left to the trigger rows. + await waitFor(() => expect(mockUpdateAutopilot).toHaveBeenCalledTimes(1)); + expect(mockUpdateTrigger).not.toHaveBeenCalled(); + expect(mockCreateTrigger).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/views/autopilots/components/autopilot-dialog.tsx b/packages/views/autopilots/components/autopilot-dialog.tsx index c6ba1fbc4..1c761da6a 100644 --- a/packages/views/autopilots/components/autopilot-dialog.tsx +++ b/packages/views/autopilots/components/autopilot-dialog.tsx @@ -227,14 +227,22 @@ export function AutopilotDialog(props: AutopilotDialogProps) { // above. Null means there is none to patch, so the write creates one. const scheduleTriggerIdRef = useRef(existingSchedule?.id ?? null); - const triggerCount = isCreate ? 0 : props.triggers.length; - const schedulePillDisabled = !isCreate && triggerCount >= 2; + // Only SCHEDULE rows can make this panel ambiguous, so only they are counted. + // Counting every kind locked a 1 schedule + 1 webhook autopilot (MUL-7478), + // where `existingSchedule` above names exactly one row and the write below + // has nowhere else to land. Two schedules is the real ambiguity: this editor + // holds one `ScheduleConfig`, so it would show the first row as if it were + // the whole story and save would silently rewrite that one alone. + const scheduleTriggerCount = isCreate + ? 0 + : props.triggers.filter((trig) => trig.kind === "schedule").length; + const schedulePillDisabled = !isCreate && scheduleTriggerCount >= 2; // The manual-autopilot empty state, and the only path to a first schedule - // from this dialog. Skipped when the panel is locked (2+ triggers), which - // keeps that case rendering exactly the disabled editor it always has. + // from this dialog. A locked panel never reaches it: locking means two + // schedules exist, so `existingSchedule` is non-null whenever it is true. const showScheduleEmptyState = - !isCreate && existingSchedule === null && !scheduleAdded && !schedulePillDisabled; + !isCreate && existingSchedule === null && !scheduleAdded; const selectedAssignee = useMemo(() => { if (!assigneeId) return null; @@ -649,7 +657,9 @@ export function AutopilotDialog(props: AutopilotDialogProps) { {triggerKind === "schedule" ? (
{t(($) => $.dialog.section_schedule)} - {showScheduleEmptyState ? ( + {schedulePillDisabled ? ( + + ) : showScheduleEmptyState ? ( setScheduleAdded(true)} /> ) : ( /* No `onValidityChange` / `clearRejection` here, unlike the @@ -666,12 +676,7 @@ export function AutopilotDialog(props: AutopilotDialogProps) { // over the network and then writes the schedule it read before // that round trip, so an edit made in between would be dropped // on the floor with a success toast over it. - disabled={schedulePillDisabled || submitting} - disabledReason={ - schedulePillDisabled - ? t(($) => $.dialog.schedule_disabled_reason) - : undefined - } + disabled={submitting} /> )}
@@ -964,6 +969,22 @@ function SubscribersSection({ } +// The panel cannot speak for two schedules, and a disabled editor showing the +// first of them is still half a truth — the one the reader would set their +// clock by. It says what it cannot do and where the reader can: this dialog +// only ever opens from the detail page, so the Triggers list is already on +// screen behind it. +function ScheduleMultipleNotice({ count }: { count: number }) { + const { t } = useT("autopilots"); + return ( +
+

+ {t(($) => $.dialog.schedule_multiple_notice, { count })} +

+
+ ); +} + // The schedule section of an autopilot that has none. Mirrors the detail // page's trigger empty state — a dashed card that states the autopilot is // manual — so the two surfaces agree on what "no schedule" looks like instead diff --git a/packages/views/autopilots/components/edit-schedule-trigger-dialog.test.tsx b/packages/views/autopilots/components/edit-schedule-trigger-dialog.test.tsx new file mode 100644 index 000000000..352cead43 --- /dev/null +++ b/packages/views/autopilots/components/edit-schedule-trigger-dialog.test.tsx @@ -0,0 +1,252 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { AutopilotTrigger } from "@multica/core/types"; +import { renderWithI18n } from "../../test/i18n"; + +// The editor a trigger row opens (MUL-7478). Before it, an existing schedule +// could only be deleted and recreated: the autopilot dialog's panel speaks for +// one schedule, and the detail page listed triggers read-only. + +const mockUpdateTrigger = vi.hoisted(() => vi.fn()); + +vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-test" })); + +// The submit path validates over the network before it writes. Parking that +// round trip holds the dialog mid-flight, in the window a label typed after +// Save used to fall into. +const preview = vi.hoisted(() => ({ release: null as null | (() => void), hold: false })); + +vi.mock("@multica/core/autopilots/queries", () => ({ + cronPreviewOptions: (wsId: string, expr: string, tz: string) => ({ + queryKey: ["cron-preview", wsId, expr, tz], + queryFn: async () => { + if (preview.hold) { + await new Promise((resolve) => { + preview.release = resolve; + }); + } + return { next_runs: ["2126-07-14T01:00:00Z"] }; + }, + retry: false, + }), +})); + +vi.mock("@multica/core/autopilots/mutations", () => ({ + useUpdateAutopilotTrigger: () => ({ mutateAsync: mockUpdateTrigger }), +})); + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +vi.mock("./pickers/timezone-picker", () => ({ + TimezonePicker: ({ value }: { value: string }) =>
{value}
, +})); + +import { EditScheduleTriggerDialog } from "./edit-schedule-trigger-dialog"; + +const AUTOPILOT_ID = "ap-1"; + +function trigger(overrides: Partial = {}): AutopilotTrigger { + return { + id: "trg-evening", + autopilot_id: AUTOPILOT_ID, + kind: "schedule", + enabled: true, + cron_expression: "TZ=Asia/Bangkok 0 */3 * * *", + timezone: "Asia/Bangkok", + next_run_at: null, + webhook_token: null, + label: null, + last_fired_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +function renderDialog(trig: AutopilotTrigger = trigger()) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const onOpenChange = vi.fn(); + const tree = (next: AutopilotTrigger) => ( + + + + ); + const result = renderWithI18n(tree(trig)); + // The detail query refreshing under an open dialog: new props, same mount. + return { ...result, onOpenChange, refreshProps: (next: AutopilotTrigger) => result.rerender(tree(next)) }; +} + +const saveButton = () => screen.getByRole("button", { name: "Save" }); +const labelInput = () => screen.getByPlaceholderText("e.g. Weekday morning"); +const enabledSwitch = () => screen.getByRole("switch", { name: "Enabled" }); + +beforeEach(() => { + mockUpdateTrigger.mockReset().mockResolvedValue({ id: "trg-evening" }); + preview.hold = false; + preview.release = null; +}); + +describe("EditScheduleTriggerDialog", () => { + it("opens on the schedule the row already runs, not on a default", () => { + renderDialog(); + + // The stored zone and interval, read back from the row — seeding the editor + // with its own 09:00 default would be a proposal dressed as the trigger's + // state, which is how MUL-5649 lost a save under a success toast. + expect(screen.getByTestId("timezone-picker")).toHaveTextContent("Asia/Bangkok"); + expect(screen.getByRole("button", { name: "At an interval", pressed: true })).toBeInTheDocument(); + expect(screen.getByDisplayValue("3")).toBeInTheDocument(); + }); + + it("patches this trigger alone, carrying the zone with the expression", async () => { + const user = userEvent.setup(); + const { onOpenChange } = renderDialog(); + + await user.click(screen.getByRole("button", { name: "At a time" })); + await user.click(saveButton()); + + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + const patch = mockUpdateTrigger.mock.calls[0]?.[0]; + expect(patch).toMatchObject({ + autopilotId: AUTOPILOT_ID, + triggerId: "trg-evening", + timezone: "Asia/Bangkok", + }); + expect(patch.cron_expression).toContain("Asia/Bangkok"); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("keeps the dialog open when the write fails, with the server's reason", async () => { + const user = userEvent.setup(); + const { onOpenChange } = renderDialog(); + mockUpdateTrigger.mockRejectedValueOnce(new Error("cron_expression is invalid")); + + await user.click(screen.getByRole("button", { name: "At a time" })); + await user.click(saveButton()); + + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + const { toast } = await import("sonner"); + expect(toast.error).toHaveBeenCalledWith("cron_expression is invalid"); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); +}); + +// The PATCH is partial on purpose. A changed cron / timezone / enabled reads +// server-side as a substantive edit: it republishes the rule version and moves +// this trigger's accountability to whoever saved (`UpdateAutopilotTrigger` in +// server/internal/handler/autopilot.go, MUL-4302). Since `parseCron` → `toCron` +// hands an untouched schedule back normalized — `TZ=` prefix and all, textually +// different from the stored row — resending it would make a rename look like a +// schedule change and carry that responsibility along with it. +describe("EditScheduleTriggerDialog sends only what the user changed", () => { + it("sends the label alone when only the label was touched", async () => { + const user = userEvent.setup(); + // Stored without the prefix the editor adds back, so a resend would be + // visibly a different string to the server. + renderDialog(trigger({ cron_expression: "0 */3 * * *", label: "Old name" })); + + await user.clear(labelInput()); + await user.type(labelInput(), "Evening sweep"); + await user.click(saveButton()); + + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + expect(mockUpdateTrigger.mock.calls[0]?.[0]).toEqual({ + autopilotId: AUTOPILOT_ID, + triggerId: "trg-evening", + label: "Evening sweep", + }); + }); + + it("pauses a schedule without resending the schedule", async () => { + const user = userEvent.setup(); + renderDialog(trigger({ cron_expression: "0 */3 * * *" })); + + await user.click(enabledSwitch()); + await user.click(saveButton()); + + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + // The cron stays where it is — pausing is not an edit to it, and the row + // keeps running the same schedule if it is switched back on. + expect(mockUpdateTrigger.mock.calls[0]?.[0]).toEqual({ + autopilotId: AUTOPILOT_ID, + triggerId: "trg-evening", + enabled: false, + }); + }); + + it("has nothing to save until something changes", async () => { + const user = userEvent.setup(); + renderDialog(); + + // A no-op PATCH is not free: the server recomputes and rewrites the row's + // next_run_at from whatever it is sent, so opening and saving a dialog the + // user never edited would still move a reading they never touched. + expect(saveButton()).toBeDisabled(); + + await user.click(enabledSwitch()); + expect(saveButton()).toBeEnabled(); + + await user.click(enabledSwitch()); + expect(saveButton()).toBeDisabled(); + expect(mockUpdateTrigger).not.toHaveBeenCalled(); + }); + + it("takes no label the in-flight write could not carry", async () => { + const user = userEvent.setup(); + renderDialog(); + preview.hold = true; + + await user.click(screen.getByRole("button", { name: "At a time" })); + await user.click(saveButton()); + + // Parked mid-validation: submit has already read the label it will send, so + // the input locks rather than accepting one this write cannot carry and the + // closing dialog would swallow. + await waitFor(() => expect(labelInput()).toBeDisabled()); + expect(mockUpdateTrigger).not.toHaveBeenCalled(); + + preview.release?.(); + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + expect(mockUpdateTrigger.mock.calls[0]?.[0].label).toBeUndefined(); + }); + + it("does not call a stored label with stray whitespace an edit", () => { + renderDialog(trigger({ label: " Morning sweep " })); + + // The baseline is trimmed the way submit trims, so opening the dialog on a + // historical label does not by itself arm Save. + expect(saveButton()).toBeDisabled(); + }); + + it("leaves alone a field a teammate changed under the open dialog", async () => { + const user = userEvent.setup(); + const { refreshProps } = renderDialog(trigger({ label: "Old name" })); + + // A teammate renames this row and pauses it; the detail query refreshes and + // the dialog takes the new props without remounting, so its untouched + // controls still hold what it opened on. + refreshProps(trigger({ label: "Renamed by teammate", enabled: false })); + + // This user has edited nothing, so there is nothing of theirs to save. + expect(saveButton()).toBeDisabled(); + + // And when they do edit one field, only that field travels: the rename and + // the pause stay as the teammate left them instead of being reverted to + // what this dialog happened to be showing. + await user.click(screen.getByRole("button", { name: "At a time" })); + await user.click(saveButton()); + + await waitFor(() => expect(mockUpdateTrigger).toHaveBeenCalledTimes(1)); + const patch = mockUpdateTrigger.mock.calls[0]?.[0]; + expect(patch.label).toBeUndefined(); + expect(patch.enabled).toBeUndefined(); + expect(patch.cron_expression).toBeDefined(); + }); +}); diff --git a/packages/views/autopilots/components/edit-schedule-trigger-dialog.tsx b/packages/views/autopilots/components/edit-schedule-trigger-dialog.tsx new file mode 100644 index 000000000..d8caaae90 --- /dev/null +++ b/packages/views/autopilots/components/edit-schedule-trigger-dialog.tsx @@ -0,0 +1,206 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useUpdateAutopilotTrigger } from "@multica/core/autopilots/mutations"; +import { useWorkspaceId } from "@multica/core/hooks"; +import { Button } from "@multica/ui/components/ui/button"; +import { Switch } from "@multica/ui/components/ui/switch"; +import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog"; +import { toast } from "sonner"; +import type { AutopilotTrigger } from "@multica/core/types"; +import { ScheduleEditor } from "./schedule-editor/schedule-editor"; +import { parseCron, toCron } from "./schedule-editor/cron-mapping"; +import { useScheduleSubmitGate } from "./schedule-editor/validate"; +import type { ScheduleConfig } from "./schedule-editor/model"; +import { useT } from "../../i18n"; + +// The only place in the UI where an existing schedule can be changed. The +// autopilot dialog's panel speaks for the autopilot's one schedule; a trigger +// row speaks for itself, which is what an autopilot carrying several of them +// needs (MUL-7478). Mounted per open so the editor always hydrates from the +// row as it stands now — a stale snapshot here would write back a cron the +// user never saw. +export function EditScheduleTriggerDialog({ + open, + onOpenChange, + autopilotId, + trigger, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + autopilotId: string; + trigger: AutopilotTrigger; +}) { + if (!open) return null; + return ( + + ); +} + +function EditScheduleTriggerDialogBody({ + onOpenChange, + autopilotId, + trigger, +}: { + onOpenChange: (open: boolean) => void; + autopilotId: string; + trigger: AutopilotTrigger; +}) { + const { t } = useT("autopilots"); + const wsId = useWorkspaceId(); + const updateTrigger = useUpdateAutopilotTrigger(); + // `parseCron` round-trips anything the server stored: an expression outside + // the structured model comes back as an advanced config holding the raw + // fields, which the editor renders in its expression row. So every schedule + // row is editable here, not only the ones the pickers can describe. + const initialCfg = parseCron(trigger.cron_expression ?? "", trigger.timezone ?? "UTC"); + const [config, setConfig] = useState(initialCfg); + const [label, setLabel] = useState(trigger.label ?? ""); + const [enabled, setEnabled] = useState(trigger.enabled); + const [submitting, setSubmitting] = useState(false); + const scheduleGate = useScheduleSubmitGate(wsId); + + // What "changed" is measured against, snapshotted at mount — never read live + // off `trigger`. The detail query refreshes under an open dialog (a teammate + // saving this same row), and a prop moving under an untouched control would + // read as this user's edit: Save would then send the value they never set, + // back over the one that had just landed. + // + // The cron baseline is the editor's own rendering of the stored expression, + // not the stored text: `parseCron` → `toCron` normalizes (a bare cron on a + // zoned row comes back carrying its `TZ=` prefix), so comparing against the + // stored string would call an untouched schedule edited. Server-side that + // reads as a substantive change — republishing the rule version and moving + // this trigger's accountability to whoever opened the dialog, which MUL-4302 + // settled must not happen on a label-only or no-op save. + const baseline = useRef({ + cron: toCron(initialCfg), + timezone: initialCfg.timezone, + // Trimmed like the value submit sends, so a stored label carrying stray + // whitespace is not already an edit the moment the dialog opens. + label: (trigger.label ?? "").trim(), + enabled: trigger.enabled, + }); + const scheduleDirty = + toCron(config) !== baseline.current.cron || + config.timezone !== baseline.current.timezone; + const labelDirty = label.trim() !== baseline.current.label; + const enabledDirty = enabled !== baseline.current.enabled; + const dirty = scheduleDirty || labelDirty || enabledDirty; + // The cron gate only stands between the user and a write that carries a cron. + // A row whose stored expression the server can no longer preview is exactly + // the one a user reaches for this dialog to switch OFF, and a rejection of an + // expression they are not sending must not be what stops them. + const canSubmit = !submitting && dirty && (!scheduleDirty || scheduleGate.scheduleValid); + + const handleSubmit = async () => { + if (!canSubmit) return; + setSubmitting(true); + try { + let cronExpr: string | null = null; + if (scheduleDirty) { + if (!(await scheduleGate.ensureAccepted(config))) { + setSubmitting(false); + return; + } + cronExpr = toCron(config); + if (!cronExpr.trim()) { + setSubmitting(false); + return; + } + } + // Only the fields that moved. The PATCH preserves everything it is not + // sent, so a field left out here keeps whatever the row has now — which + // is also what makes this dialog safe to have open while someone else + // edits the same trigger: it can only overwrite what its user touched. + await updateTrigger.mutateAsync({ + autopilotId, + triggerId: trigger.id, + ...(cronExpr !== null + ? { cron_expression: cronExpr, timezone: config.timezone || undefined } + : {}), + ...(labelDirty ? { label: label.trim() } : {}), + ...(enabledDirty ? { enabled } : {}), + }); + toast.success(t(($) => $.edit_trigger_dialog.toast_updated)); + onOpenChange(false); + } catch (err) { + toast.error( + err instanceof Error && err.message + ? err.message + : t(($) => $.edit_trigger_dialog.toast_update_failed), + ); + } finally { + setSubmitting(false); + } + }; + + return ( + + + {t(($) => $.edit_trigger_dialog.title)} + {/* Same min-w-0 as the add dialog: the cron readback is one unbreakable + line that would otherwise push the grid track past the dialog. */} +
+ { + scheduleGate.clearRejection(); + setConfig(next); + }} + wsId={wsId} + onValidityChange={scheduleGate.onValidityChange} + // Same reason as the other two schedule dialogs: submit validates + // over the network and then writes what it read going in, so an + // edit landing inside that window would be discarded silently. + disabled={submitting} + /> + +
+ + setLabel(e.target.value)} + placeholder={t(($) => $.edit_trigger_dialog.label_placeholder)} + // Same lock as the editor above, for the same reason: submit reads + // the label going in and validates over the network before + // writing, so a label typed inside that window would be dropped — + // silently, under the success toast for the write that shipped + // without it. + disabled={submitting} + className="mt-1 w-full rounded-md border bg-background px-3 py-2 text-body outline-none focus:ring-1 focus:ring-ring disabled:opacity-50" + /> +
+ +
+ + {t(($) => $.edit_trigger_dialog.enabled_label)} + + $.edit_trigger_dialog.enabled_label)} + /> +
+ +
+ +
+
+
+
+ ); +} diff --git a/packages/views/autopilots/components/schedule-editor/schedule-editor.tsx b/packages/views/autopilots/components/schedule-editor/schedule-editor.tsx index b456faae2..7b0ebba9b 100644 --- a/packages/views/autopilots/components/schedule-editor/schedule-editor.tsx +++ b/packages/views/autopilots/components/schedule-editor/schedule-editor.tsx @@ -66,7 +66,6 @@ export interface ScheduleEditorProps { onChange: (value: ScheduleConfig) => void; wsId: string; disabled?: boolean; - disabledReason?: string; /** Fires when the server accepts or rejects the current expression, so the * owning dialog can keep its submit button in step with the inline error. */ onValidityChange?: (valid: boolean) => void; @@ -238,7 +237,6 @@ export function ScheduleEditor({ onChange, wsId, disabled, - disabledReason, onValidityChange, }: ScheduleEditorProps) { const { t, i18n } = useT("autopilots"); @@ -862,9 +860,6 @@ export function ScheduleEditor({ )} - {disabled === true && disabledReason !== undefined && ( -

{disabledReason}

- )} ); } diff --git a/packages/views/autopilots/components/trigger-row.test.tsx b/packages/views/autopilots/components/trigger-row.test.tsx new file mode 100644 index 000000000..7e57cd37a --- /dev/null +++ b/packages/views/autopilots/components/trigger-row.test.tsx @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { AutopilotTrigger } from "@multica/core/types"; +import { renderWithI18n } from "../../test/i18n"; + +// The detail page's trigger row: what a schedule row says about itself, and +// the edit entry it grew in MUL-7478. + +vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-test" })); +vi.mock("@multica/core/paths", () => ({ + useWorkspacePaths: () => ({}), + useCurrentWorkspace: () => ({ name: "Acme" }), +})); + +vi.mock("@multica/core/autopilots/queries", () => ({ + autopilotDetailOptions: () => ({ queryKey: ["autopilot"], queryFn: async () => null }), + autopilotRunsOptions: () => ({ queryKey: ["runs"], queryFn: async () => [] }), + autopilotRunOptions: () => ({ queryKey: ["run"], queryFn: async () => null }), + cronPreviewOptions: (wsId: string, expr: string, tz: string) => ({ + queryKey: ["cron-preview", wsId, expr, tz], + queryFn: async () => ({ next_runs: ["2126-07-14T01:00:00Z"] }), + retry: false, + }), +})); + +vi.mock("@multica/core/autopilots/mutations", () => ({ + useUpdateAutopilot: () => ({ mutateAsync: vi.fn() }), + useDeleteAutopilot: () => ({ mutateAsync: vi.fn() }), + useTriggerAutopilot: () => ({ mutateAsync: vi.fn() }), + useCreateAutopilotTrigger: () => ({ mutateAsync: vi.fn() }), + useDeleteAutopilotTrigger: () => ({ mutateAsync: vi.fn() }), + useUpdateAutopilotTrigger: () => ({ mutateAsync: vi.fn() }), + useRotateAutopilotTriggerWebhookToken: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +// A webhook row composes its URL from the API base; everything else in this +// module (ApiError, which the schedule gate type-checks against) stays real. +vi.mock("@multica/core/api", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, api: { ...actual.api, getBaseUrl: () => "https://api.test" } }; +}); + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +vi.mock("./pickers/timezone-picker", () => ({ + TimezonePicker: ({ value }: { value: string }) =>
{value}
, +})); + +import { TriggerRow } from "./autopilot-detail-page"; + +const AUTOPILOT_ID = "ap-1"; + +function trigger(overrides: Partial = {}): AutopilotTrigger { + return { + id: "trg-morning", + autopilot_id: AUTOPILOT_ID, + kind: "schedule", + enabled: true, + cron_expression: "TZ=Asia/Bangkok 0 9 * * *", + timezone: "Asia/Bangkok", + next_run_at: "2126-07-14T02:00:00Z", + webhook_token: null, + label: null, + last_fired_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +function renderRow(trig: AutopilotTrigger, canWrite = true) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return renderWithI18n( + + + , + ); +} + +describe("TriggerRow", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reads out the next run of a live schedule", () => { + renderRow(trigger()); + + expect(screen.getByText(/Next:/)).toBeInTheDocument(); + expect(screen.queryByText("Disabled")).not.toBeInTheDocument(); + }); + + it("stops promising a next run once the schedule is paused", () => { + // The server keeps next_run_at on a disabled trigger — the dispatcher + // filters on `enabled` rather than clearing the column — so the row used to + // carry the Disabled badge and "Next: ..." at the same time, one of them a + // run that will never happen. + renderRow(trigger({ enabled: false })); + + expect(screen.getByText("Disabled")).toBeInTheDocument(); + expect(screen.queryByText(/Next:/)).not.toBeInTheDocument(); + }); + + it("opens the editor on the schedule this row runs", async () => { + const user = userEvent.setup(); + renderRow(trigger()); + + await user.click(screen.getByRole("button", { name: "Edit schedule" })); + + expect(await screen.findByText("Edit schedule", { selector: "h2" })).toBeInTheDocument(); + expect(screen.getByTestId("timezone-picker")).toHaveTextContent("Asia/Bangkok"); + }); + + it("offers no schedule editor on a trigger that has no cron to edit", () => { + // cron_expression / timezone are rejected on any other kind, so a webhook + // row must not offer an entry that could only fail. + renderRow(trigger({ kind: "webhook", cron_expression: null, timezone: null, next_run_at: null })); + + expect(screen.queryByRole("button", { name: "Edit schedule" })).not.toBeInTheDocument(); + }); + + it("offers no edit entry to a reader who cannot write", () => { + renderRow(trigger(), false); + + expect(screen.queryByRole("button", { name: "Edit schedule" })).not.toBeInTheDocument(); + }); +}); diff --git a/packages/views/locales/en/autopilots.json b/packages/views/locales/en/autopilots.json index 63547451b..dc90858d9 100644 --- a/packages/views/locales/en/autopilots.json +++ b/packages/views/locales/en/autopilots.json @@ -176,6 +176,7 @@ "copy_url": "Copy URL", "url_copied": "Webhook URL copied", "url_copy_failed": "Failed to copy", + "edit_schedule": "Edit schedule", "rotate_url": "Rotate URL", "rotate_confirm_title": "Rotate webhook URL", "rotate_confirm_description": "The current URL will stop working immediately. Any external systems calling it must be updated with the new URL. Continue?", @@ -255,6 +256,16 @@ "toast_added_webhook": "Webhook trigger added", "toast_add_failed": "Failed to add trigger" }, + "edit_trigger_dialog": { + "title": "Edit schedule", + "label_field": "Label (optional)", + "label_placeholder": "e.g. Weekday morning", + "enabled_label": "Enabled", + "submit": "Save", + "submitting": "Saving...", + "toast_updated": "Schedule updated", + "toast_update_failed": "Failed to update schedule" + }, "dialog": { "sr_create": "New Autopilot", "sr_edit": "Edit Autopilot", @@ -315,7 +326,7 @@ "event_filter_add": "Add", "event_filter_remove_label": "Remove filter", "event_filter_hint": "Only process webhooks matching these events. Leave empty to accept all.", - "schedule_disabled_reason": "This autopilot has multiple schedules — edit them in the detail page.", + "schedule_multiple_notice": "This autopilot has {{count}} schedules. Close this dialog and edit each one under Triggers below.", "schedule_empty": "No schedule — this autopilot only runs when triggered manually.", "schedule_add": "Add schedule", "error_title_required": "Enter a name for this autopilot.", diff --git a/packages/views/locales/fr/autopilots.json b/packages/views/locales/fr/autopilots.json index 65fed0eaf..0ba61ec52 100644 --- a/packages/views/locales/fr/autopilots.json +++ b/packages/views/locales/fr/autopilots.json @@ -176,6 +176,7 @@ "copy_url": "Copier l'URL", "url_copied": "URL du webhook copiée", "url_copy_failed": "Échec de la copie", + "edit_schedule": "Modifier la planification", "rotate_url": "Renouveler l'URL", "rotate_confirm_title": "Renouveler l'URL du webhook", "rotate_confirm_description": "L'URL actuelle cessera de fonctionner immédiatement. Tous les systèmes externes qui l'appellent devront être mis à jour avec la nouvelle. Continuer ?", @@ -255,6 +256,16 @@ "toast_added_webhook": "Déclencheur webhook ajouté", "toast_add_failed": "Échec de l'ajout du déclencheur" }, + "edit_trigger_dialog": { + "title": "Modifier la planification", + "label_field": "Libellé (facultatif)", + "label_placeholder": "ex. Matin en semaine", + "enabled_label": "Activé", + "submit": "Enregistrer", + "submitting": "Enregistrement...", + "toast_updated": "Planification mise à jour", + "toast_update_failed": "Échec de la mise à jour de la planification" + }, "dialog": { "sr_create": "Nouvelle automatisation", "sr_edit": "Modifier l'automatisation", @@ -315,7 +326,7 @@ "event_filter_add": "Ajouter", "event_filter_remove_label": "Retirer le filtre", "event_filter_hint": "Ne traiter que les webhooks correspondant à ces événements. Laissez vide pour tous les accepter.", - "schedule_disabled_reason": "Cette automatisation a plusieurs planifications — modifiez-les depuis la page de détail.", + "schedule_multiple_notice": "Cette automatisation a {{count}} planifications. Fermez cette fenêtre et modifiez chacune dans la liste Déclencheurs ci-dessous.", "schedule_empty": "Aucune planification — cette automatisation ne s'exécute que sur déclenchement manuel.", "schedule_add": "Ajouter une planification", "error_title_required": "Saisissez un nom pour cette automatisation.", diff --git a/packages/views/locales/ja/autopilots.json b/packages/views/locales/ja/autopilots.json index d2a40d07a..16131d812 100644 --- a/packages/views/locales/ja/autopilots.json +++ b/packages/views/locales/ja/autopilots.json @@ -176,6 +176,7 @@ "copy_url": "URL をコピー", "url_copied": "Webhook URL をコピーしました", "url_copy_failed": "URL をコピーできませんでした", + "edit_schedule": "スケジュールを編集", "rotate_url": "URL を再生成", "rotate_confirm_title": "Webhook URL を再生成", "rotate_confirm_description": "現在の URL は直ちに動作を停止します。この URL を呼び出している外部システムは新しい URL に更新する必要があります。続けますか?", @@ -255,6 +256,16 @@ "toast_added_webhook": "Webhook トリガーを追加しました", "toast_add_failed": "トリガーを追加できませんでした" }, + "edit_trigger_dialog": { + "title": "スケジュールを編集", + "label_field": "ラベル(任意)", + "label_placeholder": "例: 平日の朝", + "enabled_label": "有効", + "submit": "保存", + "submitting": "保存中...", + "toast_updated": "スケジュールを更新しました", + "toast_update_failed": "スケジュールの更新に失敗しました" + }, "dialog": { "sr_create": "新規オートパイロット", "sr_edit": "オートパイロットを編集", @@ -315,7 +326,7 @@ "event_filter_add": "追加", "event_filter_remove_label": "フィルターを削除", "event_filter_hint": "これらのイベントに一致する Webhook のみを処理します。空にするとすべて許可します。", - "schedule_disabled_reason": "このオートパイロットには複数のスケジュールがあります。詳細ページで編集してください。", + "schedule_multiple_notice": "このオートパイロットにはスケジュールが {{count}} 件あります。このダイアログを閉じて、下の「トリガー」から個別に編集してください。", "schedule_empty": "スケジュールはありません。このオートパイロットは手動で実行したときだけ動きます。", "schedule_add": "スケジュールを追加", "error_title_required": "オートパイロット名を入力してください。", diff --git a/packages/views/locales/ko/autopilots.json b/packages/views/locales/ko/autopilots.json index 7dba31c99..04f618a7d 100644 --- a/packages/views/locales/ko/autopilots.json +++ b/packages/views/locales/ko/autopilots.json @@ -176,6 +176,7 @@ "copy_url": "URL 복사", "url_copied": "Webhook URL을 복사했습니다", "url_copy_failed": "URL을 복사하지 못했습니다", + "edit_schedule": "일정 수정", "rotate_url": "URL 재발급", "rotate_confirm_title": "Webhook URL 재발급", "rotate_confirm_description": "현재 URL은 즉시 동작을 멈춥니다. 이 URL을 호출하는 외부 시스템은 새 URL로 업데이트해야 합니다. 계속할까요?", @@ -255,6 +256,16 @@ "toast_added_webhook": "Webhook 트리거를 추가했습니다", "toast_add_failed": "트리거를 추가하지 못했습니다" }, + "edit_trigger_dialog": { + "title": "일정 수정", + "label_field": "라벨(선택 사항)", + "label_placeholder": "예: 평일 오전", + "enabled_label": "활성화", + "submit": "저장", + "submitting": "저장하는 중...", + "toast_updated": "일정을 수정했습니다", + "toast_update_failed": "일정 수정에 실패했습니다" + }, "dialog": { "sr_create": "새 오토파일럿", "sr_edit": "오토파일럿 수정", @@ -315,7 +326,7 @@ "event_filter_add": "추가", "event_filter_remove_label": "필터 제거", "event_filter_hint": "이 이벤트와 일치하는 Webhook만 처리합니다. 비워 두면 모두 허용합니다.", - "schedule_disabled_reason": "이 오토파일럿에는 여러 일정이 있습니다. 상세 페이지에서 수정하세요.", + "schedule_multiple_notice": "이 오토파일럿에는 일정이 {{count}}개 있습니다. 이 대화상자를 닫고 아래 '트리거'에서 각각 수정하세요.", "schedule_empty": "일정이 없습니다. 이 오토파일럿은 수동으로 실행할 때만 동작합니다.", "schedule_add": "일정 추가", "error_title_required": "오토파일럿 이름을 입력하세요.", diff --git a/packages/views/locales/zh-Hans/autopilots.json b/packages/views/locales/zh-Hans/autopilots.json index c58211f10..e7a9f0270 100644 --- a/packages/views/locales/zh-Hans/autopilots.json +++ b/packages/views/locales/zh-Hans/autopilots.json @@ -176,6 +176,7 @@ "copy_url": "复制 URL", "url_copied": "已复制 Webhook URL", "url_copy_failed": "复制失败", + "edit_schedule": "编辑时间表", "rotate_url": "重新生成 URL", "rotate_confirm_title": "重新生成 Webhook URL", "rotate_confirm_description": "当前 URL 将立即失效。所有调用它的外部系统都需要更新为新 URL。继续吗?", @@ -255,6 +256,16 @@ "toast_added_webhook": "已添加 Webhook 触发器", "toast_add_failed": "添加触发器失败" }, + "edit_trigger_dialog": { + "title": "编辑时间表", + "label_field": "标签(可选)", + "label_placeholder": "例如:工作日早晨", + "enabled_label": "已启用", + "submit": "保存", + "submitting": "保存中...", + "toast_updated": "时间表已更新", + "toast_update_failed": "更新时间表失败" + }, "dialog": { "sr_create": "新建自动化", "sr_edit": "编辑自动化", @@ -315,7 +326,7 @@ "event_filter_add": "添加", "event_filter_remove_label": "移除过滤条件", "event_filter_hint": "只处理匹配这些事件的 webhook。留空则接受所有事件。", - "schedule_disabled_reason": "该自动化有多个时间表——请到详情页编辑。", + "schedule_multiple_notice": "该自动化有 {{count}} 条时间表。关闭此弹窗,在下方「触发器」中逐条编辑。", "schedule_empty": "还没有时间表——这个自动化只在手动触发时运行。", "schedule_add": "添加时间表", "error_title_required": "请填写自动化名称。",