diff --git a/apps/web/app/azure-devops/azure-devops-page-client.tsx b/apps/web/app/azure-devops/azure-devops-page-client.tsx index dc42a09d84..7c48f4bed6 100644 --- a/apps/web/app/azure-devops/azure-devops-page-client.tsx +++ b/apps/web/app/azure-devops/azure-devops-page-client.tsx @@ -56,6 +56,7 @@ import type { AzureDevOpsSavedView, AzureDevOpsWorkItem, } from "@/lib/types/azure-devops"; +import { Trans, useTranslation } from "react-i18next"; const PAGE_SIZE = 25; const WORK_ITEMS_MODE: AzureDevOpsBrowseMode = "work-items"; @@ -89,27 +90,31 @@ function NotConfigured({ workspaceId }: { workspaceId?: string }) {
- Azure DevOps is not connected for this workspace.{" "} - - Configure Azure DevOps - + + Azure DevOps is not connected for this workspace.{" "} + +
); } -const RESULT_LABELS: Record = { - [WORK_ITEMS_MODE]: "Work items", - [PULL_REQUESTS_MODE]: "Pull requests", - [BOARD_MODE]: "Board", +// Keys, not text: resolving at module scope would freeze these at the boot locale. +const RESULT_LABEL_KEYS: Record = { + [WORK_ITEMS_MODE]: "azuredevops:workItems", + [PULL_REQUESTS_MODE]: "azuredevops:pullRequests", + [BOARD_MODE]: "azuredevops:board", }; function ResultHeader({ mode, count }: { mode: AzureDevOpsBrowseMode; count: number }) { + const { t } = useTranslation(); return (
-

{RESULT_LABELS[mode]}

- {count} results +

{t(RESULT_LABEL_KEYS[mode])}

+ + {t("azuredevops:resultsCount", { count })} +
); } @@ -553,11 +558,12 @@ function MobileFilters({ state: PageState; filterProps: Omit, "idSuffix">; }) { + const { t } = useTranslation(); return ( - Azure DevOps filters + {t("azuredevops:azureDevopsFilters")} @@ -567,6 +573,7 @@ function MobileFilters({ // eslint-disable-next-line max-lines-per-function -- the page content owns the responsive browse composition. function AzureDevOpsPageContent({ workspaceId, workflows, steps, repositories }: PageProps) { + const { t } = useTranslation(); const state = useAzureDevOpsPageState(workspaceId); if (!state.preferencesLoaded) return null; @@ -589,7 +596,9 @@ function AzureDevOpsPageContent({ workspaceId, workflows, steps, repositories }:
} actions={
@@ -159,7 +166,7 @@ function IssueRow({ onClick={() => onStartTask(issue)} > - Start task + {t("linear:startTask")}
@@ -183,12 +190,13 @@ function FilterControls({ setAssigned: (v: string) => void; teams: LinearTeam[]; }) { + const { t } = useTranslation(); return (
setQuery(e.target.value)} className="pl-8" @@ -199,10 +207,10 @@ function FilterControls({ onValueChange={(v) => setTeamKey(v === "__all__" ? "" : v)} > - + - All teams + {t("linear:allTeams")} {teams.map((t) => ( {t.name} @@ -218,9 +226,9 @@ function FilterControls({ - Any assignee - Assigned to me - Unassigned + {t("linear:anyAssignee")} + {t("linear:assignedToMe")} + {t("linear:unassigned")}
@@ -242,6 +250,7 @@ function PaginationBar({ onNext: () => void; onPrev: () => void; }) { + const { t } = useTranslation(); if (page === 1 && isLast) return null; const start = (page - 1) * pageSize + 1; const end = (page - 1) * pageSize + itemCount; @@ -250,7 +259,7 @@ function PaginationBar({ return (
- {itemCount === 0 ? "No results" : `${start}–${end}`} + {itemCount === 0 ? t("linear:noResults") : `${start}–${end}`}
@@ -266,7 +275,9 @@ function PaginationBar({ /> - Page {page} + + {t("linear:page")} {page} + - Linear integration is disabled.{" "} + {t("linear:integrationDisabled")}{" "} - Re-enable it in settings + {t("linear:reEnableItInSettings")} . @@ -324,6 +336,7 @@ function ResultsArea({ onOpen: (issue: LinearIssue) => void; onStartTask: (issue: LinearIssue) => void; }) { + const { t } = useTranslation(); return (
{search.error && !search.loading && ( @@ -333,14 +346,16 @@ function ResultsArea({ )} {!search.error && empty && (
- No issues match your filters. + {t("linear:noIssuesMatchYourFilters")}
)} {search.items.map((issue) => ( ))} {search.loading && search.items.length === 0 && ( -
Loading issues…
+
+ {t("linear:loadingIssues")} +
)}
); diff --git a/apps/web/components/azure-devops/azure-devops-board.tsx b/apps/web/components/azure-devops/azure-devops-board.tsx index d2211bfaf3..c75e6167ca 100644 --- a/apps/web/components/azure-devops/azure-devops-board.tsx +++ b/apps/web/components/azure-devops/azure-devops-board.tsx @@ -20,6 +20,7 @@ import type { } from "@/lib/types/azure-devops"; import { groupAzureDevOpsBoardItems } from "./azure-devops-board-view"; import { AzureDevOpsWorkItemDetail } from "./azure-devops-work-item-detail"; +import { useTranslation } from "react-i18next"; type Props = { workspaceId?: string; @@ -41,6 +42,7 @@ function BoardCard({ onOpen: () => void; onDragStart: () => void; }) { + const { t } = useTranslation(); const pointerStart = useRef<{ x: number; y: number } | null>(null); const handlePointerDown = (event: PointerEvent) => { pointerStart.current = { x: event.clientX, y: event.clientY }; @@ -71,7 +73,7 @@ function BoardCard({ - {item.title || "Untitled work item"} + {item.title || t("azuredevops:untitledWorkItem")} @@ -97,6 +99,7 @@ function MobileColumnNavigator({ counts: Map; onChange: (columnId: string) => void; }) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); const index = Math.max( 0, @@ -114,7 +117,7 @@ function MobileColumnNavigator({ variant="outline" size="icon" className="h-11 w-11 cursor-pointer" - aria-label="Previous board column" + aria-label={t("azuredevops:previousBoardColumn")} disabled={index === 0} onClick={() => onChange(columns[index - 1].id)} > @@ -127,7 +130,7 @@ function MobileColumnNavigator({ data-testid="azure-board-column-picker" onClick={() => setOpen(true)} > - {column?.name ?? "Board column"} + {column?.name ?? t("azuredevops:boardColumn")} {counts.get(column?.id ?? "")?.length ?? 0} @@ -136,7 +139,7 @@ function MobileColumnNavigator({ variant="outline" size="icon" className="h-11 w-11 cursor-pointer" - aria-label="Next board column" + aria-label={t("azuredevops:nextBoardColumn")} disabled={index >= columns.length - 1} onClick={() => onChange(columns[index + 1].id)} > @@ -145,7 +148,7 @@ function MobileColumnNavigator({ - Board columns + {t("azuredevops:boardColumns")}
{columns.map((candidate) => ( @@ -189,11 +192,12 @@ function BoardSelectors({ boards: Array<{ id: string; name: string }>; onBoardChange: (value: string) => void; }) { + const { t } = useTranslation(); return (
- + {projects.map((project) => ( @@ -82,18 +84,21 @@ function PullRequestFilters({ onChange, compact, }: Pick) { + const { t } = useTranslation(); const [advanced, setAdvanced] = useState(false); return ( <> - + - + - + - + @@ -180,11 +190,14 @@ function WorkItemFilters({ onChange, compact, }: Pick) { + const { t } = useTranslation(); const [advanced, setAdvanced] = useState(false); return ( <> - + {error && (

@@ -74,7 +80,7 @@ function SaveViewForm({ disabled={saving} onClick={onClose} > - Cancel + {t("common:cancel")} diff --git a/apps/web/components/azure-devops/azure-devops-work-item-detail.tsx b/apps/web/components/azure-devops/azure-devops-work-item-detail.tsx index e05c3c8b6a..c14a6f7021 100644 --- a/apps/web/components/azure-devops/azure-devops-work-item-detail.tsx +++ b/apps/web/components/azure-devops/azure-devops-work-item-detail.tsx @@ -27,6 +27,7 @@ import type { AzureDevOpsWorkItem, } from "@/lib/types/azure-devops"; import { markdownComponents, remarkPlugins } from "@/components/shared/markdown-components"; +import { useTranslation } from "react-i18next"; type BoardContext = { board: AzureDevOpsBoard; @@ -50,17 +51,24 @@ type Props = { onStartTask?: (item: AzureDevOpsWorkItem, action?: AzureDevOpsActionPreset) => void; }; -export function azureDevOpsDetailFields(item: AzureDevOpsWorkItem): Array<[string, string]> { +export function azureDevOpsDetailFields( + item: AzureDevOpsWorkItem, + t: (key: string, values?: Record) => string, +): Array<[string, string]> { + // Row labels are copy; the values are provider data, with copy fallbacks when + // Azure DevOps returns nothing. return [ - ["Type", item.type || "Work item"], - ["State", item.state || "Unknown"], - ["Assignee", item.assignedTo || "Unassigned"], - ...(item.areaPath ? [["Area", item.areaPath] as [string, string]] : []), + [t("azuredevops:fieldType"), item.type || t("azuredevops:fallbackWorkItem")], + [t("azuredevops:fieldState"), item.state || t("azuredevops:fallbackUnknown")], + [t("azuredevops:fieldAssignee"), item.assignedTo || t("azuredevops:fallbackUnassigned")], + ...(item.areaPath ? [[t("azuredevops:fieldArea"), item.areaPath] as [string, string]] : []), ]; } function Description({ value }: { value: string }) { - if (!value.trim()) return

No description.

; + const { t } = useTranslation(); + if (!value.trim()) + return

{t("azuredevops:noDescription")}

; return (
void; }) { + const { t } = useTranslation(); return (
{assignedTo && ( )}
@@ -128,6 +137,7 @@ function BoardActions({ columnDone?: boolean, ) => Promise; }) { + const { t } = useTranslation(); const [currentItem, setCurrentItem] = useState(context.item); const [columnId, setColumnId] = useState(context.item.columnId); const [columnDone, setColumnDone] = useState(context.item.columnDone); @@ -139,7 +149,7 @@ function BoardActions({ const selected = context.board.columns.find((column) => column.id === columnId); return (
- +
)} @@ -183,7 +193,7 @@ function BoardActions({ }) } > - Move + {t("azuredevops:move")}
@@ -199,10 +209,11 @@ function QuickActions({ item: AzureDevOpsWorkItem; onStartTask?: (item: AzureDevOpsWorkItem, action?: AzureDevOpsActionPreset) => void; }) { + const { t } = useTranslation(); if (!onStartTask || actions.length === 0) return null; return (
- +
{actions.map((action) => ( @@ -285,7 +297,7 @@ function DetailBody({ )}
- {azureDevOpsDetailFields(item).map(([label, value]) => ( + {azureDevOpsDetailFields(item, t).map(([label, value]) => (
{label}
{value}
@@ -302,12 +314,12 @@ function DetailBody({
)}
-

Description

+

{t("azuredevops:description")}

{planningFields.length > 0 && (
-

Planning and effort

+

{t("azuredevops:planningAndEffort")}

{planningFields.map((field) => (
@@ -319,7 +331,7 @@ function DetailBody({
)}
-

Assignment

+

{t("azuredevops:assignment")}

-

Discussion

+

{t("azuredevops:discussion")}

{state.commentsError &&

{state.commentsError}

} {state.comments.map((comment) => (
- {comment.author.displayName || comment.author.uniqueName || "Azure DevOps"} + {comment.author.displayName || + comment.author.uniqueName || + t("azuredevops:fallbackAuthor")}
{comment.content}
@@ -381,20 +395,22 @@ function DetailBody({ onClick={state.loadOlderComments} disabled={state.commentsLoading} > - {state.commentsLoading ? "Loading…" : "Load older discussion"} + {state.commentsLoading + ? t("azuredevops:loading2") + : t("azuredevops:loadOlderDiscussion")} )} {!state.commentsLoading && !state.commentsError && state.comments.length === 0 && ( -

No discussion yet.

+

{t("azuredevops:noDiscussionYet")}

)}
-

Linked Kandev tasks

+

{t("azuredevops:linkedKandevTasks")}

{state.linkedTasksLoading && ( -

Loading linked tasks…

+

{t("azuredevops:loadingLinkedTasks")}

)} {!state.linkedTasksLoading && state.linkedTasks.length === 0 && ( -

No linked tasks.

+

{t("azuredevops:noLinkedTasks")}

)} {state.linkedTasks.map((task) => (
@@ -417,9 +433,10 @@ export function AzureDevOpsWorkItemDetail({ quickActions = [], onStartTask, }: Props) { + const { t } = useTranslation(); const { isMobile } = useResponsiveBreakpoint(); const state = useAzureDevOpsWorkItemDetail(workspaceId, projectId, initialItem, open); - const title = state.item?.title ?? initialItem?.title ?? "Work item"; + const title = state.item?.title ?? initialItem?.title ?? t("azuredevops:fallbackWorkItem"); const header = (
@@ -443,7 +460,7 @@ export function AzureDevOpsWorkItemDetail({ href={state.item?.webUrl ?? initialItem?.webUrl} target="_blank" rel="noreferrer" - aria-label="Open work item in Azure DevOps" + aria-label={t("azuredevops:openWorkItemInAzureDevops2")} > @@ -454,7 +471,7 @@ export function AzureDevOpsWorkItemDetail({ variant="ghost" size="icon" className="min-h-11 min-w-11 cursor-pointer" - aria-label="Close work item details" + aria-label={t("azuredevops:closeWorkItemDetails")} data-testid="azure-work-item-detail-close" onClick={() => onOpenChange(false)} > @@ -491,7 +508,7 @@ export function AzureDevOpsWorkItemDetail({ {title} - Azure DevOps work-item details + {t("azuredevops:azureDevopsWorkItemDetails")}
{header} diff --git a/apps/web/components/integrations/auth-error-message.tsx b/apps/web/components/integrations/auth-error-message.tsx index 717efc964d..a0e97bdfef 100644 --- a/apps/web/components/integrations/auth-error-message.tsx +++ b/apps/web/components/integrations/auth-error-message.tsx @@ -3,6 +3,7 @@ import Link from "@/components/routing/app-link"; import { IconLockExclamation } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; +import { useTranslation } from "react-i18next"; // Drops support URLs upstream APIs sometimes inline into error response // bodies — they're noise once the user has a clear CTA. Shared because both @@ -43,17 +44,20 @@ export function IntegrationAuthErrorMessage({ authErrorBody, compact, }: IntegrationAuthErrorMessageProps) { + const { t } = useTranslation(); const isAuth = isAuthError(error); if (compact) { return (
- {isAuth ? `${name} authentication required.` : cleanIntegrationErrorMessage(error)} + {isAuth + ? t("integrations:authRequiredInline", { name }) + : cleanIntegrationErrorMessage(error)} {isAuth && ( )}
@@ -66,11 +70,13 @@ export function IntegrationAuthErrorMessage({ <>
-

{name} authentication required

+

+ {t("integrations:authRequiredHeading", { name })} +

{authErrorBody}

) : ( diff --git a/apps/web/components/jira/jira-import-bar.tsx b/apps/web/components/jira/jira-import-bar.tsx index 4b9800a044..565e95ec76 100644 --- a/apps/web/components/jira/jira-import-bar.tsx +++ b/apps/web/components/jira/jira-import-bar.tsx @@ -6,6 +6,7 @@ import type { JiraTicket } from "@/lib/types/jira"; import { JIRA_KEY_RE } from "./jira-ticket-common"; import { useJiraAvailable } from "@/hooks/domains/jira/use-jira-availability"; import { ValidatedPopover } from "@/components/integrations/validated-popover"; +import { useTranslation } from "react-i18next"; type JiraImportBarProps = { workspaceId: string | null; @@ -14,6 +15,7 @@ type JiraImportBarProps = { }; export function JiraImportBar({ workspaceId, disabled, onImport }: JiraImportBarProps) { + const { t } = useTranslation(); const available = useJiraAvailable(workspaceId); if (!available || !workspaceId) return null; @@ -21,19 +23,19 @@ export function JiraImportBar({ workspaceId, disabled, onImport }: JiraImportBar } - triggerAriaLabel="Import from Jira" + triggerAriaLabel={t("jira:importFromJira")} triggerDisabled={disabled} testIdPrefix="jira-import" - tooltip="Import from Jira ticket URL or key" + tooltip={t("jira:importFromJiraTicketUrlOr")} align="start" - headline="Import Jira ticket" - placeholder="PROJ-123 or paste ticket URL" + headline={t("jira:importJiraTicket")} + placeholder={t("jira:proj123OrPasteTicketUrl")} extractKey={(raw) => raw.toUpperCase().match(JIRA_KEY_RE)?.[0] ?? null} - validationHint="Paste a Jira ticket URL or key (PROJ-123)" + validationHint={t("jira:pasteAJiraTicketUrlOr")} fetch={(key) => getJiraTicket(key, { workspaceId })} onSuccess={(_key, ticket) => onImport(ticket)} - submitLabel="Import" - submittingLabel="Loading..." + submitLabel={t("jira:import")} + submittingLabel={t("jira:loading")} /> ); } diff --git a/apps/web/components/jira/jira-ticket-button.tsx b/apps/web/components/jira/jira-ticket-button.tsx index 593eeed1b1..3885da3a82 100644 --- a/apps/web/components/jira/jira-ticket-button.tsx +++ b/apps/web/components/jira/jira-ticket-button.tsx @@ -6,6 +6,7 @@ import { Button } from "@kandev/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import { JiraTicketDialog } from "./jira-ticket-dialog"; import { extractJiraKey } from "./jira-ticket-common"; +import { useTranslation } from "react-i18next"; export { extractJiraKey }; @@ -17,6 +18,7 @@ type JiraTicketButtonProps = { // JiraTicketButton sits in the task top bar. It extracts a Jira key from the // task title and opens a full ticket dialog on click. export function JiraTicketButton({ workspaceId, taskTitle }: JiraTicketButtonProps) { + const { t } = useTranslation(); const ticketKey = extractJiraKey(taskTitle); const [open, setOpen] = useState(false); @@ -36,7 +38,9 @@ export function JiraTicketButton({ workspaceId, taskTitle }: JiraTicketButtonPro {ticketKey} - Open Jira ticket {ticketKey} + + {t("jira:openJiraTicket")} {ticketKey} + Unassigned; + const { t } = useTranslation(); + if (!name) return {t("jira:unassigned")}; return ( <> @@ -160,13 +162,14 @@ type JiraErrorMessageProps = { }; export function JiraErrorMessage({ error, compact }: JiraErrorMessageProps) { + const { t } = useTranslation(); return ( ); diff --git a/apps/web/components/jira/jira-ticket-dialog.tsx b/apps/web/components/jira/jira-ticket-dialog.tsx index a4002e2051..73f4d0b12a 100644 --- a/apps/web/components/jira/jira-ticket-dialog.tsx +++ b/apps/web/components/jira/jira-ticket-dialog.tsx @@ -21,6 +21,7 @@ import { type TicketState, } from "./jira-ticket-common"; import type { JiraTaskPreset } from "./my-jira/presets"; +import { useTranslation } from "react-i18next"; type JiraTicketDialogProps = { open: boolean; @@ -35,8 +36,12 @@ type JiraTicketDialogProps = { onStartTask?: (ticket: JiraTicket, preset: JiraTaskPreset) => void; }; -function dialogTitle(ticket: JiraTicket | null, ticketKey: string | null | undefined): string { - return ticket?.summary ?? ticketKey ?? "Jira ticket"; +function dialogTitle( + ticket: JiraTicket | null, + ticketKey: string | null | undefined, + t: (key: string, values?: Record) => string, +): string { + return ticket?.summary ?? ticketKey ?? t("jira:fallbackTicket"); } function effectiveTicketKey( @@ -55,6 +60,7 @@ export function JiraTicketDialog({ presets, onStartTask, }: JiraTicketDialogProps) { + const { t } = useTranslation(); const enabled = open && !!workspaceId && !!ticketKey; const state = useTicketState(workspaceId ?? "", ticketKey ?? "", enabled); const ticket = state.ticket ?? initialTicket ?? null; @@ -65,7 +71,7 @@ export function JiraTicketDialog({ return ( - {dialogTitle(ticket, ticketKey)} + {dialogTitle(ticket, ticketKey, t)} {errorOnly ? (
@@ -101,6 +107,7 @@ function DialogBody({ state: TicketState; ticketKey: string; }) { + const { t } = useTranslation(); return (
{state.error && ticket && ( @@ -109,7 +116,9 @@ function DialogBody({
)} {!ticket && state.loading && ( -
Loading ticket…
+
+ {t("jira:loadingTicket")} +
)} {ticket && }
@@ -125,13 +134,14 @@ function TicketFooter({ presets: JiraTaskPreset[]; onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void; }) { + const { t } = useTranslation(); return (
@@ -164,6 +174,7 @@ type TopBarProps = { }; function TicketTopBar({ ticket, loading, onRefresh }: TopBarProps) { + const { t } = useTranslation(); return (
{ticket?.url && ( @@ -172,7 +183,7 @@ function TicketTopBar({ ticket, loading, onRefresh }: TopBarProps) { variant="ghost" size="icon-sm" className="cursor-pointer" - title="Open in Atlassian" + title={t("jira:openInAtlassian")} > @@ -185,7 +196,7 @@ function TicketTopBar({ ticket, loading, onRefresh }: TopBarProps) { className="cursor-pointer" onClick={onRefresh} disabled={loading} - title="Refresh" + title={t("jira:refresh")} > @@ -244,14 +255,15 @@ function SectionHeading({ children }: { children: React.ReactNode }) { } function DescriptionSection({ description }: { description: string }) { + const { t } = useTranslation(); return (
- Description + {t("jira:description")}
{description ? (
{description}
) : ( -
No description.
+
{t("jira:noDescription")}
)}
@@ -301,22 +313,23 @@ function StatusBlock({ ticket, transitions, pending, onTransition }: StatusBlock } function DetailsCard({ ticket }: { ticket: JiraTicket }) { + const { t } = useTranslation(); return (
- Details + {t("jira:details")}
- + - + - + - + {ticket.projectKey}
@@ -334,11 +347,12 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo } function MetaFooter({ ticket }: { ticket: JiraTicket }) { + const { t } = useTranslation(); const updated = formatRelative(ticket.updated); if (!updated) return null; return (
- Updated {updated} + {t("jira:updatedTime", { time: updated })}
); } diff --git a/apps/web/components/jira/my-jira/filter-bar.tsx b/apps/web/components/jira/my-jira/filter-bar.tsx index 56340d899c..54b4dd0380 100644 --- a/apps/web/components/jira/my-jira/filter-bar.tsx +++ b/apps/web/components/jira/my-jira/filter-bar.tsx @@ -1,5 +1,6 @@ "use client"; +import { useTranslation } from "react-i18next"; import { Button } from "@kandev/ui/button"; import type { JiraProject, JiraStatus } from "@/lib/types/jira"; import { AssigneePill, ProjectPill, StatusPill } from "./filter-pills"; @@ -23,6 +24,7 @@ export function FilterBar({ hasActiveFilters, onClear, }: FilterBarProps) { + const { t } = useTranslation(); return (
- Clear filters + {t("jira:clearFilters")} )}
diff --git a/apps/web/components/jira/my-jira/filter-pills.tsx b/apps/web/components/jira/my-jira/filter-pills.tsx index da62a63eee..f43a092722 100644 --- a/apps/web/components/jira/my-jira/filter-pills.tsx +++ b/apps/web/components/jira/my-jira/filter-pills.tsx @@ -8,8 +8,18 @@ import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; import type { JiraProject, JiraStatus } from "@/lib/types/jira"; import { type AssigneeFilter } from "./filter-model"; +import { useTranslation } from "react-i18next"; type PillShellProps = { + /** + * Stable pill identifier, never display copy. The `data-testid` was built as + * `jira-filter-pill-${label.toLowerCase()}`, so translating the label moved + * the selector with it — and `e2e/tests/integrations/jira-default-project.spec.ts` + * selects `jira-filter-pill-project` and `-status` by name, so this was a live + * break, not a hypothetical one. apps/web/AGENTS.md: a `data-testid` is never + * translated, and deriving one from copy translates it by the back door. + */ + id: string; label: string; summary: string | null; active: boolean; @@ -23,21 +33,23 @@ type PillShellProps = { }; function DisabledPill({ + id, label, summary, disabledHint, -}: Pick) { +}: Pick) { + const { t } = useTranslation(); // Radix Tooltip on a focusable wrapper (per apps/web/AGENTS.md) so keyboard // and screen-reader users reach the disabled pill and learn why it's off, // instead of the reason being mouse-hover-only via a raw title attribute. const pill = (
@@ -57,6 +69,7 @@ function DisabledPill({ } function PillShell({ + id, label, summary, active, @@ -65,9 +78,10 @@ function PillShell({ disabledHint, children, }: PillShellProps) { + const { t } = useTranslation(); const [open, setOpen] = useState(false); if (disabled) { - return ; + return ; } return ( @@ -79,7 +93,7 @@ function PillShell({ @@ -117,6 +131,7 @@ type ProjectPillProps = { }; export function ProjectPill({ projects, value, onChange }: ProjectPillProps) { + const { t } = useTranslation(); const [query, setQuery] = useState(""); const filtered = useMemo(() => { const q = query.trim().toLowerCase(); @@ -134,7 +149,8 @@ export function ProjectPill({ projects, value, onChange }: ProjectPillProps) { return ( 0 ? joinSummary(value, 2) : null} active={value.length > 0} onClear={() => onChange([])} @@ -143,13 +159,13 @@ export function ProjectPill({ projects, value, onChange }: ProjectPillProps) { setQuery(e.target.value)} - placeholder="Search projects…" + placeholder={t("jira:searchProjects")} className="h-7 text-xs" />
{filtered.length === 0 && ( -
No projects match.
+
{t("jira:noProjectsMatch")}
)} {filtered.map((p) => (