feat(i18n): localize Automations (#2247)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import Link from "@/components/routing/app-link";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
|
||||
export default function AutomationsTopLevelPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const workspaces = useAppStore((s) => s.workspaces.items);
|
||||
|
||||
@@ -24,15 +26,15 @@ export default function AutomationsTopLevelPage() {
|
||||
if (workspaces.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<p className="text-sm font-medium">No workspaces yet</p>
|
||||
<p className="text-sm font-medium">{t("automations:noWorkspacesYet")}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Create a workspace first, then schedule automations inside it.
|
||||
{t("automations:noWorkspacesDescription")}
|
||||
</p>
|
||||
<Link
|
||||
href="/settings/workspace"
|
||||
className="mt-4 inline-block rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground hover:bg-primary/90 cursor-pointer"
|
||||
>
|
||||
Create workspace
|
||||
{t("automations:createWorkspace")}
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
@@ -41,8 +43,8 @@ export default function AutomationsTopLevelPage() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Automations</h2>
|
||||
<p className="text-sm text-muted-foreground">Pick a workspace to manage its automations.</p>
|
||||
<h2 className="text-lg font-semibold">{t("common:automations")}</h2>
|
||||
<p className="text-sm text-muted-foreground">{t("automations:pickWorkspace")}</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{workspaces.map((ws) => (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
@@ -30,26 +31,29 @@ export function NameField({
|
||||
isDirty: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="space-y-2 rounded-lg border bg-card p-4"
|
||||
data-settings-dirty={isDirty}
|
||||
data-settings-dirty-level="container"
|
||||
>
|
||||
<RequiredFieldLabel htmlFor="automation-name">Name</RequiredFieldLabel>
|
||||
<RequiredFieldLabel htmlFor="automation-name">
|
||||
{t("automations:nameLabel")}
|
||||
</RequiredFieldLabel>
|
||||
<Input
|
||||
id="automation-name"
|
||||
data-testid="automation-name-input"
|
||||
value={value}
|
||||
data-settings-dirty={isDirty}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder="Automation name"
|
||||
placeholder={t("automations:namePlaceholder")}
|
||||
aria-describedby={!value.trim() ? "automation-name-help" : undefined}
|
||||
aria-invalid={!value.trim() ? true : undefined}
|
||||
/>
|
||||
{!value.trim() && (
|
||||
<p id="automation-name-help" className="text-xs text-muted-foreground">
|
||||
Enter an automation name to enable saving.
|
||||
{t("automations:nameHelp")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -91,11 +95,12 @@ export function WhenSection({
|
||||
savedTriggers: AutomationTrigger[];
|
||||
isDirty: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="text-base font-medium">When</h3>
|
||||
<p className="text-sm text-muted-foreground">What causes this automation to run</p>
|
||||
<h3 className="text-base font-medium">{t("automations:whenTitle")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("automations:whenDescription")}</p>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-lg border bg-card p-4"
|
||||
@@ -135,6 +140,7 @@ export function ThenSection({
|
||||
savedForm: FormState;
|
||||
updateField: UpdateField;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const dirtyFields: Array<keyof FormState> = [
|
||||
"taskTitleTemplate",
|
||||
"prompt",
|
||||
@@ -149,10 +155,8 @@ export function ThenSection({
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<h3 className="text-base font-medium">Then</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A new task will be created each time this automation triggers
|
||||
</p>
|
||||
<h3 className="text-base font-medium">{t("automations:thenTitle")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("automations:thenDescription")}</p>
|
||||
</div>
|
||||
<div
|
||||
className="rounded-lg border bg-card p-4 space-y-4"
|
||||
@@ -160,18 +164,18 @@ export function ThenSection({
|
||||
data-settings-dirty-level="container"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Task title</Label>
|
||||
<Label className="text-xs">{t("automations:taskTitleLabel")}</Label>
|
||||
<Input
|
||||
value={form.taskTitleTemplate}
|
||||
data-settings-dirty={isAutomationFieldDirty(form, savedForm, "taskTitleTemplate")}
|
||||
onChange={(event) =>
|
||||
updateField("taskTitleTemplate", clampTaskTitleInput(event.target.value))
|
||||
}
|
||||
placeholder={defaultTaskTitle || "[Auto] automation name"}
|
||||
// defaultTaskTitle is the backend trigger type's own template — a
|
||||
// persisted value, not copy. The fallback is the example hint.
|
||||
placeholder={defaultTaskTitle || t("automations:taskTitlePlaceholder")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the default. Supports placeholders.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("automations:taskTitleHelp")}</p>
|
||||
</div>
|
||||
<PromptSection
|
||||
value={form.prompt}
|
||||
@@ -221,6 +225,7 @@ export function SettingsSection({
|
||||
savedForm: FormState;
|
||||
updateField: UpdateField;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const enabledIsDirty = isAutomationFieldDirty(form, savedForm, "enabled");
|
||||
const maxRunsIsDirty = isAutomationFieldDirty(form, savedForm, "maxConcurrentRuns");
|
||||
return (
|
||||
@@ -229,7 +234,9 @@ export function SettingsSection({
|
||||
data-settings-dirty={enabledIsDirty || maxRunsIsDirty}
|
||||
data-settings-dirty-level="container"
|
||||
>
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Settings</Label>
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t("common:settings")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
@@ -238,10 +245,10 @@ export function SettingsSection({
|
||||
onCheckedChange={(value) => updateField("enabled", value)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<Label className="text-sm">Enabled</Label>
|
||||
<Label className="text-sm">{t("automations:enabledLabel")}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-sm">Max concurrent runs</Label>
|
||||
<Label className="text-sm">{t("automations:maxConcurrentRuns")}</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -267,6 +274,7 @@ export function EditorFooter({
|
||||
isNew: boolean;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-3 pt-4">
|
||||
{!isNew && (
|
||||
@@ -278,7 +286,7 @@ export function EditorFooter({
|
||||
disabled={saving}
|
||||
>
|
||||
<IconTrash className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
{t("automations:delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
|
||||
import { toast } from "@/lib/toast/sonner";
|
||||
@@ -45,6 +47,10 @@ type AutomationEditorProps = {
|
||||
automationId: string | null; // null = create mode
|
||||
};
|
||||
|
||||
// The seeded prompt is persisted on the automation and sent to the agent
|
||||
// verbatim, and it is compared with `===` in useAutoPromptUpdate to decide
|
||||
// whether the user has edited it. Both make it protocol, not copy — the same
|
||||
// call the Jira (#2177), Linear (#2179) and Sentry (#2182) migrations made.
|
||||
const DEFAULT_PROMPT = "Run scheduled automation.\n\nTrigger: {{trigger.type}}";
|
||||
|
||||
const defaultForm: FormState = {
|
||||
@@ -169,7 +175,7 @@ function useSaveHandler(opts: SaveHandlerOpts): () => Promise<void> {
|
||||
setCurrentId(a.id);
|
||||
setCreatedWebhook({ url: buildWebhookUrl(a.id), secret: a.webhook_secret });
|
||||
} else {
|
||||
toast.success("Automation created");
|
||||
toast.success(t("automations:automationCreated"));
|
||||
runWithNavigationBlockerBypassed(() =>
|
||||
router.push(`/settings/workspace/${workspaceId}/automations`),
|
||||
);
|
||||
@@ -181,8 +187,10 @@ function useSaveHandler(opts: SaveHandlerOpts): () => Promise<void> {
|
||||
onSaved(formWithPromotedSelections, persistedTriggers);
|
||||
}
|
||||
} catch (err) {
|
||||
// The interpolated value is an API/network diagnostic and stays English
|
||||
// by design — see docs/i18n.md ("interpolated value" limit).
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(`Failed to save automation: ${msg}`);
|
||||
toast.error(t("automations:failedToSaveAutomation", { error: msg }));
|
||||
throw err;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -271,12 +279,18 @@ function useAutomationSaveContributor(options: {
|
||||
discard: () => void;
|
||||
}) {
|
||||
const { isNew, currentId, revision, savedRevision, canSave, save, discard } = options;
|
||||
// `invalidReason` is resolved during RENDER, so it needs the hook rather than
|
||||
// the module-level `t` the toasts below use: nothing else in AutomationEditor
|
||||
// calls useTranslation(), so without this subscription the tooltip would keep
|
||||
// the previous locale's text until some unrelated re-render. (The toasts are
|
||||
// fine on the module-level `t` — they resolve at call time inside a callback.)
|
||||
const { t: translate } = useTranslation();
|
||||
useSettingsSaveContributor({
|
||||
id: `automation:${currentId ?? "new"}`,
|
||||
revision,
|
||||
isDirty: isNew || revision !== savedRevision,
|
||||
canSave,
|
||||
invalidReason: canSave ? undefined : "Complete the required automation fields before saving.",
|
||||
invalidReason: canSave ? undefined : translate("automations:completeRequiredFields"),
|
||||
save,
|
||||
discard,
|
||||
});
|
||||
@@ -317,8 +331,10 @@ function useAutomationPersistence(options: AutomationPersistenceOptions) {
|
||||
options.remove,
|
||||
options.router,
|
||||
(error) =>
|
||||
toast.error("Failed to delete automation", {
|
||||
description: error instanceof Error ? error.message : "Request failed",
|
||||
toast.error(t("automations:failedToDeleteAutomation"), {
|
||||
// An Error message here is an API diagnostic and stays English; the
|
||||
// fallback for a missing payload is copy.
|
||||
description: error instanceof Error ? error.message : t("common:requestFailed"),
|
||||
}),
|
||||
);
|
||||
const isRunMode = options.form.executionMode === "run";
|
||||
|
||||
@@ -125,6 +125,9 @@ export function buildCreatePayload(
|
||||
): CreateAutomationRequest {
|
||||
return {
|
||||
workspace_id: workspaceId,
|
||||
// Persisted as the automation's name — user data, so it stays English
|
||||
// rather than writing a locale-dependent value into the record. (Unreachable
|
||||
// in practice: canSave requires a non-empty name.)
|
||||
name: form.name || "New Automation",
|
||||
description: form.description,
|
||||
workflow_id: form.workflowId,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Label } from "@kandev/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { LocalRepository, Repository } from "@/lib/types/http";
|
||||
import {
|
||||
buildRepositoryItems,
|
||||
@@ -37,9 +38,10 @@ export function AutomationRepositoryRows({
|
||||
onChange,
|
||||
isDirty,
|
||||
}: AutomationRepositoryRowsProps) {
|
||||
const { t } = useTranslation();
|
||||
const baseItems = useMemo(
|
||||
() => buildRepositoryItems(repositories, discoveredRepos, { includeNone: false }),
|
||||
[repositories, discoveredRepos],
|
||||
() => buildRepositoryItems(repositories, discoveredRepos, t, { includeNone: false }),
|
||||
[repositories, discoveredRepos, t],
|
||||
);
|
||||
const usedOptionIds = new Set(selections.map(selectionToOptionId));
|
||||
const nextAvailableItem = baseItems.find((item) => !usedOptionIds.has(item.id));
|
||||
@@ -61,7 +63,7 @@ export function AutomationRepositoryRows({
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5" data-testid={testId} data-settings-dirty={isDirty}>
|
||||
<Label className="text-xs">Repository</Label>
|
||||
<Label className="text-xs">{t("automations:repositoryLabel")}</Label>
|
||||
<div className="space-y-2">
|
||||
{selections.map((selection, index) => (
|
||||
<RepositoryRow
|
||||
@@ -82,13 +84,10 @@ export function AutomationRepositoryRows({
|
||||
onClick={addRow}
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5 mr-1" />
|
||||
Add repository
|
||||
{t("automations:addRepository")}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
With no repositories selected, this automation runs against the workspace's first
|
||||
repository.
|
||||
</p>
|
||||
<p className="text-[10px] text-muted-foreground">{t("automations:noRepositoriesHelp")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -106,12 +105,13 @@ function RepositoryRow({
|
||||
onChange: (optionId: string) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const currentOptionId = selectionToOptionId(selection);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={currentOptionId || undefined} onValueChange={onChange}>
|
||||
<SelectTrigger className="cursor-pointer flex-1">
|
||||
<SelectValue placeholder="Select a repository" />
|
||||
<SelectValue placeholder={t("automations:selectRepositoryPlaceholder")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{baseItems.map((item) => {
|
||||
@@ -121,7 +121,7 @@ function RepositoryRow({
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
disabled={alreadyAdded}
|
||||
title={alreadyAdded ? "Already added" : undefined}
|
||||
title={alreadyAdded ? t("automations:alreadyAdded") : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
@@ -134,7 +134,7 @@ function RepositoryRow({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="cursor-pointer"
|
||||
aria-label="Remove repository"
|
||||
aria-label={t("automations:removeRepository")}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5 text-destructive" />
|
||||
|
||||
@@ -25,9 +25,16 @@ export function selectionToOptionId(sel: RepositorySelection): string {
|
||||
// used by the single-picker fallback (executor doesn't support multi-repo)
|
||||
// but omitted from per-row multi-repo pickers, where removing a row is how
|
||||
// you clear it.
|
||||
//
|
||||
// `t` is threaded in rather than imported from @/lib/i18n at module scope for
|
||||
// two reasons: this is a plain .ts helper with no JSX, so the eslint guard
|
||||
// never sees the option label at all, and both callers memoize the result —
|
||||
// a captured module-level `t` would leave the memo stale after a locale
|
||||
// switch. Every repository name in the list is user data and stays verbatim.
|
||||
export function buildRepositoryItems(
|
||||
workspaceRepos: Repository[],
|
||||
discoveredRepos: LocalRepository[],
|
||||
t: (key: string) => string,
|
||||
options: { includeNone?: boolean } = {},
|
||||
): Array<{ id: string; label: string }> {
|
||||
const registeredPaths = new Set(
|
||||
@@ -39,7 +46,7 @@ export function buildRepositoryItems(
|
||||
const items: Array<{ id: string; label: string }> =
|
||||
options.includeNone === false
|
||||
? []
|
||||
: [{ id: REPO_NONE_OPTION_ID, label: "None — no repository" }];
|
||||
: [{ id: REPO_NONE_OPTION_ID, label: t("automations:repositoryNone") }];
|
||||
for (const r of workspaceRepos) {
|
||||
items.push({ id: r.id, label: r.name || `${r.provider_owner}/${r.provider_name}` });
|
||||
}
|
||||
@@ -62,6 +69,9 @@ export function pickSelectionFromOptionId(
|
||||
return {
|
||||
kind: "discovered",
|
||||
path,
|
||||
// Persisted as the created repository's name (see resolveOneRepositoryId
|
||||
// in automation-payload.ts) — user data, so it stays English rather than
|
||||
// writing a locale-dependent name into the record.
|
||||
name: match?.name ?? path.split("/").pop() ?? "New Repository",
|
||||
defaultBranch: match?.default_branch ?? "",
|
||||
};
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { cleanup, fireEvent, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { TooltipProvider } from "@kandev/ui/tooltip";
|
||||
import { ScheduleSelector } from "./schedule-selector";
|
||||
import { WebhookConfig } from "./trigger-configs/webhook-config";
|
||||
import { WebhookCreatedDialog } from "./webhook-created-dialog";
|
||||
|
||||
vi.mock("@/lib/api/domains/automation-api", () => ({
|
||||
revealWebhookSecret: vi.fn().mockResolvedValue({ webhook_secret: "s3cret" }),
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
/**
|
||||
* The three help sentences in this directory are `<Trans>` blocks whose `<n>`
|
||||
* indices address `<code>` elements positionally, and every cron expression,
|
||||
* header name and placeholder path travels as an interpolation VALUE so the
|
||||
* pseudo-locale cannot accent it into syntax that no longer parses.
|
||||
*
|
||||
* Both halves fail silently. A drifted index renders duplicated fragments with
|
||||
* empty tags, and `check-trans-indices.mjs` only proves each `<n>` lands on an
|
||||
* element — not that the reassembled sentence is the one the surface used to
|
||||
* render. A token moved into the message body would only show up in a
|
||||
* translated build. So each test reconstructs the whole sentence and asserts
|
||||
* the syntax landed inside a `<code>`.
|
||||
*
|
||||
* `{{webhook.<path>}}` additionally checks that i18next does not re-interpolate
|
||||
* a substituted value: the placeholder's own braces must survive verbatim.
|
||||
*
|
||||
* One thing these do NOT detect, deliberately: permuting the `<n>` indices among
|
||||
* themselves. Every child here is an identical self-closing `<code />`, so any
|
||||
* permutation renders byte-identically — the drift docs/i18n.md warns about
|
||||
* needs children that differ. Probing with an index swap therefore proves
|
||||
* nothing; the mutations these were verified against are a value baked into the
|
||||
* message body, a dropped tag, and a reworded sentence.
|
||||
*/
|
||||
function codeTexts(container: HTMLElement): string[] {
|
||||
return Array.from(container.querySelectorAll("code")).map((el) => el.textContent ?? "");
|
||||
}
|
||||
|
||||
function text(el: Element | null): string {
|
||||
return (el?.textContent ?? "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function renderSchedule(onChange = vi.fn()) {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<ScheduleSelector config={null} onChange={onChange} />
|
||||
</TooltipProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ScheduleSelector syntax help", () => {
|
||||
it("renders the @every help as one sentence with each expression in a code element", () => {
|
||||
const { container } = renderSchedule();
|
||||
|
||||
const paragraphs = Array.from(container.querySelectorAll("p"));
|
||||
const help = paragraphs.find((p) => p.textContent?.includes("@every"));
|
||||
expect(text(help ?? null)).toBe(
|
||||
"Use @every with a duration (e.g., @every 10m, @every 2h30m) or shorthands like @hourly, @daily, @weekly.",
|
||||
);
|
||||
expect(codeTexts(container)).toEqual([
|
||||
"@every",
|
||||
"@every 10m",
|
||||
"@every 2h30m",
|
||||
"@hourly",
|
||||
"@daily",
|
||||
"@weekly",
|
||||
]);
|
||||
});
|
||||
|
||||
it("labels the interval presets from count rather than a baked-in plural", () => {
|
||||
const { container } = renderSchedule();
|
||||
|
||||
const labels = Array.from(container.querySelectorAll("button")).map((b) => text(b));
|
||||
expect(labels).toEqual(["5 min", "15 min", "30 min", "1 hour", "6 hours", "Daily", "Weekly"]);
|
||||
});
|
||||
|
||||
it("keeps @every verbatim in the invalid-expression error", async () => {
|
||||
const { container, findByText } = renderSchedule();
|
||||
|
||||
const input = container.querySelector('[data-testid="schedule-custom-input"]')!;
|
||||
fireEvent.change(input, { target: { value: "not a cron" } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
// The token travels as an interpolation value, so an accented pseudo build
|
||||
// still tells the user to type the string the scheduler actually accepts.
|
||||
await findByText(
|
||||
"Invalid expression. Use @every with a duration, a shorthand, or a 5-field cron.",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the cron expression out of the preset's persisted value", () => {
|
||||
const { container } = renderSchedule();
|
||||
|
||||
// The testid carries the expression, so it doubles as a check that the
|
||||
// persisted value is the untranslated shorthand and not the label.
|
||||
expect(container.querySelector('[data-testid="schedule-preset-@hourly"]')).not.toBeNull();
|
||||
expect(container.querySelector('[data-testid="schedule-preset-@every 6h"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebhookConfig usage help", () => {
|
||||
it("renders one sentence with the header and both placeholder paths in code elements", async () => {
|
||||
const { container, findByText } = render(<WebhookConfig automationId="a1" workspaceId="w1" />);
|
||||
await findByText("Webhook secret");
|
||||
|
||||
const help = Array.from(container.querySelectorAll("p")).find((p) =>
|
||||
p.textContent?.includes("POST"),
|
||||
);
|
||||
expect(text(help ?? null)).toBe(
|
||||
"Send a POST request with a JSON body and the secret in the X-Webhook-Secret header. " +
|
||||
"Reference fields from the payload with {{webhook.<path>}}, e.g. " +
|
||||
"{{webhook.pull_request.number}}.",
|
||||
);
|
||||
// The placeholder paths carry their own `{{ }}`. i18next must substitute
|
||||
// them verbatim and not treat the result as another interpolation — an
|
||||
// empty or mangled path here is the failure this asserts against.
|
||||
expect(codeTexts(container)).toEqual(
|
||||
expect.arrayContaining([
|
||||
"X-Webhook-Secret",
|
||||
"{{webhook.<path>}}",
|
||||
"{{webhook.pull_request.number}}",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WebhookCreatedDialog description", () => {
|
||||
it("renders one sentence with the secret header in a code element", () => {
|
||||
render(
|
||||
<WebhookCreatedDialog
|
||||
open
|
||||
webhookUrl="http://localhost:3000/api/v1/automations/webhook/a1"
|
||||
webhookSecret="s3cret"
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Radix portals dialog content to document.body, so scope to that.
|
||||
const body = document.body;
|
||||
const description = body.querySelector("[data-slot='dialog-description']") ?? body;
|
||||
expect(text(description)).toContain(
|
||||
"Configure your external system to POST to this URL with the secret in the X-Webhook-Secret header.",
|
||||
);
|
||||
expect(codeTexts(body)).toContain("X-Webhook-Secret");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
@@ -13,6 +14,7 @@ type AutomationsListPageProps = {
|
||||
};
|
||||
|
||||
export function AutomationsListPage({ workspaceId }: AutomationsListPageProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const { items, loading, enable, disable, trigger, remove } = useAutomations(workspaceId);
|
||||
const enabledDrafts = useAutomationEnabledDrafts({ automations: items, enable, disable });
|
||||
@@ -31,11 +33,9 @@ export function AutomationsListPage({ workspaceId }: AutomationsListPageProps) {
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<IconBolt className="h-5 w-5" />
|
||||
Automations
|
||||
{t("common:automations")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Create rules that automatically trigger agent tasks.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("automations:listDescription")}</p>
|
||||
</div>
|
||||
<Button
|
||||
data-testid="new-automation-button"
|
||||
@@ -43,12 +43,14 @@ export function AutomationsListPage({ workspaceId }: AutomationsListPageProps) {
|
||||
onClick={() => router.push(`/settings/workspace/${workspaceId}/automations/new`)}
|
||||
>
|
||||
<IconPlus className="h-4 w-4 mr-1" />
|
||||
New Automation
|
||||
{t("automations:newAutomation")}
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
{loading && items.length === 0 ? (
|
||||
<div className="py-12 text-center text-muted-foreground">Loading automations...</div>
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
{t("automations:loadingAutomations")}
|
||||
</div>
|
||||
) : (
|
||||
<AutomationsTable
|
||||
automations={enabledDrafts.automations}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -27,24 +28,28 @@ const TRIGGER_BADGE_VARIANT: Record<TriggerType, string> = {
|
||||
webhook: "bg-orange-500/15 text-orange-400 border-orange-500/20",
|
||||
};
|
||||
|
||||
const TRIGGER_LABELS: Record<TriggerType, string> = {
|
||||
scheduled: "Scheduled",
|
||||
github_pr: "GitHub PR",
|
||||
github_push: "GitHub Push",
|
||||
github_ci: "GitHub CI",
|
||||
webhook: "Webhook",
|
||||
// The keys are persisted TriggerType identifiers the backend stores and
|
||||
// replays — only the catalog key is display copy, so the map holds keys and
|
||||
// resolves them at render (a module-scope t() would freeze at the boot locale).
|
||||
const TRIGGER_LABEL_KEYS: Record<TriggerType, string> = {
|
||||
scheduled: "automations:triggerLabelScheduled",
|
||||
github_pr: "automations:triggerLabelGithubPr",
|
||||
github_push: "automations:triggerLabelGithubPush",
|
||||
github_ci: "automations:triggerLabelGithubCi",
|
||||
webhook: "automations:triggerLabelWebhook",
|
||||
};
|
||||
|
||||
function TriggerBadges({ triggers }: { triggers: Automation["triggers"] }) {
|
||||
const { t } = useTranslation();
|
||||
const items = triggers ?? [];
|
||||
if (items.length === 0) {
|
||||
return <span className="text-xs text-muted-foreground">None</span>;
|
||||
return <span className="text-xs text-muted-foreground">{t("automations:noTriggers")}</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{items.map((t) => (
|
||||
<Badge key={t.id} variant="outline" className={TRIGGER_BADGE_VARIANT[t.type]}>
|
||||
{TRIGGER_LABELS[t.type]}
|
||||
{items.map((trigger) => (
|
||||
<Badge key={trigger.id} variant="outline" className={TRIGGER_BADGE_VARIANT[trigger.type]}>
|
||||
{t(TRIGGER_LABEL_KEYS[trigger.type])}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
@@ -60,6 +65,7 @@ function RowActions({
|
||||
onTrigger: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
<Tooltip>
|
||||
@@ -73,7 +79,7 @@ function RowActions({
|
||||
<IconPlayerPlay className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Trigger manually</TooltipContent>
|
||||
<TooltipContent>{t("automations:triggerManually")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -86,7 +92,7 @@ function RowActions({
|
||||
<IconTrash className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
<TooltipContent>{t("automations:delete")}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
@@ -100,6 +106,7 @@ export function AutomationsTable({
|
||||
onTrigger,
|
||||
onDelete,
|
||||
}: AutomationsTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
@@ -112,11 +119,11 @@ export function AutomationsTable({
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent focus-within:bg-transparent">
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Mode</TableHead>
|
||||
<TableHead>Triggers</TableHead>
|
||||
<TableHead>Enabled</TableHead>
|
||||
<TableHead>Last Triggered</TableHead>
|
||||
<TableHead>{t("automations:columnName")}</TableHead>
|
||||
<TableHead>{t("automations:columnMode")}</TableHead>
|
||||
<TableHead>{t("automations:columnTriggers")}</TableHead>
|
||||
<TableHead>{t("automations:columnEnabled")}</TableHead>
|
||||
<TableHead>{t("automations:columnLastTriggered")}</TableHead>
|
||||
<TableHead className="w-[100px]" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -128,7 +135,7 @@ export function AutomationsTable({
|
||||
className="text-center text-muted-foreground py-8"
|
||||
data-testid="automations-empty"
|
||||
>
|
||||
No automations yet
|
||||
{t("automations:noAutomationsYet")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
@@ -146,7 +153,9 @@ export function AutomationsTable({
|
||||
<TableCell className="font-medium">{a.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{a.execution_mode === "run" ? "Run" : "Task"}
|
||||
{a.execution_mode === "run"
|
||||
? t("automations:executionModeRunShort")
|
||||
: t("automations:executionModeTaskShort")}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -163,7 +172,9 @@ export function AutomationsTable({
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{a.last_triggered_at ? formatRelativeTime(a.last_triggered_at) : "Never"}
|
||||
{a.last_triggered_at
|
||||
? formatRelativeTime(a.last_triggered_at)
|
||||
: t("automations:neverTriggered")}
|
||||
</TableCell>
|
||||
<TableCell onClick={(e) => e.stopPropagation()}>
|
||||
<RowActions id={a.id} onTrigger={onTrigger} onDelete={onDelete} />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
@@ -72,9 +73,12 @@ const CLEAN_FIELDS = {
|
||||
repositorySelections: false,
|
||||
};
|
||||
|
||||
// `id` is the persisted ExecutionMode the backend stores and replays; only the
|
||||
// catalog key is copy. Resolved at render — a module-scope t() would freeze at
|
||||
// the boot locale.
|
||||
const EXECUTION_MODE_ITEMS = [
|
||||
{ id: "task", label: "Task — creates a tracked kanban task" },
|
||||
{ id: "run", label: "Run — fire-and-forget, hidden from kanban" },
|
||||
{ id: "task", labelKey: "automations:executionModeTask" },
|
||||
{ id: "run", labelKey: "automations:executionModeRun" },
|
||||
];
|
||||
|
||||
function useDiscoveredRepositories(workspaceId: string) {
|
||||
@@ -99,9 +103,16 @@ function useDiscoveredRepositories(workspaceId: string) {
|
||||
|
||||
type StepOption = { id: string; name: string };
|
||||
|
||||
function getWorkflowStepHelpText(workflowId: string, workflowStepId: string): string | undefined {
|
||||
if (!workflowId) return "Select a workflow before choosing a step.";
|
||||
if (!workflowStepId) return "Select a workflow step to enable saving.";
|
||||
// A plain function returning copy is invisible to `i18next/no-literal-string`
|
||||
// (it only inspects literals in JSX), so `t` is threaded in explicitly rather
|
||||
// than imported at module scope.
|
||||
function getWorkflowStepHelpText(
|
||||
workflowId: string,
|
||||
workflowStepId: string,
|
||||
t: (key: string) => string,
|
||||
): string | undefined {
|
||||
if (!workflowId) return t("automations:workflowStepHelpNoWorkflow");
|
||||
if (!workflowStepId) return t("automations:workflowStepHelp");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -142,6 +153,7 @@ function useConfigSectionComputed({
|
||||
repositorySelections,
|
||||
repositories,
|
||||
discoveredRepos,
|
||||
t,
|
||||
}: {
|
||||
agentProfiles: AgentProfileLike[];
|
||||
executors: ExecutorLike[];
|
||||
@@ -149,6 +161,7 @@ function useConfigSectionComputed({
|
||||
repositorySelections: RepositorySelection[];
|
||||
repositories: Repository[];
|
||||
discoveredRepos: LocalRepository[];
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
const filteredAgentProfiles = agentProfiles.filter((profile) => !profile.cli_passthrough);
|
||||
// Profiles returned by the executors list/boot payload don't always carry
|
||||
@@ -177,8 +190,8 @@ function useConfigSectionComputed({
|
||||
};
|
||||
});
|
||||
const singleRepositoryItems = useMemo(
|
||||
() => buildRepositoryItems(repositories, discoveredRepos),
|
||||
[repositories, discoveredRepos],
|
||||
() => buildRepositoryItems(repositories, discoveredRepos, t),
|
||||
[repositories, discoveredRepos, t],
|
||||
);
|
||||
return { filteredAgentProfiles, executorItems, supportsMultiRepo, singleRepositoryItems };
|
||||
}
|
||||
@@ -200,6 +213,7 @@ export function ConfigSection({
|
||||
onRepositoriesChange,
|
||||
onExecutionModeChange,
|
||||
}: ConfigSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
useSettingsData(true);
|
||||
useWorkflows(workspaceId, true);
|
||||
const { repositories } = useRepositories(workspaceId, true);
|
||||
@@ -220,22 +234,23 @@ export function ConfigSection({
|
||||
repositorySelections,
|
||||
repositories,
|
||||
discoveredRepos,
|
||||
t,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
Configuration
|
||||
{t("automations:configurationLabel")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<SelectField
|
||||
testId="execution-mode-selector"
|
||||
label="Execution Mode"
|
||||
label={t("automations:executionModeLabel")}
|
||||
value={executionMode}
|
||||
isDirty={dirtyFields.executionMode}
|
||||
onChange={(v) => onExecutionModeChange(v as ExecutionMode)}
|
||||
placeholder="Select mode"
|
||||
items={EXECUTION_MODE_ITEMS}
|
||||
placeholder={t("automations:executionModePlaceholder")}
|
||||
items={EXECUTION_MODE_ITEMS.map((item) => ({ id: item.id, label: t(item.labelKey) }))}
|
||||
/>
|
||||
{!isRunMode && (
|
||||
<WorkflowFields
|
||||
@@ -250,22 +265,22 @@ export function ConfigSection({
|
||||
/>
|
||||
)}
|
||||
<SelectField
|
||||
label="Agent Profile"
|
||||
label={t("automations:agentProfileLabel")}
|
||||
value={agentProfileId}
|
||||
isDirty={dirtyFields.agentProfileId}
|
||||
onChange={onAgentProfileChange}
|
||||
placeholder="Select agent"
|
||||
placeholder={t("automations:agentProfilePlaceholder")}
|
||||
items={filteredAgentProfiles.map((p) => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
}))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Executor Profile"
|
||||
label={t("automations:executorProfileLabel")}
|
||||
value={executorProfileId}
|
||||
isDirty={dirtyFields.executorProfileId}
|
||||
onChange={onExecutorProfileChange}
|
||||
placeholder="Select executor"
|
||||
placeholder={t("automations:executorProfilePlaceholder")}
|
||||
items={executorItems}
|
||||
/>
|
||||
<RepositoryPickerField
|
||||
@@ -307,6 +322,7 @@ function RepositoryPickerField({
|
||||
isDirty: boolean;
|
||||
onRepositoriesChange: (selections: RepositorySelection[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (supportsMultiRepo && !isPRTrigger) {
|
||||
return (
|
||||
<AutomationRepositoryRows
|
||||
@@ -322,17 +338,17 @@ function RepositoryPickerField({
|
||||
return (
|
||||
<SelectField
|
||||
testId="repository-selector"
|
||||
label="Repository"
|
||||
label={t("automations:repositoryLabel")}
|
||||
value={selectionToOptionId(repositorySelections[0] ?? { kind: "none" })}
|
||||
isDirty={isDirty}
|
||||
onChange={(v) => {
|
||||
const picked = pickSelectionFromOptionId(v, repositories, discoveredRepos);
|
||||
onRepositoriesChange(picked.kind === "none" ? [] : [picked]);
|
||||
}}
|
||||
placeholder="Auto"
|
||||
placeholder={t("automations:repositoryPlaceholderAuto")}
|
||||
items={singleRepositoryItems}
|
||||
disabled={isPRTrigger}
|
||||
helpText={isPRTrigger ? "PR triggers always use the PR's own repository." : undefined}
|
||||
helpText={isPRTrigger ? t("automations:prTriggerRepositoryHelp") : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -356,6 +372,7 @@ function WorkflowFields({
|
||||
onWorkflowChange: (id: string) => void;
|
||||
onStepChange: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The step list is empty until a workflow is picked. Showing an empty
|
||||
// dropdown next to the workflow select invites users to click it first
|
||||
// and bounce off — keep the field in the DOM (so its testid is stable
|
||||
@@ -366,26 +383,30 @@ function WorkflowFields({
|
||||
<>
|
||||
<SelectField
|
||||
testId="workflow-selector"
|
||||
label="Workflow"
|
||||
label={t("automations:workflowLabel")}
|
||||
required
|
||||
value={workflowId}
|
||||
isDirty={workflowDirty}
|
||||
onChange={onWorkflowChange}
|
||||
placeholder="Select workflow"
|
||||
placeholder={t("automations:workflowPlaceholder")}
|
||||
items={workflows.map((w) => ({ id: w.id, label: w.name }))}
|
||||
helpText={!hasWorkflow ? "Select a workflow to enable saving." : undefined}
|
||||
helpText={!hasWorkflow ? t("automations:workflowHelp") : undefined}
|
||||
/>
|
||||
<SelectField
|
||||
testId="workflow-step-selector"
|
||||
label="Workflow Step"
|
||||
label={t("automations:workflowStepLabel")}
|
||||
required
|
||||
value={workflowStepId}
|
||||
isDirty={workflowStepDirty}
|
||||
onChange={onStepChange}
|
||||
placeholder={hasWorkflow ? "Select step" : "Pick a workflow first"}
|
||||
placeholder={
|
||||
hasWorkflow
|
||||
? t("automations:workflowStepPlaceholder")
|
||||
: t("automations:workflowStepPlaceholderNoWorkflow")
|
||||
}
|
||||
items={steps.map((s) => ({ id: s.id, label: s.name }))}
|
||||
disabled={!hasWorkflow}
|
||||
helpText={getWorkflowStepHelpText(workflowId, workflowStepId)}
|
||||
helpText={getWorkflowStepHelpText(workflowId, workflowStepId, t)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { ScriptEditor } from "@/components/settings/profile-edit/script-editor";
|
||||
import type { PlaceholderInfo } from "@/lib/types/automation";
|
||||
@@ -14,11 +15,14 @@ type PromptSectionProps = {
|
||||
};
|
||||
|
||||
export function PromptSection({ value, isDirty, onChange, placeholders }: PromptSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const scriptPlaceholders = useMemo(() => toScriptPlaceholders(placeholders), [placeholders]);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">Instructions</Label>
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t("automations:instructionsLabel")}
|
||||
</Label>
|
||||
<div
|
||||
className="rounded-md border border-transparent"
|
||||
data-settings-dirty={isDirty}
|
||||
@@ -34,7 +38,7 @@ export function PromptSection({ value, isDirty, onChange, placeholders }: Prompt
|
||||
</div>
|
||||
{placeholders.length > 0 && (
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-medium">Available placeholders:</p>
|
||||
<p className="font-medium">{t("automations:availablePlaceholdersColon")}</p>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{placeholders.map((p) => (
|
||||
<span key={p.key} title={p.description}>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -7,13 +8,14 @@ export function RequiredFieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof Label>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Label className={cn("gap-1.5", className)} {...props}>
|
||||
<span>{children}</span>
|
||||
<span aria-hidden className="text-destructive">
|
||||
*
|
||||
</span>
|
||||
<span className="sr-only">required</span>
|
||||
<span className="sr-only">{t("automations:required")}</span>
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -28,24 +29,27 @@ type RunsSectionProps = {
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
// The keys are persisted RunStatus values the backend writes and the frontend
|
||||
// matches on; only the catalog key is copy, and it is resolved at render so a
|
||||
// locale switch is picked up (a module-scope t() would freeze at boot).
|
||||
const STATUS_BADGE: Record<
|
||||
RunStatus,
|
||||
{ variant: "default" | "destructive" | "secondary" | "outline"; label: string }
|
||||
{ variant: "default" | "destructive" | "secondary" | "outline"; labelKey: string }
|
||||
> = {
|
||||
triggered: { variant: "secondary", label: "Triggered" },
|
||||
task_created: { variant: "secondary", label: "Running" },
|
||||
succeeded: { variant: "default", label: "Succeeded" },
|
||||
failed: { variant: "destructive", label: "Failed" },
|
||||
skipped: { variant: "outline", label: "Skipped" },
|
||||
triggered: { variant: "secondary", labelKey: "automations:runStatusTriggered" },
|
||||
task_created: { variant: "secondary", labelKey: "automations:runStatusRunning" },
|
||||
succeeded: { variant: "default", labelKey: "automations:runStatusSucceeded" },
|
||||
failed: { variant: "destructive", labelKey: "automations:runStatusFailed" },
|
||||
skipped: { variant: "outline", labelKey: "automations:runStatusSkipped" },
|
||||
// The generating task was archived — via the UI or by the agent itself
|
||||
// (e.g. an "archive this task" instruction). Distinct from a genuine
|
||||
// user cancellation: archiving just closes the task out, it doesn't
|
||||
// mean the run's work was rejected. See internal/automation.RunStatusArchived.
|
||||
archived: { variant: "outline", label: "Archived" },
|
||||
archived: { variant: "outline", labelKey: "automations:runStatusArchived" },
|
||||
// The generating task no longer exists, or its current primary session
|
||||
// is CANCELLED — a real cancellation, distinct from archived.
|
||||
// See internal/automation.RunStatusCancelled.
|
||||
cancelled: { variant: "outline", label: "Cancelled" },
|
||||
cancelled: { variant: "outline", labelKey: "automations:runStatusCancelled" },
|
||||
};
|
||||
|
||||
type RunRowProps = {
|
||||
@@ -56,6 +60,7 @@ type RunRowProps = {
|
||||
};
|
||||
|
||||
function RunRow({ run, taskClickable, onDelete, onNavigate }: RunRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const badge = STATUS_BADGE[run.status] ?? STATUS_BADGE.triggered;
|
||||
const rowClickable = taskClickable && !!run.task_id;
|
||||
return (
|
||||
@@ -67,9 +72,11 @@ function RunRow({ run, taskClickable, onDelete, onNavigate }: RunRowProps) {
|
||||
}
|
||||
onClick={rowClickable ? () => onNavigate(run.task_id) : undefined}
|
||||
>
|
||||
{/* trigger_type is the persisted TriggerType identifier the backend
|
||||
stored on the run — an id, not copy. */}
|
||||
<TableCell className="text-sm">{run.trigger_type}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={badge.variant}>{badge.label}</Badge>
|
||||
<Badge variant={badge.variant}>{t(badge.labelKey)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm font-mono">
|
||||
{run.task_id ? run.task_id.slice(0, 8) : "-"}
|
||||
@@ -90,7 +97,7 @@ function RunRow({ run, taskClickable, onDelete, onNavigate }: RunRowProps) {
|
||||
e.preventDefault();
|
||||
onDelete(run.id);
|
||||
}}
|
||||
title="Delete run"
|
||||
title={t("automations:deleteRun")}
|
||||
data-testid="delete-run"
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5" />
|
||||
@@ -103,6 +110,7 @@ function RunRow({ run, taskClickable, onDelete, onNavigate }: RunRowProps) {
|
||||
type DeleteAllButtonProps = { disabled: boolean; onConfirm: () => void };
|
||||
|
||||
function DeleteAllButton({ disabled, onConfirm }: DeleteAllButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
@@ -111,7 +119,7 @@ function DeleteAllButton({ disabled, onConfirm }: DeleteAllButtonProps) {
|
||||
size="icon-sm"
|
||||
className="cursor-pointer text-destructive hover:text-destructive"
|
||||
disabled={disabled}
|
||||
title="Delete all runs"
|
||||
title={t("automations:deleteAllRuns")}
|
||||
data-testid="delete-all-runs"
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5" />
|
||||
@@ -119,20 +127,19 @@ function DeleteAllButton({ disabled, onConfirm }: DeleteAllButtonProps) {
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete all runs?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("automations:deleteAllRunsTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently remove all run records for this automation — including any not
|
||||
currently loaded — and their associated tasks. This cannot be undone.
|
||||
{t("automations:deleteAllRunsDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={onConfirm}
|
||||
data-testid="delete-all-runs-confirm"
|
||||
>
|
||||
Delete all
|
||||
{t("automations:deleteAll")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -141,6 +148,7 @@ function DeleteAllButton({ disabled, onConfirm }: DeleteAllButtonProps) {
|
||||
}
|
||||
|
||||
export function RunsSection({ automationId, executionMode, workspaceId }: RunsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { runs, loading, refresh, deleteRun, deleteAllRuns } = useAutomationRuns(
|
||||
automationId,
|
||||
@@ -158,7 +166,7 @@ export function RunsSection({ automationId, executionMode, workspaceId }: RunsSe
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<Label className="text-xs uppercase tracking-wider text-muted-foreground cursor-pointer">
|
||||
Recent Runs ({runs.length})
|
||||
{t("automations:recentRuns", { count: runs.length })}
|
||||
</Label>
|
||||
{expanded ? (
|
||||
<IconChevronUp className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
@@ -174,7 +182,7 @@ export function RunsSection({ automationId, executionMode, workspaceId }: RunsSe
|
||||
className="cursor-pointer"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
title="Refresh"
|
||||
title={t("automations:refresh")}
|
||||
>
|
||||
<IconRefresh className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
@@ -187,11 +195,11 @@ export function RunsSection({ automationId, executionMode, workspaceId }: RunsSe
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="hover:bg-transparent focus-within:bg-transparent">
|
||||
<TableHead>Trigger</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Task</TableHead>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Error</TableHead>
|
||||
<TableHead>{t("automations:runsColumnTrigger")}</TableHead>
|
||||
<TableHead>{t("common:status")}</TableHead>
|
||||
<TableHead>{t("automations:runsColumnTask")}</TableHead>
|
||||
<TableHead>{t("automations:runsColumnTime")}</TableHead>
|
||||
<TableHead>{t("automations:runsColumnError")}</TableHead>
|
||||
<TableHead className="w-8" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -199,7 +207,7 @@ export function RunsSection({ automationId, executionMode, workspaceId }: RunsSe
|
||||
{runs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground py-4">
|
||||
{loading ? "Loading..." : "No runs yet"}
|
||||
{loading ? t("automations:loading") : t("automations:noRunsYet")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
@@ -26,17 +27,33 @@ function isValidExpression(expr: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
const PRESETS = [
|
||||
{ label: "5 min", expression: "@every 5m" },
|
||||
{ label: "15 min", expression: "@every 15m" },
|
||||
{ label: "30 min", expression: "@every 30m" },
|
||||
{ label: "1 hour", expression: "@hourly" },
|
||||
{ label: "6 hours", expression: "@every 6h" },
|
||||
{ label: "Daily", expression: "@daily" },
|
||||
{ label: "Weekly", expression: "@weekly" },
|
||||
] as const;
|
||||
// `expression` is the cron/shorthand syntax the backend parses and persists —
|
||||
// never translated. The label is copy: the interval presets carry a `count` so
|
||||
// the plural form is chosen by i18next rather than baked into English, and the
|
||||
// two named presets are plain messages.
|
||||
const PRESETS: ReadonlyArray<{ labelKey: string; count?: number; expression: string }> = [
|
||||
{ labelKey: "automations:presetMinutes", count: 5, expression: "@every 5m" },
|
||||
{ labelKey: "automations:presetMinutes", count: 15, expression: "@every 15m" },
|
||||
{ labelKey: "automations:presetMinutes", count: 30, expression: "@every 30m" },
|
||||
{ labelKey: "automations:presetHours", count: 1, expression: "@hourly" },
|
||||
{ labelKey: "automations:presetHours", count: 6, expression: "@every 6h" },
|
||||
{ labelKey: "automations:presetDaily", expression: "@daily" },
|
||||
{ labelKey: "automations:presetWeekly", expression: "@weekly" },
|
||||
];
|
||||
|
||||
// Cron syntax shown inside the help text. These travel as interpolation values
|
||||
// so the pseudo-locale cannot accent them into an expression that no longer
|
||||
// parses — see docs/i18n.md.
|
||||
const CRON_EVERY = "@every";
|
||||
const CRON_EVERY_10M = "@every 10m";
|
||||
const CRON_EVERY_2H30M = "@every 2h30m";
|
||||
const CRON_HOURLY = "@hourly";
|
||||
const CRON_DAILY = "@daily";
|
||||
const CRON_WEEKLY = "@weekly";
|
||||
const codeClass = "bg-muted px-1 rounded";
|
||||
|
||||
export function ScheduleSelector({ config, isDirty = false, onChange }: ScheduleSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const configExpr = (config?.cron_expression as string) ?? "";
|
||||
const [customInput, setCustomInput] = useState(configExpr);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -65,7 +82,11 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule
|
||||
return;
|
||||
}
|
||||
if (!isValidExpression(trimmed)) {
|
||||
setError("Invalid expression. Use @every with a duration, a shorthand, or a 5-field cron.");
|
||||
// `@every` is the token the scheduler accepts and the user must type, so
|
||||
// it is interpolated rather than left in the catalog — otherwise the
|
||||
// pseudo-locale renders `@ēvēŕŷ` and the error tells them to type
|
||||
// something that can never parse.
|
||||
setError(t("automations:invalidExpression", { every: CRON_EVERY }));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
@@ -84,7 +105,7 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule
|
||||
className="cursor-pointer"
|
||||
onClick={() => handlePreset(preset.expression)}
|
||||
>
|
||||
{preset.label}
|
||||
{t(preset.labelKey, { count: preset.count })}
|
||||
</Button>
|
||||
))}
|
||||
<Tooltip>
|
||||
@@ -92,13 +113,14 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground ml-1" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[280px]">
|
||||
How often to check for matching events. Checked every 30 seconds by a background
|
||||
process. Schedules persist across backend restarts.
|
||||
{t("automations:scheduleTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Custom interval</Label>
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t("automations:customIntervalLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
value={customInput}
|
||||
onChange={(e) => {
|
||||
@@ -108,17 +130,31 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule
|
||||
onBlur={handleCustomBlur}
|
||||
data-testid="schedule-custom-input"
|
||||
data-settings-dirty={isDirty}
|
||||
placeholder="@every 2h30m"
|
||||
// Cron syntax the user types verbatim, not copy — same value the
|
||||
// help text below interpolates.
|
||||
placeholder={CRON_EVERY_2H30M}
|
||||
className={`font-mono text-sm max-w-xs ${error ? "border-destructive" : ""}`}
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Use <code className="bg-muted px-1 rounded">@every</code> with a duration (e.g.,{" "}
|
||||
<code className="bg-muted px-1 rounded">@every 10m</code>,{" "}
|
||||
<code className="bg-muted px-1 rounded">@every 2h30m</code>) or shorthands like{" "}
|
||||
<code className="bg-muted px-1 rounded">@hourly</code>,{" "}
|
||||
<code className="bg-muted px-1 rounded">@daily</code>,{" "}
|
||||
<code className="bg-muted px-1 rounded">@weekly</code>.
|
||||
<Trans
|
||||
i18nKey="automations:scheduleSyntaxHelp"
|
||||
values={{
|
||||
every: CRON_EVERY,
|
||||
example1: CRON_EVERY_10M,
|
||||
example2: CRON_EVERY_2H30M,
|
||||
hourly: CRON_HOURLY,
|
||||
daily: CRON_DAILY,
|
||||
weekly: CRON_WEEKLY,
|
||||
}}
|
||||
>
|
||||
<code className={codeClass} />
|
||||
<code className={codeClass} />
|
||||
<code className={codeClass} />
|
||||
<code className={codeClass} />
|
||||
<code className={codeClass} />
|
||||
<code className={codeClass} />
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Switch } from "@kandev/ui/switch";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
@@ -46,42 +47,60 @@ const TRIGGER_COLOR: Record<TriggerType, string> = {
|
||||
webhook: "text-orange-400",
|
||||
};
|
||||
|
||||
const CRON_PRESETS: Record<string, string> = {
|
||||
"@hourly": "Every hour",
|
||||
"0 * * * *": "Every hour",
|
||||
"@daily": "Every day",
|
||||
"0 0 * * *": "Every day",
|
||||
"@weekly": "Every week",
|
||||
"0 0 * * 0": "Every week",
|
||||
// Keyed by the cron expression the backend parses — syntax, never translated.
|
||||
// Only the value is copy, so these hold catalog keys resolved at render.
|
||||
const CRON_PRESET_KEYS: Record<string, string> = {
|
||||
"@hourly": "automations:cronEveryHour",
|
||||
"0 * * * *": "automations:cronEveryHour",
|
||||
"@daily": "automations:cronEveryDay",
|
||||
"0 0 * * *": "automations:cronEveryDay",
|
||||
"@weekly": "automations:cronEveryWeek",
|
||||
"0 0 * * 0": "automations:cronEveryWeek",
|
||||
};
|
||||
|
||||
const SIMPLE_SUMMARIES: Partial<Record<TriggerType, string>> = {
|
||||
github_push: "Push to branch",
|
||||
github_ci: "CI completed",
|
||||
webhook: "Webhook",
|
||||
// Keyed by the persisted TriggerType.
|
||||
const SIMPLE_SUMMARY_KEYS: Partial<Record<TriggerType, string>> = {
|
||||
github_push: "automations:summaryPushToBranch",
|
||||
github_ci: "automations:summaryCiCompleted",
|
||||
webhook: "automations:summaryWebhook",
|
||||
};
|
||||
|
||||
const TRIGGER_INFO: Record<TriggerType, string> = {
|
||||
scheduled: "Checked every 30 seconds. Fires when the cron schedule matches.",
|
||||
github_pr: "Polls GitHub API on your schedule for PRs matching your filters.",
|
||||
github_push: "Not yet implemented.",
|
||||
github_ci: "Not yet implemented.",
|
||||
webhook: "Fires immediately when a POST request hits the webhook URL.",
|
||||
const TRIGGER_INFO_KEYS: Record<TriggerType, string> = {
|
||||
scheduled: "automations:triggerInfoScheduled",
|
||||
github_pr: "automations:triggerInfoGithubPr",
|
||||
github_push: "automations:triggerInfoNotImplemented",
|
||||
github_ci: "automations:triggerInfoNotImplemented",
|
||||
webhook: "automations:triggerInfoWebhook",
|
||||
};
|
||||
|
||||
function getTriggerSummary(trigger: AutomationTrigger): string {
|
||||
const simple = SIMPLE_SUMMARIES[trigger.type];
|
||||
if (simple) return simple;
|
||||
// A plain function returning copy is invisible to `i18next/no-literal-string`,
|
||||
// which only inspects literals in JSX — `t` is threaded in from the caller so
|
||||
// the summary re-resolves on a locale switch.
|
||||
function getTriggerSummary(
|
||||
trigger: AutomationTrigger,
|
||||
t: (key: string, values?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
const simple = SIMPLE_SUMMARY_KEYS[trigger.type];
|
||||
if (simple) return t(simple);
|
||||
|
||||
const cfg = trigger.config;
|
||||
if (trigger.type === "scheduled") {
|
||||
const expr = (cfg.cron_expression as string) ?? "";
|
||||
return CRON_PRESETS[expr] ?? (expr ? `Cron: ${expr}` : "Custom schedule");
|
||||
const presetKey = CRON_PRESET_KEYS[expr];
|
||||
if (presetKey) return t(presetKey);
|
||||
// The expression itself is syntax and travels as an interpolated value.
|
||||
return expr
|
||||
? t("automations:summaryCron", { expression: expr })
|
||||
: t("automations:summaryCustomSchedule");
|
||||
}
|
||||
if (trigger.type === "github_pr") {
|
||||
// Event names are the persisted GitHub event ids, not copy.
|
||||
const events = (cfg.events as string[]) ?? [];
|
||||
return events.length > 0 ? `PR: ${events.join(", ")}` : "Pull request event";
|
||||
return events.length > 0
|
||||
? t("automations:summaryPrEvents", { events: events.join(", ") })
|
||||
: t("automations:summaryPullRequestEvent");
|
||||
}
|
||||
// Fallback for a trigger type this build does not know: the raw persisted id.
|
||||
return trigger.type;
|
||||
}
|
||||
|
||||
@@ -94,6 +113,7 @@ export function TriggerCard({
|
||||
onToggleEnabled,
|
||||
onDelete,
|
||||
}: TriggerCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const Icon = TRIGGER_ICON[trigger.type];
|
||||
const color = TRIGGER_COLOR[trigger.type];
|
||||
@@ -114,13 +134,13 @@ export function TriggerCard({
|
||||
className="flex-1 text-sm text-left cursor-pointer hover:underline"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{getTriggerSummary(trigger)}
|
||||
{getTriggerSummary(trigger, t)}
|
||||
</button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{TRIGGER_INFO[trigger.type]}</TooltipContent>
|
||||
<TooltipContent>{t(TRIGGER_INFO_KEYS[trigger.type])}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -170,6 +190,7 @@ function TriggerConfigForm({
|
||||
workspaceId: string;
|
||||
onUpdate: (config: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
switch (trigger.type) {
|
||||
case "scheduled":
|
||||
return <ScheduledConfig config={trigger.config} onUpdate={onUpdate} />;
|
||||
@@ -182,6 +203,6 @@ function TriggerConfigForm({
|
||||
case "webhook":
|
||||
return <WebhookConfig automationId={automationId} workspaceId={workspaceId} />;
|
||||
default:
|
||||
return <p className="text-sm text-muted-foreground">Unknown trigger type</p>;
|
||||
return <p className="text-sm text-muted-foreground">{t("automations:unknownTriggerType")}</p>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
@@ -10,13 +11,16 @@ type GitHubCIConfigProps = {
|
||||
onUpdate: (config: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
// `value` is the GitHub check-run conclusion persisted in the trigger config
|
||||
// and matched by the backend — protocol. Only the label is copy.
|
||||
const CI_CONCLUSIONS = [
|
||||
{ value: "success", label: "Success" },
|
||||
{ value: "failure", label: "Failure" },
|
||||
{ value: "cancelled", label: "Cancelled" },
|
||||
{ value: "success", labelKey: "automations:ciConclusionSuccess" },
|
||||
{ value: "failure", labelKey: "automations:ciConclusionFailure" },
|
||||
{ value: "cancelled", labelKey: "automations:ciConclusionCancelled" },
|
||||
] as const;
|
||||
|
||||
export function GitHubCIConfig({ config, onUpdate }: GitHubCIConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const conclusions = (config.conclusions as string[]) ?? [];
|
||||
const configCheckNames = ((config.check_names as string[]) ?? []).join(", ");
|
||||
const [checkNames, setCheckNames] = useState(configCheckNames);
|
||||
@@ -42,7 +46,7 @@ export function GitHubCIConfig({ config, onUpdate }: GitHubCIConfigProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Conclusions</Label>
|
||||
<Label className="text-xs">{t("automations:ciConclusionsLabel")}</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{CI_CONCLUSIONS.map((c) => (
|
||||
<label key={c.value} className="flex items-center gap-1.5 cursor-pointer">
|
||||
@@ -51,13 +55,15 @@ export function GitHubCIConfig({ config, onUpdate }: GitHubCIConfigProps) {
|
||||
onCheckedChange={() => toggleConclusion(c.value)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm">{c.label}</span>
|
||||
<span className="text-sm">{t(c.labelKey)}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Check names (comma-separated, optional)</Label>
|
||||
<Label className="text-xs">{t("automations:checkNamesLabel")}</Label>
|
||||
{/* The placeholder is an example list of literal CI check names — data
|
||||
the user types verbatim, not copy. */}
|
||||
<Input
|
||||
value={checkNames}
|
||||
onChange={(e) => setCheckNames(e.target.value)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Checkbox } from "@kandev/ui/checkbox";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
@@ -15,16 +16,18 @@ type GitHubPRConfigProps = {
|
||||
onUpdate: (config: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
// `value` is the GitHub PR event name persisted in the trigger config and
|
||||
// matched by the backend — protocol. Label and tooltip are copy.
|
||||
const PR_EVENTS = [
|
||||
{
|
||||
value: "opened",
|
||||
label: "Opened",
|
||||
tooltip:
|
||||
"Fires once when a new open PR is detected. Each PR number is deduped — the same PR won't trigger again.",
|
||||
labelKey: "automations:prEventOpened",
|
||||
tooltipKey: "automations:prEventOpenedTooltip",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function GitHubPRConfig({ config, onUpdate }: GitHubPRConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const events = (config.events as string[]) ?? [];
|
||||
const repos = (config.repos as RepoFilter[]) ?? [];
|
||||
const allRepos = (config.all_repos as boolean) ?? true;
|
||||
@@ -74,7 +77,8 @@ export function GitHubPRConfig({ config, onUpdate }: GitHubPRConfigProps) {
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Base branches (comma-separated)</Label>
|
||||
<Label className="text-xs">{t("automations:baseBranchesLabel")}</Label>
|
||||
{/* Example branch names — data the user types verbatim, not copy. */}
|
||||
<Input
|
||||
value={branches}
|
||||
onChange={(e) => setBranches(e.target.value)}
|
||||
@@ -83,7 +87,8 @@ export function GitHubPRConfig({ config, onUpdate }: GitHubPRConfigProps) {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Authors (comma-separated)</Label>
|
||||
<Label className="text-xs">{t("automations:authorsLabel")}</Label>
|
||||
{/* Example GitHub usernames — data, not copy. */}
|
||||
<Input
|
||||
value={authors}
|
||||
onChange={(e) => setAuthors(e.target.value)}
|
||||
@@ -99,7 +104,7 @@ export function GitHubPRConfig({ config, onUpdate }: GitHubPRConfigProps) {
|
||||
onCheckedChange={(checked) => onUpdate({ ...config, exclude_draft: checked })}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<Label className="text-xs">Exclude draft PRs</Label>
|
||||
<Label className="text-xs">{t("automations:excludeDraftPrs")}</Label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -112,9 +117,10 @@ function EventsSection({
|
||||
events: string[];
|
||||
onToggle: (event: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs">Events</Label>
|
||||
<Label className="text-xs">{t("automations:eventsLabel")}</Label>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{PR_EVENTS.map((evt) => (
|
||||
<label key={evt.value} className="flex items-center gap-1.5 cursor-pointer">
|
||||
@@ -123,12 +129,12 @@ function EventsSection({
|
||||
onCheckedChange={() => onToggle(evt.value)}
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm">{evt.label}</span>
|
||||
<span className="text-sm">{t(evt.labelKey)}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconInfoCircle className="h-3 w-3 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[220px]">{evt.tooltip}</TooltipContent>
|
||||
<TooltipContent className="max-w-[220px]">{t(evt.tooltipKey)}</TooltipContent>
|
||||
</Tooltip>
|
||||
</label>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
|
||||
@@ -10,6 +11,7 @@ type GitHubPushConfigProps = {
|
||||
};
|
||||
|
||||
export function GitHubPushConfig({ config, onUpdate }: GitHubPushConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const configBranches = ((config.branches as string[]) ?? []).join(", ");
|
||||
const [branches, setBranches] = useState(configBranches);
|
||||
useEffect(() => {
|
||||
@@ -27,16 +29,15 @@ export function GitHubPushConfig({ config, onUpdate }: GitHubPushConfigProps) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Branch patterns (comma-separated, supports globs)</Label>
|
||||
<Label className="text-xs">{t("automations:branchPatternsLabel")}</Label>
|
||||
{/* Example branch glob patterns — data the user types verbatim. */}
|
||||
<Input
|
||||
value={branches}
|
||||
onChange={(e) => setBranches(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder="main, release/*"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Triggers when code is pushed to matching branches
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("automations:pushTriggerHelp")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
@@ -10,13 +11,20 @@ type ScheduledConfigProps = {
|
||||
onUpdate: (config: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
// `expression` is the cron shorthand the backend parses and persists — syntax,
|
||||
// never translated. Only the label is copy.
|
||||
const PRESETS = [
|
||||
{ label: "Every hour", expression: "@hourly" },
|
||||
{ label: "Every day", expression: "@daily" },
|
||||
{ label: "Every week", expression: "@weekly" },
|
||||
{ labelKey: "automations:cronEveryHour", expression: "@hourly" },
|
||||
{ labelKey: "automations:cronEveryDay", expression: "@daily" },
|
||||
{ labelKey: "automations:cronEveryWeek", expression: "@weekly" },
|
||||
] as const;
|
||||
|
||||
// Shown inside the help text as an example; interpolated so the pseudo-locale
|
||||
// cannot accent it into syntax that no longer parses.
|
||||
const CRON_SHORTCUT_EXAMPLES = "@hourly, @daily, @weekly, @every 5m";
|
||||
|
||||
export function ScheduledConfig({ config, onUpdate }: ScheduledConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const configExpr = (config.cron_expression as string) ?? "";
|
||||
const [cronExpression, setCronExpression] = useState(configExpr);
|
||||
useEffect(() => {
|
||||
@@ -47,12 +55,12 @@ export function ScheduledConfig({ config, onUpdate }: ScheduledConfigProps) {
|
||||
className="cursor-pointer"
|
||||
onClick={() => handlePreset(preset.expression)}
|
||||
>
|
||||
{preset.label}
|
||||
{t(preset.labelKey)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Cron expression</Label>
|
||||
<Label className="text-xs">{t("automations:cronExpressionLabel")}</Label>
|
||||
<Input
|
||||
value={cronExpression}
|
||||
onChange={(e) => handleCustomChange(e.target.value)}
|
||||
@@ -61,7 +69,7 @@ export function ScheduledConfig({ config, onUpdate }: ScheduledConfigProps) {
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Standard 5-field cron or shortcuts (@hourly, @daily, @weekly, @every 5m)
|
||||
{t("automations:cronHelp", { shortcuts: CRON_SHORTCUT_EXAMPLES })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { toast } from "@/lib/toast/sonner";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
@@ -15,6 +17,15 @@ type WebhookConfigProps = {
|
||||
workspaceId: string;
|
||||
};
|
||||
|
||||
// Wire syntax the user copies verbatim: the header the backend checks, and the
|
||||
// placeholder paths the prompt renderer resolves. These are interpolated into
|
||||
// the help text rather than written into the catalog, so the pseudo-locale
|
||||
// cannot accent them into a header or path that no longer resolves.
|
||||
const WEBHOOK_SECRET_HEADER = "X-Webhook-Secret";
|
||||
const WEBHOOK_PATH_PLACEHOLDER = "{{webhook.<path>}}";
|
||||
const WEBHOOK_PATH_EXAMPLE = "{{webhook.pull_request.number}}";
|
||||
const codeClass = "bg-muted px-1 rounded";
|
||||
|
||||
// extractPaths walks a parsed JSON object two levels deep and returns the
|
||||
// dot-paths to leaf scalars (string/number/bool). For nested objects it
|
||||
// returns the children's full paths so the badges suggest
|
||||
@@ -59,6 +70,7 @@ function extractKeys(json: string): string[] {
|
||||
}
|
||||
|
||||
export function WebhookConfig({ automationId, workspaceId }: WebhookConfigProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState<"url" | "secret" | null>(null);
|
||||
const [samplePayload, setSamplePayload] = useState("");
|
||||
|
||||
@@ -74,9 +86,7 @@ export function WebhookConfig({ automationId, workspaceId }: WebhookConfigProps)
|
||||
if (!automationId) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Webhook URL will be available after saving the automation.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("automations:webhookUrlAfterSave")}</p>
|
||||
<SamplePayloadSection
|
||||
samplePayload={samplePayload}
|
||||
onChange={setSamplePayload}
|
||||
@@ -105,10 +115,18 @@ export function WebhookConfig({ automationId, workspaceId }: WebhookConfigProps)
|
||||
onCopy={(value) => copyValue(value, "secret")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send a POST request with a JSON body and the secret in the{" "}
|
||||
<code className="bg-muted px-1 rounded">X-Webhook-Secret</code> header. Reference fields
|
||||
from the payload with <code className="bg-muted px-1 rounded">{`{{webhook.<path>}}`}</code>,
|
||||
e.g. <code className="bg-muted px-1 rounded">{`{{webhook.pull_request.number}}`}</code>.
|
||||
<Trans
|
||||
i18nKey="automations:webhookUsageHelp"
|
||||
values={{
|
||||
header: WEBHOOK_SECRET_HEADER,
|
||||
placeholder: WEBHOOK_PATH_PLACEHOLDER,
|
||||
example: WEBHOOK_PATH_EXAMPLE,
|
||||
}}
|
||||
>
|
||||
<code className={codeClass} />
|
||||
<code className={codeClass} />
|
||||
<code className={codeClass} />
|
||||
</Trans>
|
||||
</p>
|
||||
<SamplePayloadSection
|
||||
samplePayload={samplePayload}
|
||||
@@ -120,9 +138,10 @@ export function WebhookConfig({ automationId, workspaceId }: WebhookConfigProps)
|
||||
}
|
||||
|
||||
function UrlField({ url, copied, onCopy }: { url: string; copied: boolean; onCopy: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Webhook URL</Label>
|
||||
<Label className="text-xs">{t("automations:webhookUrlLabel")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input value={url} readOnly className="font-mono text-xs" />
|
||||
<Button variant="outline" size="sm" className="cursor-pointer shrink-0" onClick={onCopy}>
|
||||
@@ -135,13 +154,16 @@ function UrlField({ url, copied, onCopy }: { url: string; copied: boolean; onCop
|
||||
|
||||
type SecretState = { status: "loading" } | { status: "ready"; value: string } | { status: "error" };
|
||||
|
||||
// A plain function returning copy is invisible to `i18next/no-literal-string`,
|
||||
// so the loading label is resolved by the caller and passed in.
|
||||
function inputValueFor(
|
||||
status: SecretState["status"],
|
||||
secret: string | null,
|
||||
hidden: boolean,
|
||||
masked: string,
|
||||
loadingLabel: string,
|
||||
): string {
|
||||
if (status === "loading") return "Loading…";
|
||||
if (status === "loading") return loadingLabel;
|
||||
if (!secret || hidden) return masked;
|
||||
return secret;
|
||||
}
|
||||
@@ -157,6 +179,7 @@ function SecretField({
|
||||
copied: boolean;
|
||||
onCopy: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The secret is revealable any time — fetch it once on mount so users
|
||||
// landing on a saved automation can see/copy it without an extra click.
|
||||
// Combining status + value in one state avoids a setState(true) inside
|
||||
@@ -173,8 +196,9 @@ function SecretField({
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
// The interpolated value is an API/network diagnostic and stays English.
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error(`Failed to load webhook secret: ${msg}`);
|
||||
toast.error(translate("automations:failedToLoadWebhookSecret", { error: msg }));
|
||||
setState({ status: "error" });
|
||||
});
|
||||
return () => {
|
||||
@@ -184,11 +208,17 @@ function SecretField({
|
||||
|
||||
const secret = state.status === "ready" ? state.value : null;
|
||||
const masked = "•".repeat(32);
|
||||
const inputValue = inputValueFor(state.status, secret, hidden, masked);
|
||||
const inputValue = inputValueFor(
|
||||
state.status,
|
||||
secret,
|
||||
hidden,
|
||||
masked,
|
||||
t("automations:webhookSecretLoading"),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Webhook secret</Label>
|
||||
<Label className="text-xs">{t("automations:webhookSecretLabel")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={inputValue}
|
||||
@@ -229,10 +259,12 @@ function SamplePayloadSection({
|
||||
onChange: (value: string) => void;
|
||||
detectedKeys: string[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs">Sample payload (optional)</Label>
|
||||
<Label className="text-xs">{t("automations:samplePayloadLabel")}</Label>
|
||||
{/* An example JSON body — a value, not prose. */}
|
||||
<Textarea
|
||||
value={samplePayload}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
@@ -240,12 +272,10 @@ function SamplePayloadSection({
|
||||
className="font-mono text-xs min-h-[60px] resize-y"
|
||||
rows={2}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste an example JSON body to discover available placeholders.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("automations:samplePayloadHelp")}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">Available placeholders</Label>
|
||||
<Label className="text-xs">{t("automations:availablePlaceholders")}</Label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<PlaceholderBadge value="webhook.body" />
|
||||
{detectedKeys.map((key) => (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import { Command, CommandInput, CommandList, CommandGroup, CommandItem } from "@kandev/ui/command";
|
||||
@@ -14,17 +15,27 @@ type TriggerPickerProps = {
|
||||
};
|
||||
|
||||
type CategoryMeta = {
|
||||
heading: string;
|
||||
headingKey: string;
|
||||
icon: typeof IconBrandGithub;
|
||||
color: string;
|
||||
};
|
||||
|
||||
// Keyed by the backend's trigger-type category id; only the heading is copy.
|
||||
const CATEGORY_META: Record<string, CategoryMeta> = {
|
||||
github: { heading: "GitHub", icon: IconBrandGithub, color: "text-purple-400" },
|
||||
webhook: { heading: "Webhook", icon: IconWebhook, color: "text-blue-400" },
|
||||
github: {
|
||||
headingKey: "automations:triggerCategoryGithub",
|
||||
icon: IconBrandGithub,
|
||||
color: "text-purple-400",
|
||||
},
|
||||
webhook: {
|
||||
headingKey: "automations:triggerCategoryWebhook",
|
||||
icon: IconWebhook,
|
||||
color: "text-blue-400",
|
||||
},
|
||||
};
|
||||
|
||||
export function TriggerPicker({ triggerTypes, onSelect }: TriggerPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Only show condition types (not schedule — that's handled separately).
|
||||
@@ -59,12 +70,12 @@ export function TriggerPicker({ triggerTypes, onSelect }: TriggerPickerProps) {
|
||||
className="cursor-pointer text-muted-foreground"
|
||||
>
|
||||
<IconPlus className="h-4 w-4 mr-1" />
|
||||
Add Condition
|
||||
{t("automations:addCondition")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search conditions..." />
|
||||
<CommandInput placeholder={t("automations:searchConditions")} />
|
||||
<CommandList>
|
||||
{groups.map(([category, items]) => {
|
||||
const meta = CATEGORY_META[category];
|
||||
@@ -72,7 +83,7 @@ export function TriggerPicker({ triggerTypes, onSelect }: TriggerPickerProps) {
|
||||
return (
|
||||
<PickerGroup
|
||||
key={category}
|
||||
heading={meta.heading}
|
||||
heading={t(meta.headingKey)}
|
||||
icon={meta.icon}
|
||||
color={meta.color}
|
||||
items={items}
|
||||
@@ -100,6 +111,7 @@ function PickerGroup({
|
||||
items: TriggerTypeInfo[];
|
||||
onSelect: (info: TriggerTypeInfo) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<CommandGroup heading={heading}>
|
||||
{items.map((item) => (
|
||||
@@ -110,9 +122,11 @@ function PickerGroup({
|
||||
className={!item.enabled ? "opacity-50" : "cursor-pointer"}
|
||||
>
|
||||
<Icon className={`h-4 w-4 mr-2 ${color}`} />
|
||||
{/* item.label and item.description are authored by the backend's
|
||||
trigger-type registry, not the frontend. */}
|
||||
<span className="flex-1">
|
||||
{item.label}
|
||||
{!item.enabled && " (Coming soon)"}
|
||||
{!item.enabled && t("automations:comingSoonSuffix")}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import type { AutomationTrigger, TriggerType, TriggerTypeInfo } from "@/lib/types/automation";
|
||||
@@ -31,6 +32,7 @@ export function TriggersSection({
|
||||
onToggleTrigger,
|
||||
onDeleteTrigger,
|
||||
}: TriggersSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const webhookTrigger = useMemo(() => triggers.find((t) => t.type === "webhook"), [triggers]);
|
||||
const savedWebhookTrigger = useMemo(
|
||||
() => savedTriggers.find((t) => t.type === "webhook"),
|
||||
@@ -85,7 +87,10 @@ export function TriggersSection({
|
||||
onToggleEnabled={(enabled) => onToggleTrigger(webhookTrigger.id, enabled)}
|
||||
onDelete={handleRemoveWebhook}
|
||||
/>
|
||||
<SwitchModeButton label="Switch to scheduled" onClick={handleRemoveWebhook} />
|
||||
<SwitchModeButton
|
||||
label={t("automations:switchToScheduled")}
|
||||
onClick={handleRemoveWebhook}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -108,7 +113,7 @@ export function TriggersSection({
|
||||
onToggleTrigger={onToggleTrigger}
|
||||
onDeleteTrigger={onDeleteTrigger}
|
||||
/>
|
||||
<SwitchModeButton label="Or use a webhook instead" onClick={handleAddWebhook} />
|
||||
<SwitchModeButton label={t("automations:orUseWebhookInstead")} onClick={handleAddWebhook} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -122,9 +127,10 @@ function ScheduleArea({
|
||||
savedScheduleTrigger: AutomationTrigger | undefined;
|
||||
onScheduleChange: (config: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Schedule</Label>
|
||||
<Label className="text-xs font-medium">{t("automations:scheduleLabel")}</Label>
|
||||
<ScheduleSelector
|
||||
config={scheduleTrigger?.config ?? null}
|
||||
isDirty={
|
||||
@@ -158,9 +164,10 @@ function ConditionArea({
|
||||
onToggleTrigger: (triggerId: string, enabled: boolean) => void;
|
||||
onDeleteTrigger: (triggerId: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium">Watch for</Label>
|
||||
<Label className="text-xs font-medium">{t("automations:watchForLabel")}</Label>
|
||||
{trigger ? (
|
||||
<div className="space-y-2">
|
||||
<TriggerCard
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { IconCheck, IconCopy } from "@tabler/icons-react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -14,6 +15,11 @@ import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard";
|
||||
|
||||
// The HTTP header name the backend checks — protocol, not copy, so it travels
|
||||
// as an interpolation value rather than sitting in the catalog where the
|
||||
// pseudo-locale would accent it.
|
||||
const WEBHOOK_SECRET_HEADER = "X-Webhook-Secret";
|
||||
|
||||
type WebhookCreatedDialogProps = {
|
||||
open: boolean;
|
||||
webhookUrl: string;
|
||||
@@ -27,26 +33,30 @@ export function WebhookCreatedDialog({
|
||||
webhookSecret,
|
||||
onClose,
|
||||
}: WebhookCreatedDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<DialogContent className="sm:max-w-xl" data-testid="webhook-created-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Automation created</DialogTitle>
|
||||
<DialogTitle>{t("automations:automationCreated")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure your external system to POST to this URL with the secret in the{" "}
|
||||
<code className="bg-muted px-1 rounded">X-Webhook-Secret</code> header. You can come
|
||||
back to this automation any time to copy these values again.
|
||||
<Trans
|
||||
i18nKey="automations:webhookCreatedDescription"
|
||||
values={{ header: WEBHOOK_SECRET_HEADER }}
|
||||
>
|
||||
<code className="bg-muted px-1 rounded" />
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<CopyableField label="Webhook URL" value={webhookUrl} />
|
||||
<CopyableField label="Webhook secret" value={webhookSecret} mono />
|
||||
<CopyableField label={t("automations:webhookUrlLabel")} value={webhookUrl} />
|
||||
<CopyableField label={t("automations:webhookSecretLabel")} value={webhookSecret} mono />
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
className="cursor-pointer"
|
||||
data-testid="webhook-created-dialog-close"
|
||||
>
|
||||
Done
|
||||
{t("automations:done")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
const MULTI_REPO_SUPPORTED_EXECUTOR_TYPES = new Set(["worktree", "local_docker", "ssh", "sprites"]);
|
||||
|
||||
/**
|
||||
* Returns the selector explanation for runtimes that cannot launch a task
|
||||
* with sibling repositories. This is deliberately a pure capability check:
|
||||
* changing repository rows must never replace a user's supported executor.
|
||||
*
|
||||
* The three messages are copy: they render as the `title` on a disabled option
|
||||
* in the automation editor's Executor Profile picker and in the task-create
|
||||
* dialog. This module is shared by both surfaces and belongs to neither, so the
|
||||
* Automations migration translated the strings its own picker renders without
|
||||
* adding the file to `i18nGuardFiles` — allowlisting it would claim a
|
||||
* completeness the task-create dialog has not had. Resolved at call time rather
|
||||
* than module scope, so a locale switch is picked up.
|
||||
*/
|
||||
export function getMultiRepoExecutorDisabledReason(executorType: string | null | undefined) {
|
||||
if (MULTI_REPO_SUPPORTED_EXECUTOR_TYPES.has(executorType ?? "")) return null;
|
||||
if (executorType === "local" || executorType === "local_pc") {
|
||||
return "Multi-repo tasks are unavailable on Local until its initial launch path can project sibling repositories.";
|
||||
return t("common:multiRepoUnavailableLocal");
|
||||
}
|
||||
if (executorType === "remote_docker") {
|
||||
return "Multi-repo tasks are unavailable on Remote Docker until it supports creating task instances.";
|
||||
return t("common:multiRepoUnavailableRemoteDocker");
|
||||
}
|
||||
return "Multi-repo tasks are not supported by this executor.";
|
||||
return t("common:multiRepoUnsupportedExecutor");
|
||||
}
|
||||
|
||||
@@ -694,7 +694,13 @@ export const i18nGuardFiles = [
|
||||
// union, and the `configure_session` rule `operation`s. Workflow and step
|
||||
// NAMES are user data throughout and are always interpolated as values,
|
||||
// never built into a message by concatenation.
|
||||
"app/settings/workspace/[id]/workflows/**/*.{ts,tsx}",
|
||||
// `[[]id[]]`, not `[id]`: glob brackets are a character class, so the
|
||||
// unescaped form matches nothing and the entry silently guards no files.
|
||||
// This one and the two under Workspaces were escaped by #2247, which hit the
|
||||
// same trap on its own dynamic route. `pnpm lint` is unchanged by the repair,
|
||||
// so these directories were always clean — just unguarded. See docs/i18n.md
|
||||
// ("An entry can be born dead").
|
||||
"app/settings/workspace/[[]id[]]/workflows/**/*.{ts,tsx}",
|
||||
"app/settings/workspace/use-workflow-creation.ts",
|
||||
"app/settings/workspace/workspace-workflows-client.tsx",
|
||||
"app/settings/workspace/workspace-workflows-dialogs.tsx",
|
||||
@@ -933,8 +939,8 @@ export const i18nGuardFiles = [
|
||||
// workspace's own name, so there is no translatable type-to-confirm token
|
||||
// here to break; the name travels into the sentence as an interpolated value.
|
||||
"app/settings/workspace/page.tsx",
|
||||
"app/settings/workspace/[id]/page.tsx",
|
||||
"app/settings/workspace/[id]/repositories/page.tsx",
|
||||
"app/settings/workspace/[[]id[]]/page.tsx",
|
||||
"app/settings/workspace/[[]id[]]/repositories/page.tsx",
|
||||
"app/settings/workspace/workspace-edit-client.tsx",
|
||||
"app/settings/workspace/workspace-not-found-card.tsx",
|
||||
"app/settings/workspace/workspace-repositories-client.tsx",
|
||||
@@ -1128,4 +1134,69 @@ export const i18nGuardFiles = [
|
||||
// chrome — and `configuration-chat-toggle.tsx`, which `ConfigChatSetup`
|
||||
// renders — is still English and belongs to the quick-chat migration.
|
||||
"components/config-chat/*.{ts,tsx}",
|
||||
|
||||
// Automations — `components/automations/**` (incl. `trigger-configs/`) and
|
||||
// the three `app/settings/**/automations/**` routes. Copy lives in a new
|
||||
// `automations` namespace, except three strings reused verbatim from
|
||||
// `common`: `common:automations` (the word SEGMENT_LABEL_KEYS already names
|
||||
// as this settings route's owner), `common:cancel`, `common:status` and
|
||||
// `common:requestFailed` — each diffed byte-for-byte against what the live
|
||||
// surface rendered before reusing it. Inside the namespace the reverse call
|
||||
// was made for "Webhook": the badge, the card summary and the picker's group
|
||||
// heading get one key each rather than sharing one, because they are three
|
||||
// different grammatical contexts and a language that inflects them
|
||||
// differently would otherwise have nowhere to say so.
|
||||
//
|
||||
// Most of this directory's copy was invisible to the guard. `pnpm run
|
||||
// lint:i18n` reported 116; the migration converted 156. The extra 40 were in
|
||||
// shapes the rule does not inspect: SCREAMING_CASE config tables
|
||||
// (`TRIGGER_LABELS`, `STATUS_BADGE`, `EXECUTION_MODE_ITEMS`, `PRESETS` in two
|
||||
// files, `CRON_PRESETS`/`SIMPLE_SUMMARIES`/`TRIGGER_INFO`, `CI_CONCLUSIONS`,
|
||||
// `PR_EVENTS`, `CATEGORY_META`), plain functions returning copy
|
||||
// (`getTriggerSummary`, `getWorkflowStepHelpText`, `inputValueFor`), toasts
|
||||
// and a save-contributor `invalidReason` in `automation-editor.tsx`, an
|
||||
// `sr-only` "required" the guard's single-lowercase-token pattern skipped,
|
||||
// and `automation-repository-selection.ts` — a `.ts` module with no JSX that
|
||||
// reported 0 and held a picker option label.
|
||||
//
|
||||
// Persisted/protocol strings deliberately left in English, because a
|
||||
// translated one breaks a stored automation with nothing failing until a
|
||||
// locale switch: `DEFAULT_PROMPT` in `automation-editor.tsx` (persisted, sent
|
||||
// to the agent verbatim, and compared with `===`), the `"New Automation"` and
|
||||
// `"New Repository"` name fallbacks (both written to a record), every cron
|
||||
// expression and shorthand, every TriggerType / RunStatus / ExecutionMode /
|
||||
// PR-event / CI-conclusion id, `X-Webhook-Secret`, the `{{webhook.*}}`
|
||||
// placeholder paths, and the example values in `placeholder` attributes
|
||||
// (branch names, CI check names, GitHub usernames, a sample JSON body).
|
||||
// `run.trigger_type` renders raw in the runs table for the same reason.
|
||||
// Where syntax appears inside a sentence it travels as an interpolation
|
||||
// value, so the pseudo-locale cannot accent it into something that no longer
|
||||
// parses.
|
||||
//
|
||||
// NOT listed, and deliberately so:
|
||||
// - `components/task-create-dialog-multi-repo-guard.ts`. Its three disabled
|
||||
// -executor explanations render as a `title` in this editor's Executor
|
||||
// Profile picker, so they are migrated here (into `common:multiRepo*`),
|
||||
// but the module is shared with the un-migrated task-create dialog and
|
||||
// belongs to neither. Allowlisting it would claim a completeness that
|
||||
// dialog has not had; whichever migration takes the dialog adds it.
|
||||
// - `formatRelativeTime` in `lib/utils.ts`, which the runs table and the
|
||||
// automations table both render. It is a repo-wide formatter with 21
|
||||
// consumers and `lib/i18n/formats.ts` already holds its intended
|
||||
// replacement (`formatRelative`); swapping it is a cross-cutting change,
|
||||
// not part of a copy migration.
|
||||
//
|
||||
// NOTE the `[[]id[]]` escaping on the dynamic route. Written as `[id]`, the
|
||||
// brackets are a glob CHARACTER CLASS matching a single `i` or `d`, so the
|
||||
// pattern matches nothing and the route is silently unguarded — verified by
|
||||
// putting a hardcoded literal in `automations/new/page.tsx` and watching
|
||||
// `pnpm lint` report 0 errors. `check-guard-allowlist.mjs` cannot catch this:
|
||||
// it only inspects entries that LEFT the array, so an entry that never
|
||||
// matched anything looks identical to a healthy one. `fs.globSync` on the
|
||||
// escaped form is what proves an entry is live.
|
||||
"components/automations/*.{ts,tsx}",
|
||||
"components/automations/trigger-configs/*.tsx",
|
||||
"app/settings/automations/page.tsx",
|
||||
"app/settings/workspace/[[]id[]]/automations/**/*.tsx",
|
||||
"hooks/domains/settings/use-automation-runs.ts",
|
||||
];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { t } from "@/lib/i18n";
|
||||
import { toast } from "@/lib/toast/sonner";
|
||||
import {
|
||||
listAutomationRuns,
|
||||
@@ -75,7 +76,9 @@ export function useAutomationRuns(automationId: string | null, workspaceId: stri
|
||||
removeRun(automationId, runId);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : "Failed to delete run";
|
||||
// An Error message here is an API diagnostic and stays English; the
|
||||
// fallback for a non-Error rejection is copy.
|
||||
const msg = err instanceof Error ? err.message : t("automations:failedToDeleteRun");
|
||||
toast.error(msg);
|
||||
// revert on failure
|
||||
listAutomationRuns(automationId)
|
||||
@@ -88,7 +91,7 @@ export function useAutomationRuns(automationId: string | null, workspaceId: stri
|
||||
if (deletedRun) {
|
||||
restoreRun(automationId, deletedRun);
|
||||
}
|
||||
toast.error("Could not refresh runs — restored from local cache");
|
||||
toast.error(t("automations:couldNotRefreshRuns"));
|
||||
});
|
||||
});
|
||||
},
|
||||
@@ -108,7 +111,7 @@ export function useAutomationRuns(automationId: string | null, workspaceId: stri
|
||||
clearRuns(automationId);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : "Failed to delete runs";
|
||||
const msg = err instanceof Error ? err.message : t("automations:failedToDeleteRuns");
|
||||
toast.error(msg);
|
||||
// revert on failure
|
||||
listAutomationRuns(automationId)
|
||||
@@ -118,7 +121,7 @@ export function useAutomationRuns(automationId: string | null, workspaceId: stri
|
||||
// would stay permanently empty even though delete-all never
|
||||
// succeeded server-side. Fall back to the pre-clear snapshot.
|
||||
setRuns(automationId, previousRuns);
|
||||
toast.error("Could not refresh runs — restored from local cache");
|
||||
toast.error(t("automations:couldNotRefreshRuns"));
|
||||
});
|
||||
});
|
||||
}, [automationId, clearRuns, setRuns, storeApi, workspaceId]);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"addCondition": "Add Condition",
|
||||
"addRepository": "Add repository",
|
||||
"agentProfileLabel": "Agent Profile",
|
||||
"agentProfilePlaceholder": "Select agent",
|
||||
"alreadyAdded": "Already added",
|
||||
"authorsLabel": "Authors (comma-separated)",
|
||||
"automationCreated": "Automation created",
|
||||
"availablePlaceholders": "Available placeholders",
|
||||
"availablePlaceholdersColon": "Available placeholders:",
|
||||
"baseBranchesLabel": "Base branches (comma-separated)",
|
||||
"branchPatternsLabel": "Branch patterns (comma-separated, supports globs)",
|
||||
"checkNamesLabel": "Check names (comma-separated, optional)",
|
||||
"ciConclusionCancelled": "Cancelled",
|
||||
"ciConclusionFailure": "Failure",
|
||||
"ciConclusionSuccess": "Success",
|
||||
"ciConclusionsLabel": "Conclusions",
|
||||
"columnEnabled": "Enabled",
|
||||
"columnLastTriggered": "Last Triggered",
|
||||
"columnMode": "Mode",
|
||||
"columnName": "Name",
|
||||
"columnTriggers": "Triggers",
|
||||
"comingSoonSuffix": " (Coming soon)",
|
||||
"completeRequiredFields": "Complete the required automation fields before saving.",
|
||||
"configurationLabel": "Configuration",
|
||||
"couldNotRefreshRuns": "Could not refresh runs — restored from local cache",
|
||||
"createWorkspace": "Create workspace",
|
||||
"cronEveryDay": "Every day",
|
||||
"cronEveryHour": "Every hour",
|
||||
"cronEveryWeek": "Every week",
|
||||
"cronExpressionLabel": "Cron expression",
|
||||
"cronHelp": "Standard 5-field cron or shortcuts ({{shortcuts}})",
|
||||
"customIntervalLabel": "Custom interval",
|
||||
"delete": "Delete",
|
||||
"deleteAll": "Delete all",
|
||||
"deleteAllRuns": "Delete all runs",
|
||||
"deleteAllRunsDescription": "This will permanently remove all run records for this automation — including any not currently loaded — and their associated tasks. This cannot be undone.",
|
||||
"deleteAllRunsTitle": "Delete all runs?",
|
||||
"deleteRun": "Delete run",
|
||||
"done": "Done",
|
||||
"enabledLabel": "Enabled",
|
||||
"eventsLabel": "Events",
|
||||
"excludeDraftPrs": "Exclude draft PRs",
|
||||
"executionModeLabel": "Execution Mode",
|
||||
"executionModePlaceholder": "Select mode",
|
||||
"executionModeRun": "Run — fire-and-forget, hidden from kanban",
|
||||
"executionModeRunShort": "Run",
|
||||
"executionModeTask": "Task — creates a tracked kanban task",
|
||||
"executionModeTaskShort": "Task",
|
||||
"executorProfileLabel": "Executor Profile",
|
||||
"executorProfilePlaceholder": "Select executor",
|
||||
"failedToDeleteAutomation": "Failed to delete automation",
|
||||
"failedToDeleteRun": "Failed to delete run",
|
||||
"failedToDeleteRuns": "Failed to delete runs",
|
||||
"failedToLoadWebhookSecret": "Failed to load webhook secret: {{error}}",
|
||||
"failedToSaveAutomation": "Failed to save automation: {{error}}",
|
||||
"instructionsLabel": "Instructions",
|
||||
"invalidExpression": "Invalid expression. Use {{every}} with a duration, a shorthand, or a 5-field cron.",
|
||||
"listDescription": "Create rules that automatically trigger agent tasks.",
|
||||
"loading": "Loading...",
|
||||
"loadingAutomations": "Loading automations...",
|
||||
"maxConcurrentRuns": "Max concurrent runs",
|
||||
"nameHelp": "Enter an automation name to enable saving.",
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "Automation name",
|
||||
"neverTriggered": "Never",
|
||||
"newAutomation": "New Automation",
|
||||
"noAutomationsYet": "No automations yet",
|
||||
"noRepositoriesHelp": "With no repositories selected, this automation runs against the workspace's first repository.",
|
||||
"noRunsYet": "No runs yet",
|
||||
"noTriggers": "None",
|
||||
"noWorkspacesDescription": "Create a workspace first, then schedule automations inside it.",
|
||||
"noWorkspacesYet": "No workspaces yet",
|
||||
"orUseWebhookInstead": "Or use a webhook instead",
|
||||
"pickWorkspace": "Pick a workspace to manage its automations.",
|
||||
"prEventOpened": "Opened",
|
||||
"prEventOpenedTooltip": "Fires once when a new open PR is detected. Each PR number is deduped — the same PR won't trigger again.",
|
||||
"prTriggerRepositoryHelp": "PR triggers always use the PR's own repository.",
|
||||
"presetDaily": "Daily",
|
||||
"presetHours_one": "{{count}} hour",
|
||||
"presetHours_other": "{{count}} hours",
|
||||
"presetMinutes_one": "{{count}} min",
|
||||
"presetMinutes_other": "{{count}} min",
|
||||
"presetWeekly": "Weekly",
|
||||
"pushTriggerHelp": "Triggers when code is pushed to matching branches",
|
||||
"recentRuns_one": "Recent Runs ({{count}})",
|
||||
"recentRuns_other": "Recent Runs ({{count}})",
|
||||
"refresh": "Refresh",
|
||||
"removeRepository": "Remove repository",
|
||||
"repositoryLabel": "Repository",
|
||||
"repositoryNone": "None — no repository",
|
||||
"repositoryPlaceholderAuto": "Auto",
|
||||
"required": "required",
|
||||
"runStatusArchived": "Archived",
|
||||
"runStatusCancelled": "Cancelled",
|
||||
"runStatusFailed": "Failed",
|
||||
"runStatusRunning": "Running",
|
||||
"runStatusSkipped": "Skipped",
|
||||
"runStatusSucceeded": "Succeeded",
|
||||
"runStatusTriggered": "Triggered",
|
||||
"runsColumnError": "Error",
|
||||
"runsColumnTask": "Task",
|
||||
"runsColumnTime": "Time",
|
||||
"runsColumnTrigger": "Trigger",
|
||||
"samplePayloadHelp": "Paste an example JSON body to discover available placeholders.",
|
||||
"samplePayloadLabel": "Sample payload (optional)",
|
||||
"scheduleLabel": "Schedule",
|
||||
"scheduleSyntaxHelp": "Use <0>{{every}}</0> with a duration (e.g., <1>{{example1}}</1>, <2>{{example2}}</2>) or shorthands like <3>{{hourly}}</3>, <4>{{daily}}</4>, <5>{{weekly}}</5>.",
|
||||
"scheduleTooltip": "How often to check for matching events. Checked every 30 seconds by a background process. Schedules persist across backend restarts.",
|
||||
"searchConditions": "Search conditions...",
|
||||
"selectRepositoryPlaceholder": "Select a repository",
|
||||
"summaryCiCompleted": "CI completed",
|
||||
"summaryCron": "Cron: {{expression}}",
|
||||
"summaryCustomSchedule": "Custom schedule",
|
||||
"summaryPrEvents": "PR: {{events}}",
|
||||
"summaryPullRequestEvent": "Pull request event",
|
||||
"summaryPushToBranch": "Push to branch",
|
||||
"summaryWebhook": "Webhook",
|
||||
"switchToScheduled": "Switch to scheduled",
|
||||
"taskTitleHelp": "Leave empty to use the default. Supports placeholders.",
|
||||
"taskTitleLabel": "Task title",
|
||||
"taskTitlePlaceholder": "[Auto] automation name",
|
||||
"thenDescription": "A new task will be created each time this automation triggers",
|
||||
"thenTitle": "Then",
|
||||
"triggerCategoryGithub": "GitHub",
|
||||
"triggerCategoryWebhook": "Webhook",
|
||||
"triggerInfoGithubPr": "Polls GitHub API on your schedule for PRs matching your filters.",
|
||||
"triggerInfoNotImplemented": "Not yet implemented.",
|
||||
"triggerInfoScheduled": "Checked every 30 seconds. Fires when the cron schedule matches.",
|
||||
"triggerInfoWebhook": "Fires immediately when a POST request hits the webhook URL.",
|
||||
"triggerLabelGithubCi": "GitHub CI",
|
||||
"triggerLabelGithubPr": "GitHub PR",
|
||||
"triggerLabelGithubPush": "GitHub Push",
|
||||
"triggerLabelScheduled": "Scheduled",
|
||||
"triggerLabelWebhook": "Webhook",
|
||||
"triggerManually": "Trigger manually",
|
||||
"unknownTriggerType": "Unknown trigger type",
|
||||
"watchForLabel": "Watch for",
|
||||
"webhookCreatedDescription": "Configure your external system to POST to this URL with the secret in the <0>{{header}}</0> header. You can come back to this automation any time to copy these values again.",
|
||||
"webhookSecretLabel": "Webhook secret",
|
||||
"webhookSecretLoading": "Loading…",
|
||||
"webhookUrlAfterSave": "Webhook URL will be available after saving the automation.",
|
||||
"webhookUrlLabel": "Webhook URL",
|
||||
"webhookUsageHelp": "Send a POST request with a JSON body and the secret in the <0>{{header}}</0> header. Reference fields from the payload with <1>{{placeholder}}</1>, e.g. <2>{{example}}</2>.",
|
||||
"whenDescription": "What causes this automation to run",
|
||||
"whenTitle": "When",
|
||||
"workflowHelp": "Select a workflow to enable saving.",
|
||||
"workflowLabel": "Workflow",
|
||||
"workflowPlaceholder": "Select workflow",
|
||||
"workflowStepHelp": "Select a workflow step to enable saving.",
|
||||
"workflowStepHelpNoWorkflow": "Select a workflow before choosing a step.",
|
||||
"workflowStepLabel": "Workflow Step",
|
||||
"workflowStepPlaceholder": "Select step",
|
||||
"workflowStepPlaceholderNoWorkflow": "Pick a workflow first"
|
||||
}
|
||||
@@ -46,6 +46,9 @@
|
||||
"integrations": "Integrations",
|
||||
"justNow": "just now",
|
||||
"mAgo": "{{diffMin}}m ago",
|
||||
"multiRepoUnavailableLocal": "Multi-repo tasks are unavailable on Local until its initial launch path can project sibling repositories.",
|
||||
"multiRepoUnavailableRemoteDocker": "Multi-repo tasks are unavailable on Remote Docker until it supports creating task instances.",
|
||||
"multiRepoUnsupportedExecutor": "Multi-repo tasks are not supported by this executor.",
|
||||
"plugins": "Plugins",
|
||||
"prDetails": "PR Details",
|
||||
"presetIconBug": "Bug",
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"addCondition": "Àďď Ćōńďĩţĩōń",
|
||||
"addRepository": "Àďď ŕēƥōśĩţōŕŷ",
|
||||
"agentProfileLabel": "Àĝēńţ Ƥŕōƒĩĺē",
|
||||
"agentProfilePlaceholder": "Śēĺēćţ àĝēńţ",
|
||||
"alreadyAdded": "Àĺŕēàďŷ àďďēď",
|
||||
"authorsLabel": "Àũţĥōŕś (ćōḿḿà-śēƥàŕàţēď)",
|
||||
"automationCreated": "Àũţōḿàţĩōń ćŕēàţēď",
|
||||
"availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś",
|
||||
"availablePlaceholdersColon": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
|
||||
"baseBranchesLabel": "Ɓàśē ƀŕàńćĥēś (ćōḿḿà-śēƥàŕàţēď)",
|
||||
"branchPatternsLabel": "Ɓŕàńćĥ ƥàţţēŕńś (ćōḿḿà-śēƥàŕàţēď, śũƥƥōŕţś ĝĺōƀś)",
|
||||
"checkNamesLabel": "Ćĥēćķ ńàḿēś (ćōḿḿà-śēƥàŕàţēď, ōƥţĩōńàĺ)",
|
||||
"ciConclusionCancelled": "Ćàńćēĺĺēď",
|
||||
"ciConclusionFailure": "Ƒàĩĺũŕē",
|
||||
"ciConclusionSuccess": "Śũććēśś",
|
||||
"ciConclusionsLabel": "Ćōńćĺũśĩōńś",
|
||||
"columnEnabled": "Ēńàƀĺēď",
|
||||
"columnLastTriggered": "Ĺàśţ Ţŕĩĝĝēŕēď",
|
||||
"columnMode": "Ḿōďē",
|
||||
"columnName": "Ńàḿē",
|
||||
"columnTriggers": "Ţŕĩĝĝēŕś",
|
||||
"comingSoonSuffix": " (Ćōḿĩńĝ śōōń)",
|
||||
"completeRequiredFields": "Ćōḿƥĺēţē ţĥē ŕēqũĩŕēď àũţōḿàţĩōń ƒĩēĺďś ƀēƒōŕē śàvĩńĝ.",
|
||||
"configurationLabel": "Ćōńƒĩĝũŕàţĩōń",
|
||||
"couldNotRefreshRuns": "Ćōũĺď ńōţ ŕēƒŕēśĥ ŕũńś — ŕēśţōŕēď ƒŕōḿ ĺōćàĺ ćàćĥē",
|
||||
"createWorkspace": "Ćŕēàţē ŵōŕķśƥàćē",
|
||||
"cronEveryDay": "Ēvēŕŷ ďàŷ",
|
||||
"cronEveryHour": "Ēvēŕŷ ĥōũŕ",
|
||||
"cronEveryWeek": "Ēvēŕŷ ŵēēķ",
|
||||
"cronExpressionLabel": "Ćŕōń ēxƥŕēśśĩōń",
|
||||
"cronHelp": "Śţàńďàŕď 5-ƒĩēĺď ćŕōń ōŕ śĥōŕţćũţś ({{shortcuts}})",
|
||||
"customIntervalLabel": "Ćũśţōḿ ĩńţēŕvàĺ",
|
||||
"delete": "Ďēĺēţē",
|
||||
"deleteAll": "Ďēĺēţē àĺĺ",
|
||||
"deleteAllRuns": "Ďēĺēţē àĺĺ ŕũńś",
|
||||
"deleteAllRunsDescription": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē àĺĺ ŕũń ŕēćōŕďś ƒōŕ ţĥĩś àũţōḿàţĩōń — ĩńćĺũďĩńĝ àńŷ ńōţ ćũŕŕēńţĺŷ ĺōàďēď — àńď ţĥēĩŕ àśśōćĩàţēď ţàśķś. Ţĥĩś ćàńńōţ ƀē ũńďōńē.",
|
||||
"deleteAllRunsTitle": "Ďēĺēţē àĺĺ ŕũńś?",
|
||||
"deleteRun": "Ďēĺēţē ŕũń",
|
||||
"done": "Ďōńē",
|
||||
"enabledLabel": "Ēńàƀĺēď",
|
||||
"eventsLabel": "Ēvēńţś",
|
||||
"excludeDraftPrs": "Ēxćĺũďē ďŕàƒţ ƤŔś",
|
||||
"executionModeLabel": "Ēxēćũţĩōń Ḿōďē",
|
||||
"executionModePlaceholder": "Śēĺēćţ ḿōďē",
|
||||
"executionModeRun": "Ŕũń — ƒĩŕē-àńď-ƒōŕĝēţ, ĥĩďďēń ƒŕōḿ ķàńƀàń",
|
||||
"executionModeRunShort": "Ŕũń",
|
||||
"executionModeTask": "Ţàśķ — ćŕēàţēś à ţŕàćķēď ķàńƀàń ţàśķ",
|
||||
"executionModeTaskShort": "Ţàśķ",
|
||||
"executorProfileLabel": "Ēxēćũţōŕ Ƥŕōƒĩĺē",
|
||||
"executorProfilePlaceholder": "Śēĺēćţ ēxēćũţōŕ",
|
||||
"failedToDeleteAutomation": "Ƒàĩĺēď ţō ďēĺēţē àũţōḿàţĩōń",
|
||||
"failedToDeleteRun": "Ƒàĩĺēď ţō ďēĺēţē ŕũń",
|
||||
"failedToDeleteRuns": "Ƒàĩĺēď ţō ďēĺēţē ŕũńś",
|
||||
"failedToLoadWebhookSecret": "Ƒàĩĺēď ţō ĺōàď ŵēƀĥōōķ śēćŕēţ: {{error}}",
|
||||
"failedToSaveAutomation": "Ƒàĩĺēď ţō śàvē àũţōḿàţĩōń: {{error}}",
|
||||
"instructionsLabel": "Ĩńśţŕũćţĩōńś",
|
||||
"invalidExpression": "Ĩńvàĺĩď ēxƥŕēśśĩōń. Ũśē {{every}} ŵĩţĥ à ďũŕàţĩōń, à śĥōŕţĥàńď, ōŕ à 5-ƒĩēĺď ćŕōń.",
|
||||
"listDescription": "Ćŕēàţē ŕũĺēś ţĥàţ àũţōḿàţĩćàĺĺŷ ţŕĩĝĝēŕ àĝēńţ ţàśķś.",
|
||||
"loading": "Ĺōàďĩńĝ...",
|
||||
"loadingAutomations": "Ĺōàďĩńĝ àũţōḿàţĩōńś...",
|
||||
"maxConcurrentRuns": "Ḿàx ćōńćũŕŕēńţ ŕũńś",
|
||||
"nameHelp": "Ēńţēŕ àń àũţōḿàţĩōń ńàḿē ţō ēńàƀĺē śàvĩńĝ.",
|
||||
"nameLabel": "Ńàḿē",
|
||||
"namePlaceholder": "Àũţōḿàţĩōń ńàḿē",
|
||||
"neverTriggered": "Ńēvēŕ",
|
||||
"newAutomation": "Ńēŵ Àũţōḿàţĩōń",
|
||||
"noAutomationsYet": "Ńō àũţōḿàţĩōńś ŷēţ",
|
||||
"noRepositoriesHelp": "Ŵĩţĥ ńō ŕēƥōśĩţōŕĩēś śēĺēćţēď, ţĥĩś àũţōḿàţĩōń ŕũńś àĝàĩńśţ ţĥē ŵōŕķśƥàćē'ś ƒĩŕśţ ŕēƥōśĩţōŕŷ.",
|
||||
"noRunsYet": "Ńō ŕũńś ŷēţ",
|
||||
"noTriggers": "Ńōńē",
|
||||
"noWorkspacesDescription": "Ćŕēàţē à ŵōŕķśƥàćē ƒĩŕśţ, ţĥēń śćĥēďũĺē àũţōḿàţĩōńś ĩńśĩďē ĩţ.",
|
||||
"noWorkspacesYet": "Ńō ŵōŕķśƥàćēś ŷēţ",
|
||||
"orUseWebhookInstead": "Ōŕ ũśē à ŵēƀĥōōķ ĩńśţēàď",
|
||||
"pickWorkspace": "Ƥĩćķ à ŵōŕķśƥàćē ţō ḿàńàĝē ĩţś àũţōḿàţĩōńś.",
|
||||
"prEventOpened": "Ōƥēńēď",
|
||||
"prEventOpenedTooltip": "Ƒĩŕēś ōńćē ŵĥēń à ńēŵ ōƥēń ƤŔ ĩś ďēţēćţēď. Ēàćĥ ƤŔ ńũḿƀēŕ ĩś ďēďũƥēď — ţĥē śàḿē ƤŔ ŵōń'ţ ţŕĩĝĝēŕ àĝàĩń.",
|
||||
"prTriggerRepositoryHelp": "ƤŔ ţŕĩĝĝēŕś àĺŵàŷś ũśē ţĥē ƤŔ'ś ōŵń ŕēƥōśĩţōŕŷ.",
|
||||
"presetDaily": "Ďàĩĺŷ",
|
||||
"presetHours_one": "{{count}} ĥōũŕ",
|
||||
"presetHours_other": "{{count}} ĥōũŕś",
|
||||
"presetMinutes_one": "{{count}} ḿĩń",
|
||||
"presetMinutes_other": "{{count}} ḿĩń",
|
||||
"presetWeekly": "Ŵēēķĺŷ",
|
||||
"pushTriggerHelp": "Ţŕĩĝĝēŕś ŵĥēń ćōďē ĩś ƥũśĥēď ţō ḿàţćĥĩńĝ ƀŕàńćĥēś",
|
||||
"recentRuns_one": "Ŕēćēńţ Ŕũńś ({{count}})",
|
||||
"recentRuns_other": "Ŕēćēńţ Ŕũńś ({{count}})",
|
||||
"refresh": "Ŕēƒŕēśĥ",
|
||||
"removeRepository": "Ŕēḿōvē ŕēƥōśĩţōŕŷ",
|
||||
"repositoryLabel": "Ŕēƥōśĩţōŕŷ",
|
||||
"repositoryNone": "Ńōńē — ńō ŕēƥōśĩţōŕŷ",
|
||||
"repositoryPlaceholderAuto": "Àũţō",
|
||||
"required": "ŕēqũĩŕēď",
|
||||
"runStatusArchived": "Àŕćĥĩvēď",
|
||||
"runStatusCancelled": "Ćàńćēĺĺēď",
|
||||
"runStatusFailed": "Ƒàĩĺēď",
|
||||
"runStatusRunning": "Ŕũńńĩńĝ",
|
||||
"runStatusSkipped": "Śķĩƥƥēď",
|
||||
"runStatusSucceeded": "Śũććēēďēď",
|
||||
"runStatusTriggered": "Ţŕĩĝĝēŕēď",
|
||||
"runsColumnError": "Ēŕŕōŕ",
|
||||
"runsColumnTask": "Ţàśķ",
|
||||
"runsColumnTime": "Ţĩḿē",
|
||||
"runsColumnTrigger": "Ţŕĩĝĝēŕ",
|
||||
"samplePayloadHelp": "Ƥàśţē àń ēxàḿƥĺē ĴŚŌŃ ƀōďŷ ţō ďĩśćōvēŕ àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś.",
|
||||
"samplePayloadLabel": "Śàḿƥĺē ƥàŷĺōàď (ōƥţĩōńàĺ)",
|
||||
"scheduleLabel": "Śćĥēďũĺē",
|
||||
"scheduleSyntaxHelp": "Ũśē <0>{{every}}</0> ŵĩţĥ à ďũŕàţĩōń (ē.ĝ., <1>{{example1}}</1>, <2>{{example2}}</2>) ōŕ śĥōŕţĥàńďś ĺĩķē <3>{{hourly}}</3>, <4>{{daily}}</4>, <5>{{weekly}}</5>.",
|
||||
"scheduleTooltip": "Ĥōŵ ōƒţēń ţō ćĥēćķ ƒōŕ ḿàţćĥĩńĝ ēvēńţś. Ćĥēćķēď ēvēŕŷ 30 śēćōńďś ƀŷ à ƀàćķĝŕōũńď ƥŕōćēśś. Śćĥēďũĺēś ƥēŕśĩśţ àćŕōśś ƀàćķēńď ŕēśţàŕţś.",
|
||||
"searchConditions": "Śēàŕćĥ ćōńďĩţĩōńś...",
|
||||
"selectRepositoryPlaceholder": "Śēĺēćţ à ŕēƥōśĩţōŕŷ",
|
||||
"summaryCiCompleted": "ĆĨ ćōḿƥĺēţēď",
|
||||
"summaryCron": "Ćŕōń: {{expression}}",
|
||||
"summaryCustomSchedule": "Ćũśţōḿ śćĥēďũĺē",
|
||||
"summaryPrEvents": "ƤŔ: {{events}}",
|
||||
"summaryPullRequestEvent": "Ƥũĺĺ ŕēqũēśţ ēvēńţ",
|
||||
"summaryPushToBranch": "Ƥũśĥ ţō ƀŕàńćĥ",
|
||||
"summaryWebhook": "Ŵēƀĥōōķ",
|
||||
"switchToScheduled": "Śŵĩţćĥ ţō śćĥēďũĺēď",
|
||||
"taskTitleHelp": "Ĺēàvē ēḿƥţŷ ţō ũśē ţĥē ďēƒàũĺţ. Śũƥƥōŕţś ƥĺàćēĥōĺďēŕś.",
|
||||
"taskTitleLabel": "Ţàśķ ţĩţĺē",
|
||||
"taskTitlePlaceholder": "[Àũţō] àũţōḿàţĩōń ńàḿē",
|
||||
"thenDescription": "À ńēŵ ţàśķ ŵĩĺĺ ƀē ćŕēàţēď ēàćĥ ţĩḿē ţĥĩś àũţōḿàţĩōń ţŕĩĝĝēŕś",
|
||||
"thenTitle": "Ţĥēń",
|
||||
"triggerCategoryGithub": "ĜĩţĤũƀ",
|
||||
"triggerCategoryWebhook": "Ŵēƀĥōōķ",
|
||||
"triggerInfoGithubPr": "Ƥōĺĺś ĜĩţĤũƀ ÀƤĨ ōń ŷōũŕ śćĥēďũĺē ƒōŕ ƤŔś ḿàţćĥĩńĝ ŷōũŕ ƒĩĺţēŕś.",
|
||||
"triggerInfoNotImplemented": "Ńōţ ŷēţ ĩḿƥĺēḿēńţēď.",
|
||||
"triggerInfoScheduled": "Ćĥēćķēď ēvēŕŷ 30 śēćōńďś. Ƒĩŕēś ŵĥēń ţĥē ćŕōń śćĥēďũĺē ḿàţćĥēś.",
|
||||
"triggerInfoWebhook": "Ƒĩŕēś ĩḿḿēďĩàţēĺŷ ŵĥēń à ƤŌŚŢ ŕēqũēśţ ĥĩţś ţĥē ŵēƀĥōōķ ŨŔĹ.",
|
||||
"triggerLabelGithubCi": "ĜĩţĤũƀ ĆĨ",
|
||||
"triggerLabelGithubPr": "ĜĩţĤũƀ ƤŔ",
|
||||
"triggerLabelGithubPush": "ĜĩţĤũƀ Ƥũśĥ",
|
||||
"triggerLabelScheduled": "Śćĥēďũĺēď",
|
||||
"triggerLabelWebhook": "Ŵēƀĥōōķ",
|
||||
"triggerManually": "Ţŕĩĝĝēŕ ḿàńũàĺĺŷ",
|
||||
"unknownTriggerType": "Ũńķńōŵń ţŕĩĝĝēŕ ţŷƥē",
|
||||
"watchForLabel": "Ŵàţćĥ ƒōŕ",
|
||||
"webhookCreatedDescription": "Ćōńƒĩĝũŕē ŷōũŕ ēxţēŕńàĺ śŷśţēḿ ţō ƤŌŚŢ ţō ţĥĩś ŨŔĹ ŵĩţĥ ţĥē śēćŕēţ ĩń ţĥē <0>{{header}}</0> ĥēàďēŕ. Ŷōũ ćàń ćōḿē ƀàćķ ţō ţĥĩś àũţōḿàţĩōń àńŷ ţĩḿē ţō ćōƥŷ ţĥēśē vàĺũēś àĝàĩń.",
|
||||
"webhookSecretLabel": "Ŵēƀĥōōķ śēćŕēţ",
|
||||
"webhookSecretLoading": "Ĺōàďĩńĝ…",
|
||||
"webhookUrlAfterSave": "Ŵēƀĥōōķ ŨŔĹ ŵĩĺĺ ƀē àvàĩĺàƀĺē àƒţēŕ śàvĩńĝ ţĥē àũţōḿàţĩōń.",
|
||||
"webhookUrlLabel": "Ŵēƀĥōōķ ŨŔĹ",
|
||||
"webhookUsageHelp": "Śēńď à ƤŌŚŢ ŕēqũēśţ ŵĩţĥ à ĴŚŌŃ ƀōďŷ àńď ţĥē śēćŕēţ ĩń ţĥē <0>{{header}}</0> ĥēàďēŕ. Ŕēƒēŕēńćē ƒĩēĺďś ƒŕōḿ ţĥē ƥàŷĺōàď ŵĩţĥ <1>{{placeholder}}</1>, ē.ĝ. <2>{{example}}</2>.",
|
||||
"whenDescription": "Ŵĥàţ ćàũśēś ţĥĩś àũţōḿàţĩōń ţō ŕũń",
|
||||
"whenTitle": "Ŵĥēń",
|
||||
"workflowHelp": "Śēĺēćţ à ŵōŕķƒĺōŵ ţō ēńàƀĺē śàvĩńĝ.",
|
||||
"workflowLabel": "Ŵōŕķƒĺōŵ",
|
||||
"workflowPlaceholder": "Śēĺēćţ ŵōŕķƒĺōŵ",
|
||||
"workflowStepHelp": "Śēĺēćţ à ŵōŕķƒĺōŵ śţēƥ ţō ēńàƀĺē śàvĩńĝ.",
|
||||
"workflowStepHelpNoWorkflow": "Śēĺēćţ à ŵōŕķƒĺōŵ ƀēƒōŕē ćĥōōśĩńĝ à śţēƥ.",
|
||||
"workflowStepLabel": "Ŵōŕķƒĺōŵ Śţēƥ",
|
||||
"workflowStepPlaceholder": "Śēĺēćţ śţēƥ",
|
||||
"workflowStepPlaceholderNoWorkflow": "Ƥĩćķ à ŵōŕķƒĺōŵ ƒĩŕśţ"
|
||||
}
|
||||
@@ -46,6 +46,9 @@
|
||||
"integrations": "Ĩńţēĝŕàţĩōńś",
|
||||
"justNow": "ĵũśţ ńōŵ",
|
||||
"mAgo": "{{diffMin}}ḿ àĝō",
|
||||
"multiRepoUnavailableLocal": "Ḿũĺţĩ-ŕēƥō ţàśķś àŕē ũńàvàĩĺàƀĺē ōń Ĺōćàĺ ũńţĩĺ ĩţś ĩńĩţĩàĺ ĺàũńćĥ ƥàţĥ ćàń ƥŕōĵēćţ śĩƀĺĩńĝ ŕēƥōśĩţōŕĩēś.",
|
||||
"multiRepoUnavailableRemoteDocker": "Ḿũĺţĩ-ŕēƥō ţàśķś àŕē ũńàvàĩĺàƀĺē ōń Ŕēḿōţē Ďōćķēŕ ũńţĩĺ ĩţ śũƥƥōŕţś ćŕēàţĩńĝ ţàśķ ĩńśţàńćēś.",
|
||||
"multiRepoUnsupportedExecutor": "Ḿũĺţĩ-ŕēƥō ţàśķś àŕē ńōţ śũƥƥōŕţēď ƀŷ ţĥĩś ēxēćũţōŕ.",
|
||||
"plugins": "Ƥĺũĝĩńś",
|
||||
"prDetails": "ƤŔ Ďēţàĩĺś",
|
||||
"presetIconBug": "Ɓũĝ",
|
||||
|
||||
@@ -235,6 +235,30 @@ new PR's. Entries that a broader glob already covers are deliberate and stay —
|
||||
they record which PR migrated which half of a group — so this is an exact-match
|
||||
check, not a redundancy check.
|
||||
|
||||
#### An entry can be born dead, and only a glob expansion shows it
|
||||
|
||||
Both checks above ask what happened to an entry *between* two states. Neither
|
||||
asks the prior question: **does this entry match any file at all?** An entry that
|
||||
matched nothing on the day it was added is indistinguishable from a healthy one —
|
||||
it never leaves the array, it is never a duplicate, and `pnpm lint` passes
|
||||
because the rule simply has no files to apply to. The list grows, the ratchet
|
||||
reports "N added", and the path is unguarded.
|
||||
|
||||
A dynamic route is the way to hit this, because Next-style directories are named
|
||||
in glob syntax. `[id]` is a **character class** matching a single `i` or `d`:
|
||||
|
||||
```bash
|
||||
node -e 'console.log(require("fs").globSync("app/settings/workspace/[id]/automations/**/*.tsx").length)' # 0
|
||||
node -e 'console.log(require("fs").globSync("app/settings/workspace/[[]id[]]/automations/**/*.tsx").length)' # 4
|
||||
```
|
||||
|
||||
The Automations migration (#2247) shipped the unescaped form, and review caught
|
||||
it. So when an entry contains `[`, escape it as `[[]…[]]` — and prove the entry
|
||||
is live rather than trusting the glob result, the same way you would prove any
|
||||
other check earned its pass: put a hardcoded literal in one of the files the
|
||||
entry is supposed to cover and confirm `pnpm lint` reports
|
||||
`i18next/no-literal-string`, then remove it. A silent 0 errors is the tell.
|
||||
|
||||
### …but new code is guarded everywhere
|
||||
|
||||
Scoping the whole-file guard leaves a hole: a brand-new component under an
|
||||
|
||||
Reference in New Issue
Block a user