From 282e118b143b26237c8024f2aeefa8afdc2c3863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Mon, 3 Aug 2026 02:48:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(i18n):=20localize=20Settings=20=E2=86=92?= =?UTF-8?q?=20Integrations=20=E2=86=92=20Azure=20DevOps=20and=20Slack=20(#?= =?UTF-8?q?2187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Kandev Agent Co-authored-by: Claude Opus 5 (1M context) --- .../azure-devops-default-queries.tsx | 144 ++++-- .../azure-devops-quick-actions.test.tsx | 10 +- .../azure-devops-quick-actions.tsx | 209 ++++++--- .../azure-devops-settings.test.tsx | 10 +- .../azure-devops/azure-devops-settings.tsx | 113 +++-- .../azure-devops-watch-settings.test.tsx | 119 +++++ .../azure-devops-watch-settings.tsx | 212 ++++++--- .../azure-devops-workspace-defaults.ts | 24 + .../integrations/action-preset-icons.ts | 23 +- .../components/slack/slack-settings-state.ts | 266 +++++++++++ .../components/slack/slack-settings.test.tsx | 127 ++++++ apps/web/components/slack/slack-settings.tsx | 421 +++++------------- apps/web/eslint.i18n.options.mjs | 42 ++ apps/web/src/locales/en/azuredevops.json | 156 +++++++ apps/web/src/locales/en/common.json | 10 +- apps/web/src/locales/en/slack.json | 39 ++ apps/web/src/locales/pseudo/azuredevops.json | 156 +++++++ apps/web/src/locales/pseudo/common.json | 10 +- apps/web/src/locales/pseudo/slack.json | 39 ++ 19 files changed, 1597 insertions(+), 533 deletions(-) create mode 100644 apps/web/components/azure-devops/azure-devops-watch-settings.test.tsx create mode 100644 apps/web/components/slack/slack-settings-state.ts create mode 100644 apps/web/components/slack/slack-settings.test.tsx create mode 100644 apps/web/src/locales/en/azuredevops.json create mode 100644 apps/web/src/locales/en/slack.json create mode 100644 apps/web/src/locales/pseudo/azuredevops.json create mode 100644 apps/web/src/locales/pseudo/slack.json diff --git a/apps/web/components/azure-devops/azure-devops-default-queries.tsx b/apps/web/components/azure-devops/azure-devops-default-queries.tsx index cdb4fb6eab..c8b89a8a0a 100644 --- a/apps/web/components/azure-devops/azure-devops-default-queries.tsx +++ b/apps/web/components/azure-devops/azure-devops-default-queries.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { IconPlus, IconRefresh, IconTrash } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { CardContent } from "@kandev/ui/card"; @@ -21,15 +22,43 @@ import { DEFAULT_AZURE_WORK_ITEM_QUERIES, } from "./azure-devops-workspace-defaults"; -type QueryKind = "Work item" | "Pull request"; +/** + * Wire discriminant for the two query kinds. These used to be the English + * phrases "Work item" / "Pull request", which made the same value both a + * `===` comparand and display copy; they are now identifiers, and the labels + * resolve through the key maps below. + */ +type QueryKind = "work-item" | "pull-request"; +/** Title-cased kind noun, e.g. "Work item query label 1". */ +const KIND_TITLE_KEYS: Record = { + "work-item": "azuredevops:workItemKindTitle", + "pull-request": "azuredevops:pullRequestKindTitle", +}; + +/** Lower-cased kind noun for mid-sentence use, e.g. "Remove work item query 1". */ +const KIND_LOWER_KEYS: Record = { + "work-item": "azuredevops:workItemKindLower", + "pull-request": "azuredevops:pullRequestKindLower", +}; + +/** Azure DevOps identity shorthand accepted by the creator/reviewer filters. */ +const AZURE_ME_TOKEN = "@me"; + +/** Route these presets drive; a path, so it is interpolated rather than translated. */ +const AZURE_DEVOPS_ROUTE = "/azure-devops"; + +// `label` is PERSISTED as part of `AzureDevOpsQueryPreset` and is editable in +// the row below, so it must stay locale-neutral — the same contract as +// `newPreset` in components/github/default-queries-section.tsx. The seeded WIQL +// is Azure DevOps' query language, not copy. function newQuery(kind: QueryKind): AzureDevOpsQueryPreset { return { id: `query_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, label: "New query", group: "inbox", filters: - kind === "Work item" + kind === "work-item" ? { wiql: "SELECT [System.Id] FROM WorkItems WHERE [System.TeamProject] = @project", top: 50, @@ -59,21 +88,26 @@ function QueryGroupSelect({ dirty: boolean; onChange: (value: string) => void; }) { + const { t } = useTranslation(); return ( @@ -115,13 +149,17 @@ function RemoveQueryButton({ index: number; onRemove: () => void; }) { + const { t } = useTranslation(); return ( ); @@ -383,7 +431,7 @@ function validQueries(queries: AzureDevOpsQueryPreset[], kind: QueryKind): boole queries.every( (query) => query.label.trim() && - (kind === "Pull request" + (kind === "pull-request" ? stringFilter(query, "status", "active") : stringFilter(query, "wiql")), ) @@ -391,38 +439,40 @@ function validQueries(queries: AzureDevOpsQueryPreset[], kind: QueryKind): boole } export function AzureDevOpsDefaultQueriesSection({ workspaceId }: { workspaceId: string }) { + const { t } = useTranslation(); const drafts = useQueryDrafts(workspaceId); const { toast } = useToast(); const valid = - validQueries(drafts.pullRequests, "Pull request") && - validQueries(drafts.workItems, "Work item"); + validQueries(drafts.pullRequests, "pull-request") && + validQueries(drafts.workItems, "work-item"); const save = useCallback(async () => { try { await drafts.save(); - toast({ description: "Azure DevOps default queries saved", variant: "success" }); + toast({ description: t("azuredevops:defaultQueriesSaved"), variant: "success" }); } catch (error) { toast({ - description: error instanceof Error ? error.message : "Failed to save default queries", + description: + error instanceof Error ? error.message : t("azuredevops:failedToSaveDefaultQueries"), variant: "error", }); throw error; } - }, [drafts, toast]); + }, [drafts, t, toast]); useSettingsSaveContributor({ id: `azure-devops-default-queries:${workspaceId}`, revision: JSON.stringify([drafts.pullRequests, drafts.workItems]), isDirty: drafts.dirty, canSave: !drafts.loading && valid, - invalidReason: valid ? undefined : "Every default query needs a label and valid filters.", + invalidReason: valid ? undefined : t("azuredevops:defaultQueryInvalid"), save, discard: drafts.discard, }); return ( - Reset + {t("common:reset")} } > @@ -442,15 +492,15 @@ export function AzureDevOpsDefaultQueriesSection({ workspaceId }: { workspaceId: - Pull requests + {t("azuredevops:pullRequestsTab")} - Work items + {t("azuredevops:workItemsTab")} { }); expect(screen.getByRole("button", { name: "Reset" })).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Edit prompt" })); - expect(screen.getByText(/available placeholders/)).toBeTruthy(); + // The hint is a `` whose `` indices address the JSX children + // positionally, so a reflow of those children silently reassembles the + // sentence into fragments. Assert the whole reconstructed hint, including + // the three prompt tokens, which are passed as values so neither i18next + // interpolation nor the pseudo-locale rewrites them. + const hint = screen.getByText(/available placeholders/); + expect(hint.textContent).toBe( + "Type {{ to see available placeholders. {{url}} and {{title}} are substituted when the action runs.", + ); }); it("keeps edits local until the shared Save changes action is pressed", async () => { diff --git a/apps/web/components/azure-devops/azure-devops-quick-actions.tsx b/apps/web/components/azure-devops/azure-devops-quick-actions.tsx index 38e9d39dc4..766f68c0e6 100644 --- a/apps/web/components/azure-devops/azure-devops-quick-actions.tsx +++ b/apps/web/components/azure-devops/azure-devops-quick-actions.tsx @@ -1,6 +1,7 @@ "use client"; import { createElement, useCallback, useEffect, useMemo, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { IconPlus, IconRefresh, IconTrash } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { CardContent } from "@kandev/ui/card"; @@ -30,23 +31,65 @@ import { DEFAULT_AZURE_WORK_ITEM_ACTIONS, } from "./azure-devops-workspace-defaults"; -const ACTION_PROMPT_PLACEHOLDERS: ScriptPlaceholder[] = [ - { - key: "url", - description: "URL of the Azure DevOps work item or pull request", - example: "https://dev.azure.com/acme/Platform/_workitems/edit/42", - executor_types: [], - }, - { - key: "title", - description: "Title of the Azure DevOps work item or pull request", - example: "Rotate integration credentials", - executor_types: [], - }, -]; +type Translate = (key: string, values?: Record) => string; -type ActionKind = "Work item" | "Pull request"; +/** + * Prompt-template placeholders offered by the ScriptEditor completions. + * + * A function rather than a module-scope constant: `t()` at module scope would + * freeze the descriptions at the boot locale. `key` is the placeholder token + * the backend substitutes and `example` is sample data — neither is copy. + * Memoize at every call site: ScriptEditor keys its completion-provider + * registration on array identity. + */ +function actionPromptPlaceholders(t: Translate): ScriptPlaceholder[] { + return [ + { + key: "url", + description: t("azuredevops:placeholderUrlDescription"), + example: "https://dev.azure.com/acme/Platform/_workitems/edit/42", + executor_types: [], + }, + { + key: "title", + description: t("azuredevops:placeholderTitleDescription"), + example: "Rotate integration credentials", + executor_types: [], + }, + ]; +} +/** + * Wire discriminant for the two action kinds. These used to be the English + * phrases "Work item" / "Pull request", which made the same value both a + * `===` comparand and display copy; they are now identifiers, and the labels + * resolve through the key maps below. + */ +type ActionKind = "work-item" | "pull-request"; + +/** Title-cased kind noun, e.g. "Work item action label 1". */ +const KIND_TITLE_KEYS: Record = { + "work-item": "azuredevops:workItemKindTitle", + "pull-request": "azuredevops:pullRequestKindTitle", +}; + +/** Lower-cased kind noun for mid-sentence use, e.g. "Remove work item action 1". */ +const KIND_LOWER_KEYS: Record = { + "work-item": "azuredevops:workItemKindLower", + "pull-request": "azuredevops:pullRequestKindLower", +}; + +/** Route these presets drive; a path, so it is interpolated rather than translated. */ +const AZURE_DEVOPS_ROUTE = "/azure-devops"; + +/** Prompt-template tokens, shown verbatim in the hint below the editor. */ +const PROMPT_OPEN_BRACES = "{{"; +const PROMPT_URL_TOKEN = "{{url}}"; +const PROMPT_TITLE_TOKEN = "{{title}}"; + +// `label` is PERSISTED as part of `AzureDevOpsActionPreset` and is editable in +// the row below, so it must stay locale-neutral — the same contract as +// `newPreset` in components/github/action-presets-section.tsx. function newAction(): AzureDevOpsActionPreset { return { id: `preset_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`, @@ -66,11 +109,12 @@ function ActionIconSelect({ dirty: boolean; onChange: (value: string) => void; }) { + const { t } = useTranslation(); return ( onPatch({ label: event.target.value })} /> - + onPatch({ hint: event.target.value })} /> @@ -170,7 +260,7 @@ function ActionRow({ className="h-11 cursor-pointer text-xs sm:h-8" onClick={onToggle} > - {expanded ? "Hide prompt" : "Edit prompt"} + {expanded ? t("azuredevops:hidePrompt") : t("azuredevops:editPrompt")} - {expanded && ( -
-
- onPatch({ promptTemplate })} - language="markdown" - height={computeEditorHeight(action.promptTemplate)} - lineNumbers="off" - placeholders={ACTION_PROMPT_PLACEHOLDERS} - /> -
-

