mirror of
https://github.com/multica-ai/multica.git
synced 2026-09-28 13:23:48 +08:00
fix(autopilots): make an existing schedule editable again (MUL-7478) (#8538)
* fix(autopilots): make an existing schedule editable again (MUL-7478) The edit dialog's schedule panel locked whenever an autopilot carried two triggers of any kind, and pointed at a detail page whose trigger rows were read-only. Between them there was nowhere in the UI to change a schedule: the only way out was deleting the autopilot and building it again. Two halves, and both are needed — the narrowed lock alone would leave the notice pointing at a page that still cannot edit, and the row editor alone would leave the most common case (one schedule beside one webhook) taking the long way round: - Count only schedule-kind triggers when locking. A single schedule beside a webhook has exactly one row a cron can land on, which is the row the save path already targets, so there was never an ambiguity to protect. - Give each schedule row its own editor, opening on the schedule it runs and patching that trigger alone. Label and enabled ride along, so pausing a schedule no longer means deleting it. - Replace the greyed-out editor behind the lock with a notice. A disabled editor still showed the first of the schedules as if it were the whole story; the notice says how many there are and sends the reader to the Triggers list already on screen behind the dialog. Co-authored-by: multica-agent <github@multica.ai> * fix(autopilots): send only the fields the schedule editor changed Review found the new per-row dialog writing a full row on every save. Three consequences, all of them real: - The server reads a changed cron / timezone / enabled as a substantive edit and republishes the rule version, moving the trigger's accountability to whoever saved. Since parseCron -> toCron normalizes (a bare cron on a zoned row returns carrying its TZ= prefix), even a rename resent a textually different expression and took that responsibility with it — the exact over-transfer MUL-4302 settled. - A dialog left open while someone else edited the same trigger wrote its own stale reading back over their change. - A save with nothing edited still made the server recompute and rewrite next_run_at from an expression the user never touched. The dialog now snapshots the trigger at mount, sends only the fields that moved, and has nothing to save until one does. The cron gate applies only to a write that carries a cron, so a row whose stored expression the server can no longer preview can still be switched off. Two more from the same review: - The label input stayed editable while the submit validated over the network. A label typed in that window was not in the request already in flight and vanished under its success toast; it locks with the editor now. - A disabled trigger keeps its next_run_at — the dispatcher filters on `enabled` rather than clearing the column — so the row claimed "Disabled" and a next run in the same breath. The badge is the true one. Co-authored-by: multica-agent <github@multica.ai> * fix(autopilots): measure schedule edits against the row as it was opened The dirty checks read `trigger.label` / `trigger.enabled` live off props while their inputs were seeded once at mount. The detail query refreshes under an open dialog — a teammate saving this same row — and the component does not remount, so an untouched control and its prop drifted apart and the field counted as this user's edit. Save then sent the value they never set, back over the one that had just landed. Every baseline is now snapshotted at mount together, and the label baseline is trimmed the way the submitted value is, so a stored label carrying stray whitespace does not arm Save the moment the dialog opens. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
co-authored by
multica-agent
parent
a6472044c3
commit
594ff89edb
@@ -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
|
||||
</Button>
|
||||
) : 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" ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => setEditOpen(true)}
|
||||
title={t(($) => $.trigger_row.edit_schedule)}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-md border px-3 py-2">
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
|
||||
@@ -372,7 +391,11 @@ function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrig
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{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 && (
|
||||
<div className="text-caption text-muted-foreground">
|
||||
{t(($) => $.trigger_row.next_label, {
|
||||
date: formatInTimeZone(
|
||||
@@ -408,7 +431,12 @@ function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrig
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!showWebhookUrlRow && deleteButton}
|
||||
{!showWebhookUrlRow && (
|
||||
<div className="flex shrink-0 items-center gap-0.5">
|
||||
{editButton}
|
||||
{deleteButton}
|
||||
</div>
|
||||
)}
|
||||
<AlertDialog open={confirmOpen} onOpenChange={(v) => { if (!v && !deleting) setConfirmOpen(false); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@@ -433,6 +461,12 @@ function TriggerRow({ trigger, autopilotId, canWrite }: { trigger: AutopilotTrig
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<EditScheduleTriggerDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
autopilotId={autopilotId}
|
||||
trigger={trigger}
|
||||
/>
|
||||
<AlertDialog open={rotateOpen} onOpenChange={(v) => { if (!v && !rotateToken.isPending) setRotateOpen(false); }}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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" ? (
|
||||
<div>
|
||||
<SectionLabel>{t(($) => $.dialog.section_schedule)}</SectionLabel>
|
||||
{showScheduleEmptyState ? (
|
||||
{schedulePillDisabled ? (
|
||||
<ScheduleMultipleNotice count={scheduleTriggerCount} />
|
||||
) : showScheduleEmptyState ? (
|
||||
<ScheduleEmptyState onAdd={() => 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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="rounded-md border border-dashed p-3">
|
||||
<p className="text-caption text-muted-foreground">
|
||||
{t(($) => $.dialog.schedule_multiple_notice, { count })}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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<void>((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 }) => <div data-testid="timezone-picker">{value}</div>,
|
||||
}));
|
||||
|
||||
import { EditScheduleTriggerDialog } from "./edit-schedule-trigger-dialog";
|
||||
|
||||
const AUTOPILOT_ID = "ap-1";
|
||||
|
||||
function trigger(overrides: Partial<AutopilotTrigger> = {}): 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) => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<EditScheduleTriggerDialog
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
autopilotId={AUTOPILOT_ID}
|
||||
trigger={next}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<EditScheduleTriggerDialogBody
|
||||
onOpenChange={onOpenChange}
|
||||
autopilotId={autopilotId}
|
||||
trigger={trigger}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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<ScheduleConfig>(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 (
|
||||
<Dialog open onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-sm">
|
||||
<DialogTitle>{t(($) => $.edit_trigger_dialog.title)}</DialogTitle>
|
||||
{/* 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. */}
|
||||
<div className="min-w-0 space-y-4 pt-2">
|
||||
<ScheduleEditor
|
||||
value={config}
|
||||
onChange={(next) => {
|
||||
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}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="text-caption font-medium text-muted-foreground">
|
||||
{t(($) => $.edit_trigger_dialog.label_field)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={label}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-caption font-medium text-muted-foreground">
|
||||
{t(($) => $.edit_trigger_dialog.enabled_label)}
|
||||
</span>
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
disabled={submitting}
|
||||
aria-label={t(($) => $.edit_trigger_dialog.enabled_label)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end pt-1">
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{submitting
|
||||
? t(($) => $.edit_trigger_dialog.submitting)
|
||||
: t(($) => $.edit_trigger_dialog.submit)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{disabled === true && disabledReason !== undefined && (
|
||||
<p className="mt-2 text-micro text-muted-foreground">{disabledReason}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<typeof import("@multica/core/api")>();
|
||||
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 }) => <div data-testid="timezone-picker">{value}</div>,
|
||||
}));
|
||||
|
||||
import { TriggerRow } from "./autopilot-detail-page";
|
||||
|
||||
const AUTOPILOT_ID = "ap-1";
|
||||
|
||||
function trigger(overrides: Partial<AutopilotTrigger> = {}): 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(
|
||||
<QueryClientProvider client={qc}>
|
||||
<TriggerRow trigger={trig} autopilotId={AUTOPILOT_ID} canWrite={canWrite} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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": "オートパイロット名を入力してください。",
|
||||
|
||||
@@ -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": "오토파일럿 이름을 입력하세요.",
|
||||
|
||||
@@ -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": "请填写自动化名称。",
|
||||
|
||||
Reference in New Issue
Block a user