feat: edit local automations from the detail page (#611)

This commit is contained in:
Hiep Le
2026-05-19 13:11:24 +07:00
committed by GitHub
parent 1ee1de48cd
commit 8bc8d613ed
9 changed files with 1274 additions and 5 deletions
@@ -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();
});
});
+48 -5
View File
@@ -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>
);
}
+13
View File
@@ -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({
+442
View File
@@ -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": "さらに読み込む",
+14
View File
@@ -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>
+107
View File
@@ -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 };
}