- Type {"{{"} to see available placeholders. {"{{url}}"} and{" "} - {"{{title}}"} are substituted when the action runs. -

-
- )} + {expanded && } ); } @@ -220,6 +292,7 @@ function ActionEditor({ baseline: AzureDevOpsActionPreset[]; onChange: (actions: AzureDevOpsActionPreset[]) => void; }) { + const { t } = useTranslation(); const [expandedId, setExpandedId] = useState(null); const patch = (index: number, values: Partial) => onChange( @@ -252,13 +325,16 @@ function ActionEditor({ className="h-11 w-full cursor-pointer sm:h-8 sm:w-auto" onClick={add} > - Add {kind === "Pull request" ? "PR" : "work item"} action + {" "} + {kind === "pull-request" + ? t("azuredevops:addPrAction") + : t("azuredevops:addWorkItemAction")} ); } -function useActionDrafts(workspaceId: string) { +function useActionDrafts(workspaceId: string, t: Translate) { const [workItems, setWorkItems] = useState(DEFAULT_AZURE_WORK_ITEM_ACTIONS); const [pullRequests, setPullRequests] = useState(DEFAULT_AZURE_PULL_REQUEST_ACTIONS); const [baseline, setBaseline] = useState({ workItems, pullRequests }); @@ -287,13 +363,15 @@ function useActionDrafts(workspaceId: string) { }) .catch((error: unknown) => { if (!current) return; - setLoadError(error instanceof Error ? error.message : "Failed to load quick actions."); + setLoadError( + error instanceof Error ? error.message : t("azuredevops:failedToLoadQuickActions"), + ); }) .finally(() => current && setLoading(false)); return () => { current = false; }; - }, [workspaceId]); + }, [t, workspaceId]); const save = useCallback(async () => { const response = await updateAzureDevOpsWorkspaceSettings( @@ -344,37 +422,38 @@ function validActions(actions: AzureDevOpsActionPreset[]): boolean { } export function AzureDevOpsQuickActionsSection({ workspaceId }: { workspaceId: string }) { - const drafts = useActionDrafts(workspaceId); + const { t } = useTranslation(); + const drafts = useActionDrafts(workspaceId, t); const { toast } = useToast(); const valid = validActions(drafts.workItems) && validActions(drafts.pullRequests); const save = useCallback(async () => { try { await drafts.save(); - toast({ description: "Azure DevOps quick actions saved", variant: "success" }); + toast({ description: t("azuredevops:quickActionsSaved"), variant: "success" }); } catch (error) { toast({ - description: error instanceof Error ? error.message : "Failed to save quick actions", + description: + error instanceof Error ? error.message : t("azuredevops:failedToSaveQuickActions"), variant: "error", }); throw error; } - }, [drafts, toast]); + }, [drafts, t, toast]); useSettingsSaveContributor({ id: `azure-devops-actions:${workspaceId}`, revision: JSON.stringify([drafts.workItems, drafts.pullRequests]), isDirty: drafts.dirty, canSave: !drafts.loading && !drafts.loadError && valid, - invalidReason: - drafts.loadError ?? (valid ? undefined : "Every quick action needs a label and prompt."), + invalidReason: drafts.loadError ?? (valid ? undefined : t("azuredevops:quickActionInvalid")), save, discard: drafts.discard, }); return ( - Reset + {t("common:reset")} } > @@ -392,22 +471,22 @@ export function AzureDevOpsQuickActionsSection({ workspaceId }: { workspaceId: s {drafts.loadError && (

- Could not load existing quick actions: {drafts.loadError} + {t("azuredevops:couldNotLoadQuickActions", { error: drafts.loadError })}

)}
- Pull requests + {t("azuredevops:pullRequestsTab")} - Work items + {t("azuredevops:workItemsTab")} { `${OLD_ORGANIZATION_URL}/_usersSettings/tokens`, ); expect(patHelp.textContent).toContain("Custom defined"); - expect(patHelp.textContent).toContain("Work Items"); - expect(patHelp.textContent).toContain("Code"); - expect(patHelp.textContent).toContain("Read"); + // The scope step is a `` whose `` indices address the JSX children + // positionally, so a reflow of those children silently reassembles the + // sentence into fragments. Assert the whole reconstructed clause, not just + // that the two scope names appear somewhere. + expect(patHelp.textContent).toContain("Under Work Items, check Read & write."); + expect(patHelp.textContent).toContain("Under Code, check Read."); + expect(patHelp.textContent).toContain("Leave all other scopes unchecked."); }); it("does not create a token link from a non-Azure organization URL", async () => { diff --git a/apps/web/components/azure-devops/azure-devops-settings.tsx b/apps/web/components/azure-devops/azure-devops-settings.tsx index fd89011547..57e0d6b096 100644 --- a/apps/web/components/azure-devops/azure-devops-settings.tsx +++ b/apps/web/components/azure-devops/azure-devops-settings.tsx @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; +import { Trans, useTranslation } from "react-i18next"; import { IconBrandAzure, IconDeviceFloppy, @@ -142,6 +143,7 @@ function useConfigRefresh( } function useAzureDevOpsSettings(workspaceId: string) { + const { t } = useTranslation(); const { toast } = useToast(); const [config, setConfig] = useState(null); const [form, setForm] = useState(EMPTY_FORM); @@ -158,13 +160,13 @@ function useAzureDevOpsSettings(workspaceId: string) { setForm(configToForm(next)); } catch (err) { toast({ - description: `Failed to load Azure DevOps config: ${String(err)}`, + description: t("azuredevops:failedToLoadConfig", { error: String(err) }), variant: "error", }); } finally { setLoading(false); } - }, [toast, workspaceId]); + }, [t, toast, workspaceId]); useEffect(() => void load(), [load]); useConfigRefresh(workspaceId, setConfig); @@ -201,26 +203,32 @@ function useAzureDevOpsSettings(workspaceId: string) { setConfig(next); setForm(configToForm(next)); setTestResult(null); - toast({ description: "Azure DevOps configuration saved", variant: "success" }); + toast({ description: t("azuredevops:configurationSaved"), variant: "success" }); } catch (err) { - toast({ description: `Save failed: ${String(err)}`, variant: "error" }); + toast({ + description: t("azuredevops:saveFailed", { error: String(err) }), + variant: "error", + }); } finally { setSaving(false); } - }, [config, form, toast, workspaceId]); + }, [config, form, t, toast, workspaceId]); const remove = useCallback(async () => { - if (!confirm("Remove Azure DevOps configuration?")) return; + if (!confirm(t("azuredevops:removeConfigurationConfirm"))) return; try { await deleteAzureDevOpsConfig(workspaceId); setConfig(null); setForm(EMPTY_FORM); setTestResult(null); - toast({ description: "Azure DevOps configuration removed", variant: "success" }); + toast({ description: t("azuredevops:configurationRemoved"), variant: "success" }); } catch (err) { - toast({ description: `Remove failed: ${String(err)}`, variant: "error" }); + toast({ + description: t("azuredevops:removeFailed", { error: String(err) }), + variant: "error", + }); } - }, [toast, workspaceId]); + }, [t, toast, workspaceId]); return { config, @@ -237,15 +245,24 @@ function useAzureDevOpsSettings(workspaceId: string) { }; } +function testResultMessage( + t: (key: string, values?: Record) => string, + result: TestAzureDevOpsConnectionResult, +): string { + if (!result.ok) return result.error || t("azuredevops:connectionFailed"); + // `displayName` is the Azure DevOps identity — data, so it is interpolated + // rather than concatenated into the message. + return result.displayName + ? t("azuredevops:connectedAs", { name: result.displayName }) + : t("azuredevops:connected"); +} + function TestResult({ result }: { result: TestAzureDevOpsConnectionResult | null }) { + const { t } = useTranslation(); if (!result) return null; return ( - - {result.ok - ? `Connected${result.displayName ? ` as ${result.displayName}` : ""}` - : result.error || "Connection failed"} - + {testResultMessage(t, result)} ); } @@ -253,7 +270,14 @@ function TestResult({ result }: { result: TestAzureDevOpsConnectionResult | null type SettingsState = ReturnType; type ProjectsState = ReturnType; +// Azure DevOps names these two PAT scope groups in its own token UI. They are +// pointers into that screen, not copy, so they are interpolated as values — the +// pseudo-locale must not transliterate a label the user has to find verbatim. +const PAT_SCOPE_WORK_ITEMS = "Work Items"; +const PAT_SCOPE_CODE = "Code"; + function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) { + const { t } = useTranslation(); const patURL = azureDevOpsPATURL(organizationUrl); const [open, setOpen] = useState(false); @@ -266,7 +290,7 @@ function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) { variant="ghost" size="icon-sm" className="size-11 shrink-0 cursor-help text-muted-foreground sm:size-7" - aria-label="How to create a personal access token" + aria-label={t("azuredevops:howToCreatePat")} onClick={() => setOpen((current) => !current)} > @@ -279,18 +303,22 @@ function PATSetupHelp({ organizationUrl }: { organizationUrl: string }) { className="pointer-events-auto max-w-sm space-y-2 p-3 text-left text-xs leading-relaxed" data-testid="azure-devops-pat-help" > -

- Create a personal access token for board editing -

+

{t("azuredevops:patHelpTitle")}

    -
  1. Open token settings and select New Token.
  2. -
  3. Choose this organization, a short expiration, and Custom defined scopes.
  4. +
  5. {t("azuredevops:patStepOpenTokenSettings")}
  6. +
  7. {t("azuredevops:patStepChooseScopes")}
  8. - Under Work Items, check Read - & write. Under Code, check - Read. Leave all other scopes unchecked. + + Under {PAT_SCOPE_WORK_ITEMS}, + check Read & write. Under{" "} + {PAT_SCOPE_CODE}, check Read. + Leave all other scopes unchecked. +
  9. -
  10. Create the token, copy it, and paste it into this field.
  11. +
  12. {t("azuredevops:patStepCreateAndPaste")}
{patURL ? ( - Create personal access token + {t("azuredevops:createPersonalAccessToken")} ) : ( -

- Enter a valid organization URL to open its token settings. -

+

{t("azuredevops:enterValidOrganizationUrl")}

)} @@ -324,12 +350,15 @@ function ConnectionFields({ canReusePAT: boolean; projectSelectionEnabled: boolean; }) { - const projectPlaceholder = projects.loading ? "Loading projects..." : "Optional"; + const { t } = useTranslation(); + const projectPlaceholder = projects.loading + ? t("azuredevops:loadingProjects") + : t("azuredevops:optional"); return ( <>
- +
- +
state.update("pat", event.target.value)} - placeholder={canReusePAT ? "Saved credential" : "Paste PAT"} + placeholder={canReusePAT ? t("azuredevops:savedCredential") : t("azuredevops:pastePat")} disabled={state.loading} autoComplete="new-password" aria-describedby="azure-devops-pat-help" @@ -397,12 +426,13 @@ function ConnectionFields({ ); } -function saveButtonLabel(state: SettingsState): string { - if (state.saving) return "Saving..."; - return state.config ? "Update" : "Save"; +function saveButtonLabel(t: (key: string) => string, state: SettingsState): string { + if (state.saving) return t("azuredevops:saving"); + return state.config ? t("azuredevops:update") : t("azuredevops:save"); } function ConnectionActions({ state, disabled }: { state: SettingsState; disabled: boolean }) { + const { t } = useTranslation(); return (
{state.config && ( )}
@@ -443,6 +473,7 @@ function ConnectionActions({ state, disabled }: { state: SettingsState; disabled } export function AzureDevOpsConnectionSection({ workspaceId }: { workspaceId: string }) { + const { t } = useTranslation(); const state = useAzureDevOpsSettings(workspaceId); const projects = useAzureDevOpsProjects(workspaceId, !!state.config?.hasSecret); const canReusePAT = savedPATMatches(state.config, state.form); @@ -456,8 +487,8 @@ export function AzureDevOpsConnectionSection({ workspaceId }: { workspaceId: str return ( } - title="Azure DevOps integration" - description="Azure DevOps Services organization, project, and PAT for this workspace." + title={t("azuredevops:integrationTitle")} + description={t("azuredevops:integrationDescription")} > diff --git a/apps/web/components/azure-devops/azure-devops-watch-settings.test.tsx b/apps/web/components/azure-devops/azure-devops-watch-settings.test.tsx new file mode 100644 index 0000000000..c05bc5998e --- /dev/null +++ b/apps/web/components/azure-devops/azure-devops-watch-settings.test.tsx @@ -0,0 +1,119 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "@/lib/i18n"; +import type { + AzureDevOpsPullRequestWatch, + AzureDevOpsWorkItemWatch, +} from "@/lib/types/azure-devops"; + +const PR_CARD = "azure-pull-request-watch-pr-1"; +const WI_CARD = "azure-work-item-watch-wi-1"; + +const mocks = vi.hoisted(() => ({ watches: vi.fn() })); + +vi.mock("@/hooks/domains/azure-devops/use-azure-devops-watches", () => ({ + useAzureDevOpsWatches: mocks.watches, +})); + +import { AzureDevOpsWatchSettings } from "@/components/azure-devops/azure-devops-watch-settings"; + +const PULL_REQUEST: AzureDevOpsPullRequestWatch = { + id: "pr-1", + projectId: "project-1", + status: "completed", + enabled: true, + pollIntervalSeconds: 300, + cleanupPolicy: "auto", +} as AzureDevOpsPullRequestWatch; + +const WORK_ITEM: AzureDevOpsWorkItemWatch = { + id: "wi-1", + projectId: "project-1", + wiql: "SELECT [System.Id] FROM WorkItems", + enabled: false, + pollIntervalSeconds: 600, + cleanupPolicy: "never", +} as AzureDevOpsWorkItemWatch; + +function state(overrides: Partial = {}) { + return { + workItems: [WORK_ITEM], + pullRequests: [{ ...PULL_REQUEST, ...overrides }], + loading: false, + error: null, + refresh: vi.fn(), + createWorkItem: vi.fn(), + createPullRequest: vi.fn(), + updateWorkItem: vi.fn(), + updatePullRequest: vi.fn(), + remove: vi.fn(), + trigger: vi.fn(), + previewReset: vi.fn(), + reset: vi.fn(), + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.watches.mockReturnValue(state()); +}); + +afterEach(async () => { + cleanup(); + await i18n.changeLanguage("en"); +}); + +describe("AzureDevOpsWatchSettings", () => { + it("renders the watch summary", () => { + render(); + + const prCard = screen.getByTestId(PR_CARD); + expect(prCard.textContent).toContain("Status: completed"); + expect(prCard.textContent).toContain("project-1 · every 300s · cleanup auto"); + expect(prCard.textContent).toContain("Not checked yet"); + + const wiCard = screen.getByTestId(WI_CARD); + expect(wiCard.textContent).toContain("project-1 · every 600s · cleanup never"); + }); + + // `status` and `cleanupPolicy` are Azure DevOps wire enums whose values happen + // to equal their own English labels, so an English assertion cannot tell a + // catalog lookup from raw interpolation — it passes either way. Only a + // non-English locale distinguishes them, which is the whole point of the + // pseudo-locale. + it("resolves both wire enums through the catalog rather than interpolating them raw", async () => { + await i18n.changeLanguage("pseudo"); + render(); + + const prCard = screen.getByTestId(PR_CARD); + expect(prCard.textContent).toContain("Śţàţũś: ćōḿƥĺēţēď"); + expect(prCard.textContent).toContain("project-1 · ēvēŕŷ 300ś · ćĺēàńũƥ àũţō"); + // The wire values must not leak through anywhere on the card. + expect(prCard.textContent).not.toContain("completed"); + expect(prCard.textContent).not.toContain("auto"); + + const wiCard = screen.getByTestId(WI_CARD); + expect(wiCard.textContent).toContain("ćĺēàńũƥ ńēvēŕ"); + expect(wiCard.textContent).not.toContain("never"); + // `projectId` is Azure DevOps data and the WIQL is a query language; both + // stay verbatim under pseudo. + expect(wiCard.textContent).toContain("project-1"); + expect(wiCard.textContent).toContain("SELECT [System.Id] FROM WorkItems"); + }); + + it("treats an `all` status filter the same as no status filter", async () => { + mocks.watches.mockReturnValue(state({ status: "all" })); + await i18n.changeLanguage("pseudo"); + render(); + expect(screen.getByTestId(PR_CARD).textContent).toContain("Śţàţũś: àńŷ"); + }); + + it("echoes an unrecognized status rather than blanking it", async () => { + mocks.watches.mockReturnValue( + state({ status: "draft" as AzureDevOpsPullRequestWatch["status"] }), + ); + await i18n.changeLanguage("pseudo"); + render(); + expect(screen.getByTestId(PR_CARD).textContent).toContain("Śţàţũś: draft"); + }); +}); diff --git a/apps/web/components/azure-devops/azure-devops-watch-settings.tsx b/apps/web/components/azure-devops/azure-devops-watch-settings.tsx index 2c297d4f20..66cb291a6d 100644 --- a/apps/web/components/azure-devops/azure-devops-watch-settings.tsx +++ b/apps/web/components/azure-devops/azure-devops-watch-settings.tsx @@ -3,6 +3,7 @@ "use client"; import { useState } from "react"; +import { useTranslation } from "react-i18next"; import { IconPlayerPlay, IconPlus, IconRefresh, IconTrash, IconX } from "@tabler/icons-react"; import { Alert, AlertDescription } from "@kandev/ui/alert"; import { Badge } from "@kandev/ui/badge"; @@ -14,6 +15,7 @@ import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { SettingsSection } from "@/components/settings/settings-section"; +import { formatDateTime } from "@/lib/i18n/formats"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useAzureDevOpsWatches } from "@/hooks/domains/azure-devops/use-azure-devops-watches"; import type { @@ -26,12 +28,63 @@ import type { type Kind = "work-item" | "pull-request"; -function formatLastChecked(value?: string | null): string { - if (!value) return "Not checked yet"; - const date = new Date(value); - return Number.isNaN(date.valueOf()) ? "Not checked yet" : `checked ${date.toLocaleString()}`; +type Translate = (key: string, values?: Record) => string; + +/** Azure DevOps identity shorthand accepted by the creator/reviewer filters. */ +const AZURE_ME_TOKEN = "@me"; + +/** + * Catalog keys for the two watch kinds. `Kind` is a wire discriminant compared + * with `===` and baked into `data-testid`s, so it is never rendered directly — + * only the label resolves at render. + */ +const KIND_NOUN_KEYS: Record = { + "work-item": "azuredevops:workItemKindNoun", + "pull-request": "azuredevops:pullRequestKindNoun", +}; + +/** + * Sentence-cased cleanup-policy labels for the watch summary line. The map key + * is the persisted `AzureDevOpsCleanupPolicy` value; the menu items in the + * editor use the standalone (capitalized) forms instead. + */ +const CLEANUP_POLICY_KEYS: Record = { + auto: "azuredevops:cleanupAuto", + always: "azuredevops:cleanupAlways", + never: "azuredevops:cleanupNever", +}; + +/** + * Sentence-cased labels for the watch card's status line. The map key is the + * Azure DevOps pull-request status sent on the wire; `all` shares the label used + * when no status is set, since both mean "not filtered by status". The editor's + * menu items use the standalone (capitalized) forms instead. + */ +const WATCH_STATUS_KEYS: Record = { + active: "azuredevops:watchStatusActive", + completed: "azuredevops:watchStatusCompleted", + abandoned: "azuredevops:watchStatusAbandoned", + all: "azuredevops:pullRequestAny", +}; + +/** An unrecognized status is echoed verbatim rather than blanked. */ +function watchStatusLabel(t: Translate, status: string | undefined): string { + if (!status) return t("azuredevops:pullRequestAny"); + const key = WATCH_STATUS_KEYS[status]; + return key ? t(key) : status; } +function formatLastChecked(t: Translate, value?: string | null): string { + if (!value) return t("azuredevops:notCheckedYet"); + const date = new Date(value); + if (Number.isNaN(date.valueOf())) return t("azuredevops:notCheckedYet"); + return t("azuredevops:checkedAt", { at: formatDateTime(date) }); +} + +// The two default prompts are PERSISTED on the watch and sent to the agent +// verbatim, and they carry `{{work_item.title}}` / `{{pull_request.title}}` +// placeholders the backend substitutes. They are deliberately NOT translated — +// see the GitLab and Sentry watch defaults for the same contract. const emptyWorkItem: AzureDevOpsWorkItemWatchInput = { workflowId: "", workflowStepId: "", @@ -81,6 +134,7 @@ function WatchActions({ onReset: () => void; onDelete: () => void; }) { + const { t } = useTranslation(); return (
); @@ -152,19 +206,32 @@ function WatchCard({ onReset: () => void; onDelete: () => void; }) { + const { t } = useTranslation(); return ( - {kind === "work-item" ? "Work-item query" : "Pull-request filter"} + + {kind === "work-item" + ? t("azuredevops:workItemQuery") + : t("azuredevops:pullRequestFilter")} + - {watch.enabled ? "Enabled" : "Disabled"} + {watch.enabled ? t("azuredevops:enabled") : t("azuredevops:disabled")}
- {watch.projectId} · every {watch.pollIntervalSeconds}s · cleanup {watch.cleanupPolicy} + {/* `projectId` is Azure DevOps data and `cleanupPolicy` a wire enum; + both are interpolated rather than written into the message. */} + {t("azuredevops:watchSummary", { + projectId: watch.projectId, + count: watch.pollIntervalSeconds, + policy: t(CLEANUP_POLICY_KEYS[watch.cleanupPolicy] ?? CLEANUP_POLICY_KEYS.auto), + })} +
+
+ {formatLastChecked(t, watch.lastPolledAt)}
-
{formatLastChecked(watch.lastPolledAt)}
{kind === "work-item" && ( @@ -174,7 +241,9 @@ function WatchCard({ )} {kind === "pull-request" && (
- Status: {(watch as AzureDevOpsPullRequestWatch).status || "any"} + {t("azuredevops:statusLine", { + status: watchStatusLabel(t, (watch as AzureDevOpsPullRequestWatch).status), + })}
)} {watch.lastError && ( @@ -214,6 +283,7 @@ function WatchEditor({ input: AzureDevOpsWorkItemWatchInput | AzureDevOpsPullRequestWatchInput, ) => Promise; }) { + const { t } = useTranslation(); const { isMobile } = useResponsiveBreakpoint(); const [workItem, setWorkItem] = useState( kind === "work-item" && initial ? (initial as AzureDevOpsWorkItemWatchInput) : emptyWorkItem, @@ -242,8 +312,8 @@ function WatchEditor({ ) { setError( kind === "work-item" - ? "Project, WIQL, repository, workflow, step, agent, and executor are required." - : "Project, repository, workflow, step, agent, and executor are required.", + ? t("azuredevops:workItemWatchFieldsRequired") + : t("azuredevops:pullRequestWatchFieldsRequired"), ); return; } @@ -258,10 +328,12 @@ function WatchEditor({ setSaving(false); } }; - let submitLabel = editing ? "Update watch" : "Create watch"; - if (saving) submitLabel = "Saving…"; - const watchLabel = kind === "work-item" ? "work-item" : "pull-request"; - const editorTitle = `${editing ? "Edit" : "New"} ${watchLabel} watch`; + let submitLabel = editing ? t("azuredevops:updateWatch") : t("azuredevops:createWatch"); + if (saving) submitLabel = t("azuredevops:savingEllipsis"); + const kindNoun = t(KIND_NOUN_KEYS[kind]); + const editorTitle = editing + ? t("azuredevops:editWatchTitle", { kind: kindNoun }) + : t("azuredevops:newWatchTitle", { kind: kindNoun }); const body = (
{error && ( @@ -271,7 +343,7 @@ function WatchEditor({ )}
- + set("projectId", event.target.value)} @@ -279,7 +351,7 @@ function WatchEditor({ />
- + {kind === "work-item" ? (
+ {/* WIQL is the Azure DevOps query language, not copy. */}