mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 15:58:03 +08:00
feat: edit local automations from the detail page (#611)
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { EditAutomationModal } from "#/components/features/automations/detail/edit-automation-modal";
|
||||
import AutomationService from "#/api/automation-service/automation-service.api";
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
setActiveSelection,
|
||||
setRegisteredBackends,
|
||||
} from "#/api/backend-registry/active-store";
|
||||
import {
|
||||
displaySuccessToast,
|
||||
displayErrorToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import type { Automation } from "#/types/automation";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
|
||||
vi.mock("#/api/automation-service/automation-service.api", () => ({
|
||||
default: {
|
||||
updateAutomation: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("#/utils/custom-toast-handlers", () => ({
|
||||
displaySuccessToast: vi.fn(),
|
||||
displayErrorToast: vi.fn(),
|
||||
}));
|
||||
|
||||
const localBackend: Backend = {
|
||||
id: "local-1",
|
||||
name: "Local",
|
||||
host: "http://localhost:8000",
|
||||
apiKey: "session-key",
|
||||
kind: "local",
|
||||
};
|
||||
|
||||
const dailyAutomation: Automation = {
|
||||
id: "auto-1",
|
||||
name: "Daily digest",
|
||||
prompt: "Summarize yesterday's PRs",
|
||||
trigger: { type: "cron", schedule: "0 9 * * *" },
|
||||
enabled: true,
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
updated_at: "2026-01-01T00:00:00Z",
|
||||
timezone: "America/Los_Angeles",
|
||||
};
|
||||
|
||||
const customAutomation: Automation = {
|
||||
...dailyAutomation,
|
||||
id: "auto-2",
|
||||
name: "Twice daily",
|
||||
trigger: { type: "cron", schedule: "0 9,17 * * *" },
|
||||
};
|
||||
|
||||
function renderModal(automation: Automation) {
|
||||
const onClose = vi.fn();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||
});
|
||||
const utils = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ActiveBackendProvider>
|
||||
<EditAutomationModal
|
||||
automation={automation}
|
||||
isOpen
|
||||
onClose={onClose}
|
||||
/>
|
||||
</ActiveBackendProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return { ...utils, onClose };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
__resetActiveStoreForTests();
|
||||
setRegisteredBackends([localBackend]);
|
||||
setActiveSelection({ backendId: localBackend.id });
|
||||
});
|
||||
|
||||
describe("EditAutomationModal", () => {
|
||||
it("pre-fills current values and PATCHes only the fields that changed", async () => {
|
||||
// Arrange — daily automation at 09:00 with a known prompt. The
|
||||
// backend will echo back the merged result.
|
||||
vi.mocked(AutomationService.updateAutomation).mockResolvedValue({
|
||||
...dailyAutomation,
|
||||
name: "Morning digest",
|
||||
prompt: "Summarize today's open PRs",
|
||||
trigger: { type: "cron", schedule: "30 10 * * *" },
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = renderModal(dailyAutomation);
|
||||
|
||||
// Sanity-check pre-fill: the inputs reflect the existing automation
|
||||
// before the user edits anything.
|
||||
const nameInput = screen.getByTestId(
|
||||
"edit-automation-name",
|
||||
) as HTMLInputElement;
|
||||
const timeInput = screen.getByTestId(
|
||||
"edit-automation-time",
|
||||
) as HTMLInputElement;
|
||||
expect(nameInput.value).toBe("Daily digest");
|
||||
expect(timeInput.value).toBe("09:00");
|
||||
|
||||
// Act — change name, prompt, and time; leave frequency at Daily.
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "Morning digest");
|
||||
const promptInput = screen.getByTestId("edit-automation-prompt");
|
||||
await user.clear(promptInput);
|
||||
await user.type(promptInput, "Summarize today's open PRs");
|
||||
await user.clear(timeInput);
|
||||
await user.type(timeInput, "10:30");
|
||||
await user.click(screen.getByTestId("edit-automation-save"));
|
||||
|
||||
// Assert — PATCH body contains exactly the diff (no untouched
|
||||
// fields like enabled/repository), and the success path closes
|
||||
// the modal + toasts the user.
|
||||
await waitFor(() => {
|
||||
expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(AutomationService.updateAutomation).toHaveBeenCalledWith("auto-1", {
|
||||
name: "Morning digest",
|
||||
prompt: "Summarize today's open PRs",
|
||||
trigger: { type: "cron", schedule: "30 10 * * *" },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(displaySuccessToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks submit and shows a validation error when the name is empty", async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup();
|
||||
renderModal(dailyAutomation);
|
||||
|
||||
// Act — clear the name and try to save.
|
||||
const nameInput = screen.getByTestId("edit-automation-name");
|
||||
await user.clear(nameInput);
|
||||
await user.click(screen.getByTestId("edit-automation-save"));
|
||||
|
||||
// Assert — no PATCH fired, inline error appears.
|
||||
expect(AutomationService.updateAutomation).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByTestId("edit-automation-name-error"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders custom-schedule hint and skips schedule mutation for non-preset cron", async () => {
|
||||
// Arrange — schedule "0 9,17 * * *" is not a Daily/Weekdays/Weekly
|
||||
// preset; the modal must treat it as read-only for frequency but
|
||||
// still allow editing the prompt/name.
|
||||
vi.mocked(AutomationService.updateAutomation).mockResolvedValue(
|
||||
customAutomation,
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
renderModal(customAutomation);
|
||||
|
||||
// The hint surfaces so the user understands why frequency is
|
||||
// disabled; this is the user-visible signal that we're in
|
||||
// custom mode.
|
||||
expect(screen.getByTestId("custom-schedule-hint")).toBeInTheDocument();
|
||||
|
||||
// Act — change only the name and save.
|
||||
const nameInput = screen.getByTestId("edit-automation-name");
|
||||
await user.clear(nameInput);
|
||||
await user.type(nameInput, "Renamed");
|
||||
await user.click(screen.getByTestId("edit-automation-save"));
|
||||
|
||||
// Assert — the PATCH body does NOT include a trigger override, so
|
||||
// the user's hand-tuned cron is preserved.
|
||||
await waitFor(() => {
|
||||
expect(AutomationService.updateAutomation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const [, body] = vi.mocked(AutomationService.updateAutomation).mock
|
||||
.calls[0];
|
||||
expect(body).not.toHaveProperty("trigger");
|
||||
expect(body).toMatchObject({ name: "Renamed" });
|
||||
});
|
||||
|
||||
it("surfaces an error toast and keeps the modal open when the update fails", async () => {
|
||||
// Arrange — backend rejects the PATCH.
|
||||
vi.mocked(AutomationService.updateAutomation).mockRejectedValue(
|
||||
new Error("backend down"),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
const { onClose } = renderModal(dailyAutomation);
|
||||
|
||||
// Act — change the time to force a non-empty diff, then save.
|
||||
const timeInput = screen.getByTestId("edit-automation-time");
|
||||
await user.clear(timeInput);
|
||||
await user.type(timeInput, "10:30");
|
||||
await user.click(screen.getByTestId("edit-automation-save"));
|
||||
|
||||
// Assert — error toast fired, modal stays open.
|
||||
await waitFor(() => {
|
||||
expect(displayErrorToast).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import React from "react";
|
||||
import { render, waitFor } from "@testing-library/react";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MemoryRouter, Route, Routes } from "react-router";
|
||||
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
|
||||
import AutomationService from "#/api/automation-service/automation-service.api";
|
||||
import {
|
||||
__resetActiveStoreForTests,
|
||||
@@ -13,10 +16,7 @@ import {
|
||||
import { ActiveBackendProvider } from "#/contexts/active-backend-context";
|
||||
import AutomationDetail from "#/routes/automation-detail";
|
||||
import type { Backend } from "#/api/backend-registry/types";
|
||||
import type {
|
||||
Automation,
|
||||
AutomationRunsResponse,
|
||||
} from "#/types/automation";
|
||||
import type { Automation, AutomationRunsResponse } from "#/types/automation";
|
||||
|
||||
vi.mock("#/api/automation-service/automation-service.api", () => ({
|
||||
default: {
|
||||
@@ -96,6 +96,49 @@ afterEach(() => {
|
||||
__resetActiveStoreForTests();
|
||||
});
|
||||
|
||||
describe("AutomationDetail — Edit is local-only", () => {
|
||||
it("shows Edit in the kebab menu when the active backend is local", async () => {
|
||||
// Arrange — default beforeEach selects the local backend.
|
||||
const user = userEvent.setup();
|
||||
renderDetail();
|
||||
await waitFor(() => {
|
||||
expect(AutomationService.getAutomation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Act — open the kebab menu.
|
||||
await user.click(screen.getByLabelText("Automation actions"));
|
||||
|
||||
// Assert — Edit entry is present alongside the other actions.
|
||||
expect(
|
||||
screen.getByRole("button", { name: I18nKey.AUTOMATIONS$EDIT }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Edit in the kebab menu when the active backend is cloud", async () => {
|
||||
// Arrange — switch to the cloud backend BEFORE rendering so the
|
||||
// detail page mounts under cloud (the backend-change guard would
|
||||
// otherwise stop the fetch).
|
||||
setActiveSelection({ backendId: cloudBackend.id });
|
||||
const user = userEvent.setup();
|
||||
renderDetail();
|
||||
await waitFor(() => {
|
||||
expect(AutomationService.getAutomation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByLabelText("Automation actions"));
|
||||
|
||||
// Assert — Edit must not appear on cloud; Delete still does, proving
|
||||
// we opened the menu and didn't merely fail to render.
|
||||
expect(
|
||||
screen.queryByRole("button", { name: I18nKey.AUTOMATIONS$EDIT }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: I18nKey.AUTOMATIONS$DELETE }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AutomationDetail — backend-change guard", () => {
|
||||
it("does not fetch the automation again when the active backend changes after mount", async () => {
|
||||
// Arrange — the page mounts under the local backend; the id in the URL
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildCronSchedule,
|
||||
parseCronSchedule,
|
||||
parseTimeOfDay,
|
||||
} from "#/utils/automation-schedule";
|
||||
|
||||
describe("automation-schedule", () => {
|
||||
describe("parseCronSchedule", () => {
|
||||
it("decodes Daily / Weekdays / Weekly preset cron expressions", () => {
|
||||
// Arrange — three representative preset strings the edit modal
|
||||
// needs to round-trip into UI state.
|
||||
const cases = {
|
||||
daily: "0 9 * * *",
|
||||
weekdays: "30 8 * * 1-5",
|
||||
weekly: "0 14 * * 3",
|
||||
};
|
||||
|
||||
// Act
|
||||
const daily = parseCronSchedule(cases.daily);
|
||||
const weekdays = parseCronSchedule(cases.weekdays);
|
||||
const weekly = parseCronSchedule(cases.weekly);
|
||||
|
||||
// Assert
|
||||
expect(daily).toEqual({ kind: "daily", hour: 9, minute: 0 });
|
||||
expect(weekdays).toEqual({ kind: "weekdays", hour: 8, minute: 30 });
|
||||
expect(weekly).toEqual({
|
||||
kind: "weekly",
|
||||
hour: 14,
|
||||
minute: 0,
|
||||
weekday: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to 'custom' for cron strings that don't match a preset", () => {
|
||||
// Arrange — schedules the UI must NOT silently rewrite when saving:
|
||||
// multi-value hour, monthly DOM, missing fields, garbage.
|
||||
const inputs = ["0 9,17 * * *", "0 9 1 * *", "every 5 minutes", ""];
|
||||
|
||||
// Act
|
||||
const results = inputs.map(parseCronSchedule);
|
||||
|
||||
// Assert — every non-preset stays as kind: "custom".
|
||||
expect(results.every((r) => r.kind === "custom")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCronSchedule", () => {
|
||||
it("emits canonical cron strings for each preset kind", () => {
|
||||
// Act
|
||||
const daily = buildCronSchedule({ kind: "daily", hour: 9, minute: 0 });
|
||||
const weekdays = buildCronSchedule({
|
||||
kind: "weekdays",
|
||||
hour: 8,
|
||||
minute: 30,
|
||||
});
|
||||
const weekly = buildCronSchedule({
|
||||
kind: "weekly",
|
||||
hour: 14,
|
||||
minute: 0,
|
||||
weekday: 3,
|
||||
});
|
||||
|
||||
// Assert
|
||||
expect(daily).toBe("0 9 * * *");
|
||||
expect(weekdays).toBe("30 8 * * 1-5");
|
||||
expect(weekly).toBe("0 14 * * 3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTimeOfDay", () => {
|
||||
it("parses HH:MM and rejects out-of-range or malformed values", () => {
|
||||
// Act
|
||||
const valid = parseTimeOfDay("09:30");
|
||||
const invalidHour = parseTimeOfDay("24:00");
|
||||
const malformed = parseTimeOfDay("9-30");
|
||||
|
||||
// Assert
|
||||
expect(valid).toEqual({ hour: 9, minute: 30 });
|
||||
expect(invalidHour).toBeNull();
|
||||
expect(malformed).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,24 +5,40 @@ import { ToggleSwitch } from "#/components/features/automations/toggle-switch";
|
||||
import { KebabMenu } from "#/components/features/automations/kebab-menu";
|
||||
import PowerIcon from "#/icons/power.svg?react";
|
||||
import TrashIcon from "#/icons/trash.svg?react";
|
||||
import EditIcon from "#/icons/u-edit.svg?react";
|
||||
import { useHasPermission } from "#/hooks/use-has-permission";
|
||||
import { ActiveStatusBadge } from "./active-status-badge";
|
||||
|
||||
interface DetailHeaderProps {
|
||||
automation: Automation;
|
||||
onToggle: () => void;
|
||||
/**
|
||||
* When provided, the kebab menu shows an Edit entry. Omitted for cloud
|
||||
* backends where the Edit feature is not supported in MVP.
|
||||
*/
|
||||
onEdit?: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export function DetailHeader({
|
||||
automation,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: DetailHeaderProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const canManage = useHasPermission("manage_automations");
|
||||
|
||||
const kebabItems = [
|
||||
...(onEdit
|
||||
? [
|
||||
{
|
||||
label: t(I18nKey.AUTOMATIONS$EDIT),
|
||||
icon: <EditIcon className="size-4" />,
|
||||
onClick: onEdit,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
label: automation.enabled
|
||||
? t(I18nKey.AUTOMATIONS$TURN_OFF)
|
||||
|
||||
@@ -0,0 +1,346 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { isAxiosError } from "axios";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import type { Automation } from "#/types/automation";
|
||||
import { useUpdateAutomation } from "#/hooks/query/use-automations";
|
||||
import { SettingsInput } from "#/components/features/settings/settings-input";
|
||||
import { SettingsDropdownInput } from "#/components/features/settings/settings-dropdown-input";
|
||||
import { BrandButton } from "#/components/features/settings/brand-button";
|
||||
import {
|
||||
displaySuccessToast,
|
||||
displayErrorToast,
|
||||
} from "#/utils/custom-toast-handlers";
|
||||
import {
|
||||
parseCronSchedule,
|
||||
buildCronSchedule,
|
||||
formatTimeOfDay,
|
||||
parseTimeOfDay,
|
||||
type SchedulePresetKind,
|
||||
} from "#/utils/automation-schedule";
|
||||
import XMarkIcon from "#/icons/x-mark.svg?react";
|
||||
|
||||
interface EditAutomationModalProps {
|
||||
automation: Automation;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type FrequencyKey = SchedulePresetKind | "custom";
|
||||
|
||||
const WEEKDAY_KEYS: I18nKey[] = [
|
||||
I18nKey.AUTOMATIONS$WEEKDAY_SUN,
|
||||
I18nKey.AUTOMATIONS$WEEKDAY_MON,
|
||||
I18nKey.AUTOMATIONS$WEEKDAY_TUE,
|
||||
I18nKey.AUTOMATIONS$WEEKDAY_WED,
|
||||
I18nKey.AUTOMATIONS$WEEKDAY_THU,
|
||||
I18nKey.AUTOMATIONS$WEEKDAY_FRI,
|
||||
I18nKey.AUTOMATIONS$WEEKDAY_SAT,
|
||||
];
|
||||
|
||||
interface FormState {
|
||||
name: string;
|
||||
prompt: string;
|
||||
frequency: FrequencyKey;
|
||||
weekday: number;
|
||||
timeOfDay: string;
|
||||
isCustomSchedule: boolean;
|
||||
rawSchedule: string;
|
||||
}
|
||||
|
||||
function buildInitialState(automation: Automation): FormState {
|
||||
const parsed = parseCronSchedule(automation.trigger.schedule);
|
||||
if (parsed.kind === "custom") {
|
||||
return {
|
||||
name: automation.name,
|
||||
prompt: automation.prompt ?? "",
|
||||
frequency: "custom",
|
||||
weekday: 1,
|
||||
timeOfDay:
|
||||
parsed.hour !== undefined && parsed.minute !== undefined
|
||||
? formatTimeOfDay(parsed.hour, parsed.minute)
|
||||
: "",
|
||||
isCustomSchedule: true,
|
||||
rawSchedule: parsed.raw,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: automation.name,
|
||||
prompt: automation.prompt ?? "",
|
||||
frequency: parsed.kind,
|
||||
weekday: parsed.kind === "weekly" ? (parsed.weekday ?? 1) : 1,
|
||||
timeOfDay: formatTimeOfDay(parsed.hour, parsed.minute),
|
||||
isCustomSchedule: false,
|
||||
rawSchedule: automation.trigger.schedule ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
export function EditAutomationModal({
|
||||
automation,
|
||||
isOpen,
|
||||
onClose,
|
||||
}: EditAutomationModalProps) {
|
||||
const { t } = useTranslation("openhands");
|
||||
const updateMutation = useUpdateAutomation();
|
||||
|
||||
const initial = useMemo(() => buildInitialState(automation), [automation]);
|
||||
const [form, setForm] = useState<FormState>(initial);
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setForm(initial);
|
||||
setNameError(null);
|
||||
}
|
||||
}, [isOpen, initial]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const frequencyItems = [
|
||||
{
|
||||
key: "daily",
|
||||
label: t(I18nKey.AUTOMATIONS$FREQUENCY_DAILY),
|
||||
},
|
||||
{
|
||||
key: "weekdays",
|
||||
label: t(I18nKey.AUTOMATIONS$FREQUENCY_WEEKDAYS),
|
||||
},
|
||||
{
|
||||
key: "weekly",
|
||||
label: t(I18nKey.AUTOMATIONS$FREQUENCY_WEEKLY),
|
||||
},
|
||||
...(form.isCustomSchedule
|
||||
? [
|
||||
{
|
||||
key: "custom",
|
||||
label: t(I18nKey.AUTOMATIONS$FREQUENCY_CUSTOM),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const weekdayItems = WEEKDAY_KEYS.map((key, index) => ({
|
||||
key: String(index),
|
||||
label: t(key),
|
||||
}));
|
||||
|
||||
const isTimeEditable =
|
||||
!form.isCustomSchedule ||
|
||||
parseTimeOfDay(form.timeOfDay) !== null ||
|
||||
form.timeOfDay === "";
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const trimmedName = form.name.trim();
|
||||
if (!trimmedName) {
|
||||
setNameError(t(I18nKey.AUTOMATIONS$NAME_REQUIRED));
|
||||
return;
|
||||
}
|
||||
setNameError(null);
|
||||
|
||||
const body: Partial<Automation> = {};
|
||||
|
||||
if (trimmedName !== automation.name) {
|
||||
body.name = trimmedName;
|
||||
}
|
||||
|
||||
const trimmedPrompt = form.prompt.trim();
|
||||
const initialPrompt = automation.prompt ?? "";
|
||||
if (trimmedPrompt !== initialPrompt.trim()) {
|
||||
body.prompt = trimmedPrompt.length === 0 ? null : trimmedPrompt;
|
||||
}
|
||||
|
||||
if (!form.isCustomSchedule && form.frequency !== "custom") {
|
||||
const parsedTime = parseTimeOfDay(form.timeOfDay);
|
||||
if (parsedTime) {
|
||||
const newSchedule = buildCronSchedule({
|
||||
kind: form.frequency,
|
||||
hour: parsedTime.hour,
|
||||
minute: parsedTime.minute,
|
||||
weekday: form.frequency === "weekly" ? form.weekday : undefined,
|
||||
});
|
||||
if (newSchedule !== automation.trigger.schedule) {
|
||||
body.trigger = {
|
||||
...automation.trigger,
|
||||
schedule: newSchedule,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(body).length === 0) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
updateMutation.mutate(
|
||||
{ id: automation.id, body },
|
||||
{
|
||||
onSuccess: () => {
|
||||
displaySuccessToast(t(I18nKey.AUTOMATIONS$EDIT_SUCCESS));
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = isAxiosError(error)
|
||||
? (error.response?.data as { message?: string } | undefined)
|
||||
?.message ||
|
||||
error.message ||
|
||||
t(I18nKey.AUTOMATIONS$EDIT_ERROR)
|
||||
: (error as Error).message || t(I18nKey.AUTOMATIONS$EDIT_ERROR);
|
||||
displayErrorToast(message);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60"
|
||||
onClick={onClose}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") onClose();
|
||||
}}
|
||||
role="presentation"
|
||||
/>
|
||||
<div className="relative w-full max-w-md rounded-xl border border-[var(--oh-border)] bg-[var(--oh-surface)] p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 text-muted hover:text-foreground"
|
||||
aria-label={t(I18nKey.AUTOMATIONS$CANCEL)}
|
||||
>
|
||||
<XMarkIcon className="size-5" />
|
||||
</button>
|
||||
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t(I18nKey.AUTOMATIONS$EDIT_TITLE)}
|
||||
</h2>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="mt-4 flex flex-col gap-4"
|
||||
aria-label={t(I18nKey.AUTOMATIONS$EDIT_TITLE)}
|
||||
>
|
||||
<SettingsInput
|
||||
testId="edit-automation-name"
|
||||
name="name"
|
||||
type="text"
|
||||
label={t(I18nKey.AUTOMATIONS$NAME)}
|
||||
value={form.name}
|
||||
onChange={(value) => setForm((f) => ({ ...f, name: value }))}
|
||||
error={nameError ?? undefined}
|
||||
showRequiredTag
|
||||
/>
|
||||
|
||||
<label className="flex flex-col gap-2.5 w-full min-w-0">
|
||||
<span className="text-sm">{t(I18nKey.AUTOMATIONS$PROMPT)}</span>
|
||||
<textarea
|
||||
data-testid="edit-automation-prompt"
|
||||
name="prompt"
|
||||
value={form.prompt}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, prompt: e.target.value }))
|
||||
}
|
||||
rows={4}
|
||||
className="bg-tertiary border border-[var(--oh-border-input)] w-full min-w-0 rounded-sm p-2 text-sm placeholder:italic placeholder:text-tertiary-alt"
|
||||
/>
|
||||
<span className="text-xs text-muted">
|
||||
{t(I18nKey.AUTOMATIONS$EDIT_PROMPT_HINT)}
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<SettingsDropdownInput
|
||||
testId="edit-automation-frequency"
|
||||
name="frequency"
|
||||
label={t(I18nKey.AUTOMATIONS$FREQUENCY)}
|
||||
items={frequencyItems}
|
||||
selectedKey={form.frequency}
|
||||
isDisabled={form.isCustomSchedule}
|
||||
onSelectionChange={(key) => {
|
||||
if (!key || form.isCustomSchedule) return;
|
||||
setForm((f) => ({ ...f, frequency: key as FrequencyKey }));
|
||||
}}
|
||||
/>
|
||||
|
||||
{form.frequency === "weekly" && !form.isCustomSchedule && (
|
||||
<SettingsDropdownInput
|
||||
testId="edit-automation-weekday"
|
||||
name="weekday"
|
||||
label={t(I18nKey.AUTOMATIONS$WEEKDAY)}
|
||||
items={weekdayItems}
|
||||
selectedKey={String(form.weekday)}
|
||||
onSelectionChange={(key) => {
|
||||
if (key === null) return;
|
||||
setForm((f) => ({ ...f, weekday: Number(key) }));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<label className="flex flex-col gap-2.5 w-full min-w-0">
|
||||
<span className="text-sm">
|
||||
{t(I18nKey.AUTOMATIONS$TIME_OF_DAY)}
|
||||
</span>
|
||||
<input
|
||||
data-testid="edit-automation-time"
|
||||
name="timeOfDay"
|
||||
type="time"
|
||||
value={form.timeOfDay}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, timeOfDay: e.target.value }))
|
||||
}
|
||||
disabled={form.isCustomSchedule && !isTimeEditable}
|
||||
className="bg-tertiary border border-[var(--oh-border-input)] h-10 w-full min-w-0 rounded-sm p-2 disabled:bg-[var(--oh-surface-raised)] disabled:cursor-not-allowed"
|
||||
/>
|
||||
{automation.timezone && (
|
||||
<span className="text-xs text-muted">
|
||||
{t(I18nKey.AUTOMATIONS$TIMEZONE)}: {automation.timezone}
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{form.isCustomSchedule && (
|
||||
<p
|
||||
className="text-xs text-muted"
|
||||
data-testid="custom-schedule-hint"
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$CUSTOM_SCHEDULE_HINT)}
|
||||
{form.rawSchedule && (
|
||||
<>
|
||||
{" "}
|
||||
<code className="text-xs text-content">
|
||||
{form.rawSchedule}
|
||||
</code>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex justify-end gap-3">
|
||||
<BrandButton
|
||||
testId="edit-automation-cancel"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
isDisabled={updateMutation.isPending}
|
||||
>
|
||||
{t(I18nKey.AUTOMATIONS$CANCEL)}
|
||||
</BrandButton>
|
||||
<BrandButton
|
||||
testId="edit-automation-save"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
isDisabled={updateMutation.isPending}
|
||||
aria-busy={updateMutation.isPending}
|
||||
>
|
||||
{updateMutation.isPending
|
||||
? t(I18nKey.AUTOMATIONS$SAVING)
|
||||
: t(I18nKey.AUTOMATIONS$SAVE)}
|
||||
</BrandButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import AutomationService from "#/api/automation-service/automation-service.api";
|
||||
import { useActiveBackend } from "#/contexts/active-backend-context";
|
||||
import type { Automation } from "#/types/automation";
|
||||
import { AUTOMATION_DETAIL_QUERY_KEY } from "./use-automation-detail";
|
||||
|
||||
export const AUTOMATIONS_QUERY_KEY = ["automations"] as const;
|
||||
@@ -39,6 +40,18 @@ export function useToggleAutomation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateAutomation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: Partial<Automation> }) =>
|
||||
AutomationService.updateAutomation(id, body),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: AUTOMATIONS_QUERY_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: AUTOMATION_DETAIL_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteAutomation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -11423,6 +11423,448 @@
|
||||
"uk": "Скасувати",
|
||||
"ca": "Cancel·la"
|
||||
},
|
||||
"AUTOMATIONS$EDIT": {
|
||||
"en": "Edit",
|
||||
"ja": "編集",
|
||||
"zh-CN": "编辑",
|
||||
"zh-TW": "編輯",
|
||||
"ko-KR": "편집",
|
||||
"no": "Rediger",
|
||||
"it": "Modifica",
|
||||
"pt": "Editar",
|
||||
"es": "Editar",
|
||||
"ar": "تعديل",
|
||||
"fr": "Modifier",
|
||||
"tr": "Düzenle",
|
||||
"de": "Bearbeiten",
|
||||
"uk": "Редагувати",
|
||||
"ca": "Edita"
|
||||
},
|
||||
"AUTOMATIONS$EDIT_TITLE": {
|
||||
"en": "Edit automation",
|
||||
"ja": "オートメーションを編集",
|
||||
"zh-CN": "编辑自动化",
|
||||
"zh-TW": "編輯自動化",
|
||||
"ko-KR": "자동화 편집",
|
||||
"no": "Rediger automatisering",
|
||||
"it": "Modifica automazione",
|
||||
"pt": "Editar automação",
|
||||
"es": "Editar automatización",
|
||||
"ar": "تعديل الأتمتة",
|
||||
"fr": "Modifier l'automatisation",
|
||||
"tr": "Otomasyonu düzenle",
|
||||
"de": "Automatisierung bearbeiten",
|
||||
"uk": "Редагувати автоматизацію",
|
||||
"ca": "Edita l'automatització"
|
||||
},
|
||||
"AUTOMATIONS$SAVE": {
|
||||
"en": "Save",
|
||||
"ja": "保存",
|
||||
"zh-CN": "保存",
|
||||
"zh-TW": "儲存",
|
||||
"ko-KR": "저장",
|
||||
"no": "Lagre",
|
||||
"it": "Salva",
|
||||
"pt": "Salvar",
|
||||
"es": "Guardar",
|
||||
"ar": "حفظ",
|
||||
"fr": "Enregistrer",
|
||||
"tr": "Kaydet",
|
||||
"de": "Speichern",
|
||||
"uk": "Зберегти",
|
||||
"ca": "Desa"
|
||||
},
|
||||
"AUTOMATIONS$SAVING": {
|
||||
"en": "Saving...",
|
||||
"ja": "保存中...",
|
||||
"zh-CN": "保存中...",
|
||||
"zh-TW": "儲存中...",
|
||||
"ko-KR": "저장 중...",
|
||||
"no": "Lagrer...",
|
||||
"it": "Salvataggio...",
|
||||
"pt": "Salvando...",
|
||||
"es": "Guardando...",
|
||||
"ar": "جارٍ الحفظ...",
|
||||
"fr": "Enregistrement...",
|
||||
"tr": "Kaydediliyor...",
|
||||
"de": "Wird gespeichert...",
|
||||
"uk": "Збереження...",
|
||||
"ca": "S'està desant..."
|
||||
},
|
||||
"AUTOMATIONS$NAME": {
|
||||
"en": "Name",
|
||||
"ja": "名前",
|
||||
"zh-CN": "名称",
|
||||
"zh-TW": "名稱",
|
||||
"ko-KR": "이름",
|
||||
"no": "Navn",
|
||||
"it": "Nome",
|
||||
"pt": "Nome",
|
||||
"es": "Nombre",
|
||||
"ar": "الاسم",
|
||||
"fr": "Nom",
|
||||
"tr": "Ad",
|
||||
"de": "Name",
|
||||
"uk": "Назва",
|
||||
"ca": "Nom"
|
||||
},
|
||||
"AUTOMATIONS$PROMPT": {
|
||||
"en": "Prompt",
|
||||
"ja": "プロンプト",
|
||||
"zh-CN": "提示",
|
||||
"zh-TW": "提示",
|
||||
"ko-KR": "프롬프트",
|
||||
"no": "Ledetekst",
|
||||
"it": "Prompt",
|
||||
"pt": "Prompt",
|
||||
"es": "Indicación",
|
||||
"ar": "موجه",
|
||||
"fr": "Invite",
|
||||
"tr": "İstem",
|
||||
"de": "Prompt",
|
||||
"uk": "Запит",
|
||||
"ca": "Indicació"
|
||||
},
|
||||
"AUTOMATIONS$FREQUENCY": {
|
||||
"en": "Frequency",
|
||||
"ja": "頻度",
|
||||
"zh-CN": "频率",
|
||||
"zh-TW": "頻率",
|
||||
"ko-KR": "빈도",
|
||||
"no": "Frekvens",
|
||||
"it": "Frequenza",
|
||||
"pt": "Frequência",
|
||||
"es": "Frecuencia",
|
||||
"ar": "التكرار",
|
||||
"fr": "Fréquence",
|
||||
"tr": "Sıklık",
|
||||
"de": "Häufigkeit",
|
||||
"uk": "Частота",
|
||||
"ca": "Freqüència"
|
||||
},
|
||||
"AUTOMATIONS$FREQUENCY_DAILY": {
|
||||
"en": "Daily",
|
||||
"ja": "毎日",
|
||||
"zh-CN": "每日",
|
||||
"zh-TW": "每日",
|
||||
"ko-KR": "매일",
|
||||
"no": "Daglig",
|
||||
"it": "Giornaliero",
|
||||
"pt": "Diariamente",
|
||||
"es": "Diario",
|
||||
"ar": "يوميًا",
|
||||
"fr": "Quotidien",
|
||||
"tr": "Günlük",
|
||||
"de": "Täglich",
|
||||
"uk": "Щодня",
|
||||
"ca": "Diari"
|
||||
},
|
||||
"AUTOMATIONS$FREQUENCY_WEEKDAYS": {
|
||||
"en": "Weekdays (Mon–Fri)",
|
||||
"ja": "平日 (月~金)",
|
||||
"zh-CN": "工作日(周一至周五)",
|
||||
"zh-TW": "平日(週一至週五)",
|
||||
"ko-KR": "평일 (월–금)",
|
||||
"no": "Hverdager (man.–fre.)",
|
||||
"it": "Giorni feriali (lun-ven)",
|
||||
"pt": "Dias úteis (Seg–Sex)",
|
||||
"es": "Días laborables (Lun–Vie)",
|
||||
"ar": "أيام الأسبوع (الإثنين-الجمعة)",
|
||||
"fr": "Jours ouvrés (lun-ven)",
|
||||
"tr": "Hafta içi (Pzt-Cum)",
|
||||
"de": "Wochentage (Mo–Fr)",
|
||||
"uk": "Будні (Пн–Пт)",
|
||||
"ca": "Dies laborables (dl–dv)"
|
||||
},
|
||||
"AUTOMATIONS$FREQUENCY_WEEKLY": {
|
||||
"en": "Weekly",
|
||||
"ja": "毎週",
|
||||
"zh-CN": "每周",
|
||||
"zh-TW": "每週",
|
||||
"ko-KR": "매주",
|
||||
"no": "Ukentlig",
|
||||
"it": "Settimanale",
|
||||
"pt": "Semanalmente",
|
||||
"es": "Semanal",
|
||||
"ar": "أسبوعيًا",
|
||||
"fr": "Hebdomadaire",
|
||||
"tr": "Haftalık",
|
||||
"de": "Wöchentlich",
|
||||
"uk": "Щотижня",
|
||||
"ca": "Setmanal"
|
||||
},
|
||||
"AUTOMATIONS$FREQUENCY_CUSTOM": {
|
||||
"en": "Custom",
|
||||
"ja": "カスタム",
|
||||
"zh-CN": "自定义",
|
||||
"zh-TW": "自訂",
|
||||
"ko-KR": "사용자 지정",
|
||||
"no": "Tilpasset",
|
||||
"it": "Personalizzato",
|
||||
"pt": "Personalizado",
|
||||
"es": "Personalizado",
|
||||
"ar": "مخصص",
|
||||
"fr": "Personnalisé",
|
||||
"tr": "Özel",
|
||||
"de": "Benutzerdefiniert",
|
||||
"uk": "Власне",
|
||||
"ca": "Personalitzat"
|
||||
},
|
||||
"AUTOMATIONS$WEEKDAY": {
|
||||
"en": "Day of week",
|
||||
"ja": "曜日",
|
||||
"zh-CN": "星期",
|
||||
"zh-TW": "星期",
|
||||
"ko-KR": "요일",
|
||||
"no": "Ukedag",
|
||||
"it": "Giorno della settimana",
|
||||
"pt": "Dia da semana",
|
||||
"es": "Día de la semana",
|
||||
"ar": "يوم الأسبوع",
|
||||
"fr": "Jour de la semaine",
|
||||
"tr": "Haftanın günü",
|
||||
"de": "Wochentag",
|
||||
"uk": "День тижня",
|
||||
"ca": "Dia de la setmana"
|
||||
},
|
||||
"AUTOMATIONS$TIME_OF_DAY": {
|
||||
"en": "Time of day",
|
||||
"ja": "時刻",
|
||||
"zh-CN": "时间",
|
||||
"zh-TW": "時間",
|
||||
"ko-KR": "시간",
|
||||
"no": "Tidspunkt",
|
||||
"it": "Ora del giorno",
|
||||
"pt": "Hora do dia",
|
||||
"es": "Hora del día",
|
||||
"ar": "الوقت من اليوم",
|
||||
"fr": "Heure de la journée",
|
||||
"tr": "Günün saati",
|
||||
"de": "Uhrzeit",
|
||||
"uk": "Час доби",
|
||||
"ca": "Hora del dia"
|
||||
},
|
||||
"AUTOMATIONS$TIMEZONE": {
|
||||
"en": "Timezone",
|
||||
"ja": "タイムゾーン",
|
||||
"zh-CN": "时区",
|
||||
"zh-TW": "時區",
|
||||
"ko-KR": "시간대",
|
||||
"no": "Tidssone",
|
||||
"it": "Fuso orario",
|
||||
"pt": "Fuso horário",
|
||||
"es": "Zona horaria",
|
||||
"ar": "المنطقة الزمنية",
|
||||
"fr": "Fuseau horaire",
|
||||
"tr": "Saat dilimi",
|
||||
"de": "Zeitzone",
|
||||
"uk": "Часовий пояс",
|
||||
"ca": "Fus horari"
|
||||
},
|
||||
"AUTOMATIONS$WEEKDAY_SUN": {
|
||||
"en": "Sunday",
|
||||
"ja": "日曜日",
|
||||
"zh-CN": "星期日",
|
||||
"zh-TW": "星期日",
|
||||
"ko-KR": "일요일",
|
||||
"no": "Søndag",
|
||||
"it": "Domenica",
|
||||
"pt": "Domingo",
|
||||
"es": "Domingo",
|
||||
"ar": "الأحد",
|
||||
"fr": "Dimanche",
|
||||
"tr": "Pazar",
|
||||
"de": "Sonntag",
|
||||
"uk": "Неділя",
|
||||
"ca": "Diumenge"
|
||||
},
|
||||
"AUTOMATIONS$WEEKDAY_MON": {
|
||||
"en": "Monday",
|
||||
"ja": "月曜日",
|
||||
"zh-CN": "星期一",
|
||||
"zh-TW": "星期一",
|
||||
"ko-KR": "월요일",
|
||||
"no": "Mandag",
|
||||
"it": "Lunedì",
|
||||
"pt": "Segunda-feira",
|
||||
"es": "Lunes",
|
||||
"ar": "الاثنين",
|
||||
"fr": "Lundi",
|
||||
"tr": "Pazartesi",
|
||||
"de": "Montag",
|
||||
"uk": "Понеділок",
|
||||
"ca": "Dilluns"
|
||||
},
|
||||
"AUTOMATIONS$WEEKDAY_TUE": {
|
||||
"en": "Tuesday",
|
||||
"ja": "火曜日",
|
||||
"zh-CN": "星期二",
|
||||
"zh-TW": "星期二",
|
||||
"ko-KR": "화요일",
|
||||
"no": "Tirsdag",
|
||||
"it": "Martedì",
|
||||
"pt": "Terça-feira",
|
||||
"es": "Martes",
|
||||
"ar": "الثلاثاء",
|
||||
"fr": "Mardi",
|
||||
"tr": "Salı",
|
||||
"de": "Dienstag",
|
||||
"uk": "Вівторок",
|
||||
"ca": "Dimarts"
|
||||
},
|
||||
"AUTOMATIONS$WEEKDAY_WED": {
|
||||
"en": "Wednesday",
|
||||
"ja": "水曜日",
|
||||
"zh-CN": "星期三",
|
||||
"zh-TW": "星期三",
|
||||
"ko-KR": "수요일",
|
||||
"no": "Onsdag",
|
||||
"it": "Mercoledì",
|
||||
"pt": "Quarta-feira",
|
||||
"es": "Miércoles",
|
||||
"ar": "الأربعاء",
|
||||
"fr": "Mercredi",
|
||||
"tr": "Çarşamba",
|
||||
"de": "Mittwoch",
|
||||
"uk": "Середа",
|
||||
"ca": "Dimecres"
|
||||
},
|
||||
"AUTOMATIONS$WEEKDAY_THU": {
|
||||
"en": "Thursday",
|
||||
"ja": "木曜日",
|
||||
"zh-CN": "星期四",
|
||||
"zh-TW": "星期四",
|
||||
"ko-KR": "목요일",
|
||||
"no": "Torsdag",
|
||||
"it": "Giovedì",
|
||||
"pt": "Quinta-feira",
|
||||
"es": "Jueves",
|
||||
"ar": "الخميس",
|
||||
"fr": "Jeudi",
|
||||
"tr": "Perşembe",
|
||||
"de": "Donnerstag",
|
||||
"uk": "Четвер",
|
||||
"ca": "Dijous"
|
||||
},
|
||||
"AUTOMATIONS$WEEKDAY_FRI": {
|
||||
"en": "Friday",
|
||||
"ja": "金曜日",
|
||||
"zh-CN": "星期五",
|
||||
"zh-TW": "星期五",
|
||||
"ko-KR": "금요일",
|
||||
"no": "Fredag",
|
||||
"it": "Venerdì",
|
||||
"pt": "Sexta-feira",
|
||||
"es": "Viernes",
|
||||
"ar": "الجمعة",
|
||||
"fr": "Vendredi",
|
||||
"tr": "Cuma",
|
||||
"de": "Freitag",
|
||||
"uk": "П’ятниця",
|
||||
"ca": "Divendres"
|
||||
},
|
||||
"AUTOMATIONS$WEEKDAY_SAT": {
|
||||
"en": "Saturday",
|
||||
"ja": "土曜日",
|
||||
"zh-CN": "星期六",
|
||||
"zh-TW": "星期六",
|
||||
"ko-KR": "토요일",
|
||||
"no": "Lørdag",
|
||||
"it": "Sabato",
|
||||
"pt": "Sábado",
|
||||
"es": "Sábado",
|
||||
"ar": "السبت",
|
||||
"fr": "Samedi",
|
||||
"tr": "Cumartesi",
|
||||
"de": "Samstag",
|
||||
"uk": "Субота",
|
||||
"ca": "Dissabte"
|
||||
},
|
||||
"AUTOMATIONS$EDIT_PROMPT_HINT": {
|
||||
"en": "Edits apply to future runs only.",
|
||||
"ja": "編集内容は今後の実行にのみ適用されます。",
|
||||
"zh-CN": "编辑仅适用于后续运行。",
|
||||
"zh-TW": "編輯僅適用於後續執行。",
|
||||
"ko-KR": "편집 내용은 이후 실행에만 적용됩니다.",
|
||||
"no": "Endringer gjelder bare for fremtidige kjøringer.",
|
||||
"it": "Le modifiche si applicano solo alle esecuzioni future.",
|
||||
"pt": "As edições se aplicam apenas a execuções futuras.",
|
||||
"es": "Las ediciones se aplican solo a ejecuciones futuras.",
|
||||
"ar": "تنطبق التعديلات على عمليات التشغيل المستقبلية فقط.",
|
||||
"fr": "Les modifications s'appliquent uniquement aux exécutions futures.",
|
||||
"tr": "Değişiklikler yalnızca gelecek çalıştırmalara uygulanır.",
|
||||
"de": "Änderungen gelten nur für zukünftige Ausführungen.",
|
||||
"uk": "Зміни застосовуються лише до майбутніх запусків.",
|
||||
"ca": "Els canvis només s'apliquen a execucions futures."
|
||||
},
|
||||
"AUTOMATIONS$CUSTOM_SCHEDULE_HINT": {
|
||||
"en": "This automation uses a custom schedule. Frequency editing is disabled to avoid breaking it.",
|
||||
"ja": "このオートメーションはカスタムスケジュールを使用しています。誤って壊さないよう、頻度の編集は無効化されています。",
|
||||
"zh-CN": "此自动化使用自定义计划。已禁用频率编辑以避免破坏它。",
|
||||
"zh-TW": "此自動化使用自訂排程。已停用頻率編輯以避免破壞它。",
|
||||
"ko-KR": "이 자동화는 사용자 지정 일정을 사용합니다. 손상을 피하기 위해 빈도 편집이 비활성화되어 있습니다.",
|
||||
"no": "Denne automatiseringen bruker en tilpasset plan. Frekvensredigering er deaktivert for å unngå å ødelegge den.",
|
||||
"it": "Questa automazione utilizza una pianificazione personalizzata. La modifica della frequenza è disabilitata per evitare di romperla.",
|
||||
"pt": "Esta automação usa um cronograma personalizado. A edição da frequência está desativada para evitar quebrá-la.",
|
||||
"es": "Esta automatización usa un cronograma personalizado. La edición de frecuencia está desactivada para evitar romperla.",
|
||||
"ar": "تستخدم هذه الأتمتة جدولاً مخصصًا. تم تعطيل تعديل التكرار لتجنب كسرها.",
|
||||
"fr": "Cette automatisation utilise un calendrier personnalisé. L'édition de la fréquence est désactivée pour éviter de la casser.",
|
||||
"tr": "Bu otomasyon özel bir zamanlama kullanır. Bozmamak için sıklık düzenlemesi devre dışı bırakılmıştır.",
|
||||
"de": "Diese Automatisierung verwendet einen benutzerdefinierten Zeitplan. Die Häufigkeitsbearbeitung ist deaktiviert, um sie nicht zu beschädigen.",
|
||||
"uk": "Ця автоматизація використовує власний розклад. Редагування частоти вимкнено, щоб не пошкодити її.",
|
||||
"ca": "Aquesta automatització utilitza un calendari personalitzat. L'edició de la freqüència està desactivada per evitar trencar-la."
|
||||
},
|
||||
"AUTOMATIONS$EDIT_SUCCESS": {
|
||||
"en": "Automation updated",
|
||||
"ja": "オートメーションを更新しました",
|
||||
"zh-CN": "自动化已更新",
|
||||
"zh-TW": "自動化已更新",
|
||||
"ko-KR": "자동화가 업데이트되었습니다",
|
||||
"no": "Automatisering oppdatert",
|
||||
"it": "Automazione aggiornata",
|
||||
"pt": "Automação atualizada",
|
||||
"es": "Automatización actualizada",
|
||||
"ar": "تم تحديث الأتمتة",
|
||||
"fr": "Automatisation mise à jour",
|
||||
"tr": "Otomasyon güncellendi",
|
||||
"de": "Automatisierung aktualisiert",
|
||||
"uk": "Автоматизацію оновлено",
|
||||
"ca": "S'ha actualitzat l'automatització"
|
||||
},
|
||||
"AUTOMATIONS$EDIT_ERROR": {
|
||||
"en": "Could not update automation",
|
||||
"ja": "オートメーションを更新できませんでした",
|
||||
"zh-CN": "无法更新自动化",
|
||||
"zh-TW": "無法更新自動化",
|
||||
"ko-KR": "자동화를 업데이트할 수 없습니다",
|
||||
"no": "Kunne ikke oppdatere automatiseringen",
|
||||
"it": "Impossibile aggiornare l'automazione",
|
||||
"pt": "Não foi possível atualizar a automação",
|
||||
"es": "No se pudo actualizar la automatización",
|
||||
"ar": "تعذر تحديث الأتمتة",
|
||||
"fr": "Impossible de mettre à jour l'automatisation",
|
||||
"tr": "Otomasyon güncellenemedi",
|
||||
"de": "Automatisierung konnte nicht aktualisiert werden",
|
||||
"uk": "Не вдалося оновити автоматизацію",
|
||||
"ca": "No s'ha pogut actualitzar l'automatització"
|
||||
},
|
||||
"AUTOMATIONS$NAME_REQUIRED": {
|
||||
"en": "Name is required",
|
||||
"ja": "名前は必須です",
|
||||
"zh-CN": "名称为必填项",
|
||||
"zh-TW": "名稱為必填項",
|
||||
"ko-KR": "이름은 필수입니다",
|
||||
"no": "Navn er påkrevd",
|
||||
"it": "Il nome è obbligatorio",
|
||||
"pt": "O nome é obrigatório",
|
||||
"es": "El nombre es obligatorio",
|
||||
"ar": "الاسم مطلوب",
|
||||
"fr": "Le nom est requis",
|
||||
"tr": "Ad gereklidir",
|
||||
"de": "Name ist erforderlich",
|
||||
"uk": "Назва обов'язкова",
|
||||
"ca": "El nom és obligatori"
|
||||
},
|
||||
"AUTOMATIONS$LOAD_MORE": {
|
||||
"en": "Load more",
|
||||
"ja": "さらに読み込む",
|
||||
|
||||
@@ -21,11 +21,13 @@ import { NotFoundState } from "#/components/features/automations/detail/not-foun
|
||||
import { ErrorState } from "#/components/features/automations/error-state";
|
||||
import { BackendNotConfigured } from "#/components/features/automations/backend-not-configured";
|
||||
import { DeleteConfirmationModal } from "#/components/features/automations/delete-confirmation-modal";
|
||||
import { EditAutomationModal } from "#/components/features/automations/detail/edit-automation-modal";
|
||||
|
||||
export default function AutomationDetail() {
|
||||
const { automationId } = useParams();
|
||||
const { navigate } = useNavigation();
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
|
||||
const {
|
||||
data: healthData,
|
||||
@@ -129,6 +131,10 @@ export default function AutomationDetail() {
|
||||
});
|
||||
};
|
||||
|
||||
// Edit is a local-backend-only feature in MVP — cloud automations
|
||||
// are managed elsewhere and we don't yet surface them here.
|
||||
const canEdit = active.backend.kind === "local";
|
||||
|
||||
return (
|
||||
<div className="min-h-full">
|
||||
<div className="p-6 max-w-4xl mx-auto">
|
||||
@@ -137,6 +143,7 @@ export default function AutomationDetail() {
|
||||
<DetailHeader
|
||||
automation={automation}
|
||||
onToggle={handleToggle}
|
||||
onEdit={canEdit ? () => setShowEditModal(true) : undefined}
|
||||
onDelete={() => setShowDeleteModal(true)}
|
||||
/>
|
||||
{automation.prompt && <PromptSection prompt={automation.prompt} />}
|
||||
@@ -155,6 +162,13 @@ export default function AutomationDetail() {
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
/>
|
||||
{canEdit && (
|
||||
<EditAutomationModal
|
||||
automation={automation}
|
||||
isOpen={showEditModal}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
export type SchedulePresetKind = "daily" | "weekdays" | "weekly";
|
||||
|
||||
export interface PresetSchedule {
|
||||
kind: SchedulePresetKind;
|
||||
hour: number;
|
||||
minute: number;
|
||||
weekday?: number;
|
||||
}
|
||||
|
||||
export interface CustomSchedule {
|
||||
kind: "custom";
|
||||
raw: string;
|
||||
hour?: number;
|
||||
minute?: number;
|
||||
}
|
||||
|
||||
export type ParsedSchedule = PresetSchedule | CustomSchedule;
|
||||
|
||||
const SINGLE_INT = /^(\d+)$/;
|
||||
|
||||
function parseSingleInt(
|
||||
field: string,
|
||||
min: number,
|
||||
max: number,
|
||||
): number | null {
|
||||
const match = field.match(SINGLE_INT);
|
||||
if (!match) return null;
|
||||
const value = Number(match[1]);
|
||||
if (Number.isNaN(value) || value < min || value > max) return null;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseCronSchedule(
|
||||
cron: string | undefined | null,
|
||||
): ParsedSchedule {
|
||||
const raw = (cron ?? "").trim();
|
||||
if (!raw) return { kind: "custom", raw: "" };
|
||||
|
||||
const fields = raw.split(/\s+/);
|
||||
if (fields.length !== 5) return { kind: "custom", raw };
|
||||
|
||||
const [minuteField, hourField, domField, monthField, dowField] = fields;
|
||||
|
||||
const minute = parseSingleInt(minuteField, 0, 59);
|
||||
const hour = parseSingleInt(hourField, 0, 23);
|
||||
|
||||
if (minute === null || hour === null) {
|
||||
return { kind: "custom", raw };
|
||||
}
|
||||
if (domField !== "*" || monthField !== "*") {
|
||||
return { kind: "custom", raw, hour, minute };
|
||||
}
|
||||
|
||||
if (dowField === "*" || dowField === "0-6") {
|
||||
return { kind: "daily", hour, minute };
|
||||
}
|
||||
if (dowField === "1-5") {
|
||||
return { kind: "weekdays", hour, minute };
|
||||
}
|
||||
const weekday = parseSingleInt(dowField, 0, 6);
|
||||
if (weekday !== null) {
|
||||
return { kind: "weekly", hour, minute, weekday };
|
||||
}
|
||||
return { kind: "custom", raw, hour, minute };
|
||||
}
|
||||
|
||||
export function buildCronSchedule(input: PresetSchedule): string {
|
||||
const { kind, hour, minute, weekday } = input;
|
||||
switch (kind) {
|
||||
case "daily":
|
||||
return `${minute} ${hour} * * *`;
|
||||
case "weekdays":
|
||||
return `${minute} ${hour} * * 1-5`;
|
||||
case "weekly":
|
||||
return `${minute} ${hour} * * ${weekday ?? 1}`;
|
||||
default: {
|
||||
const _exhaustive: never = kind;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatTimeOfDay(hour: number, minute: number): string {
|
||||
const hh = String(hour).padStart(2, "0");
|
||||
const mm = String(minute).padStart(2, "0");
|
||||
return `${hh}:${mm}`;
|
||||
}
|
||||
|
||||
export function parseTimeOfDay(
|
||||
value: string,
|
||||
): { hour: number; minute: number } | null {
|
||||
const match = value.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!match) return null;
|
||||
const hour = Number(match[1]);
|
||||
const minute = Number(match[2]);
|
||||
if (
|
||||
Number.isNaN(hour) ||
|
||||
Number.isNaN(minute) ||
|
||||
hour < 0 ||
|
||||
hour > 23 ||
|
||||
minute < 0 ||
|
||||
minute > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { hour, minute };
|
||||
}
|
||||
Reference in New Issue
Block a user