feat(i18n): localize the Azure DevOps, Jira, Linear and Sentry task surfaces (#2284)
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 }) {
|
||||
<div className="max-w-2xl p-6">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Azure DevOps is not connected for this workspace.{" "}
|
||||
<Link href={href} className="cursor-pointer font-medium underline">
|
||||
Configure Azure DevOps
|
||||
</Link>
|
||||
<Trans i18nKey="azuredevops:notConnectedNotice">
|
||||
Azure DevOps is not connected for this workspace.{" "}
|
||||
<Link href={href} className="cursor-pointer font-medium underline" />
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const RESULT_LABELS: Record<AzureDevOpsBrowseMode, string> = {
|
||||
[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<AzureDevOpsBrowseMode, string> = {
|
||||
[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 (
|
||||
<div className="flex min-h-12 items-center justify-between border-b px-4">
|
||||
<h2 className="text-sm font-semibold">{RESULT_LABELS[mode]}</h2>
|
||||
<span className="text-xs text-muted-foreground">{count} results</span>
|
||||
<h2 className="text-sm font-semibold">{t(RESULT_LABEL_KEYS[mode])}</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("azuredevops:resultsCount", { count })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -553,11 +558,12 @@ function MobileFilters({
|
||||
state: PageState;
|
||||
filterProps: Omit<ComponentProps<typeof AzureDevOpsFilters>, "idSuffix">;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Sheet open={state.mobileFiltersOpen} onOpenChange={state.setMobileFiltersOpen}>
|
||||
<SheetContent side="left" className="w-80 max-w-[90vw] overflow-y-auto">
|
||||
<SheetHeader className="mb-5 text-left">
|
||||
<SheetTitle>Azure DevOps filters</SheetTitle>
|
||||
<SheetTitle>{t("azuredevops:azureDevopsFilters")}</SheetTitle>
|
||||
</SheetHeader>
|
||||
<AzureDevOpsFilters {...filterProps} idSuffix="-mobile" />
|
||||
</SheetContent>
|
||||
@@ -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 }:
|
||||
<main className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<PageTopbar
|
||||
title="Azure DevOps"
|
||||
subtitle={`${state.connection.data.organizationUrl} · Boards and Repos`}
|
||||
subtitle={t("azuredevops:boardsAndRepos", {
|
||||
organizationUrl: state.connection.data.organizationUrl,
|
||||
})}
|
||||
icon={<AzureDevOpsIcon className="h-4 w-4" />}
|
||||
actions={
|
||||
<Button
|
||||
@@ -598,7 +607,7 @@ function AzureDevOpsPageContent({ workspaceId, workflows, steps, repositories }:
|
||||
size="icon-lg"
|
||||
onClick={() => state.setMobileFiltersOpen(true)}
|
||||
className="cursor-pointer md:hidden"
|
||||
aria-label="Open Azure DevOps filters"
|
||||
aria-label={t("azuredevops:openAzureDevopsFilters")}
|
||||
data-testid="azure-devops-mobile-filter-button"
|
||||
>
|
||||
<IconAdjustments className="h-4 w-4" />
|
||||
|
||||
@@ -32,6 +32,7 @@ import { ResultsPagination } from "@/components/jira/my-jira/results-pagination"
|
||||
import { JqlEditor } from "@/components/jira/my-jira/jql-editor";
|
||||
import { useJiraTaskPresets } from "@/components/jira/my-jira/use-task-presets";
|
||||
import type { JiraTaskPreset } from "@/components/jira/my-jira/presets";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
type JiraPageClientProps = {
|
||||
workspaceId?: string;
|
||||
@@ -44,11 +45,14 @@ function NotConfiguredNotice() {
|
||||
<div className="p-6 max-w-2xl">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Jira is not configured.{" "}
|
||||
<Link href="/settings/integrations/jira" className="underline font-medium cursor-pointer">
|
||||
Configure Jira
|
||||
</Link>{" "}
|
||||
to see your tickets here.
|
||||
<Trans i18nKey="jira:notConfiguredNotice">
|
||||
Jira is not configured.{" "}
|
||||
<Link
|
||||
href="/settings/integrations/jira"
|
||||
className="underline font-medium cursor-pointer"
|
||||
/>{" "}
|
||||
to see your tickets here.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
@@ -134,6 +138,7 @@ function TicketResults({
|
||||
onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void;
|
||||
onOpen: (ticket: JiraTicket) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex justify-center py-16">
|
||||
@@ -142,7 +147,11 @@ function TicketResults({
|
||||
);
|
||||
}
|
||||
if (!loading && items.length === 0) {
|
||||
return <div className="text-sm text-muted-foreground py-8 text-center">No tickets found.</div>;
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground py-8 text-center">
|
||||
{t("jira:noTicketsFound")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
@@ -316,6 +325,7 @@ function useFilterState(defaultProjectKey: string) {
|
||||
}
|
||||
|
||||
export function JiraPageClient({ workspaceId, workflows, steps }: JiraPageClientProps) {
|
||||
const { t } = useTranslation();
|
||||
const { loaded, configured, projects, defaultProjectKey } = useJiraPageData(workspaceId);
|
||||
const { taskPresets } = useJiraTaskPresets();
|
||||
const [launchPayload, setLaunchPayload] = useState<JiraLaunchPayload | null>(null);
|
||||
@@ -331,10 +341,12 @@ export function JiraPageClient({ workspaceId, workflows, steps }: JiraPageClient
|
||||
<div className="flex h-full min-h-0 w-full flex-col bg-background">
|
||||
<PageTopbar
|
||||
title="Jira"
|
||||
subtitle="Tickets across your Atlassian projects."
|
||||
subtitle={t("jira:ticketsAcrossYourAtlassianProjects")}
|
||||
icon={<IconTicket className="h-4 w-4" />}
|
||||
/>
|
||||
{!loaded && <div className="p-6 text-sm text-muted-foreground">Checking Jira status…</div>}
|
||||
{!loaded && (
|
||||
<div className="p-6 text-sm text-muted-foreground">{t("jira:checkingJiraStatus")}</div>
|
||||
)}
|
||||
{loaded && !configured && <NotConfiguredNotice />}
|
||||
{loaded && configured && (
|
||||
<AuthenticatedView
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
} from "@/components/linear/use-linear-issue-search";
|
||||
import type { LinearIssue, LinearTeam } from "@/lib/types/linear";
|
||||
import type { Workflow, WorkflowStep } from "@/lib/types/http";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
|
||||
type LinearPageClientProps = {
|
||||
workspaceId?: string;
|
||||
@@ -44,14 +45,14 @@ function NotConfiguredNotice() {
|
||||
<div className="p-6 max-w-2xl">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Linear is not configured.{" "}
|
||||
<Link
|
||||
href="/settings/integrations/linear"
|
||||
className="underline font-medium cursor-pointer"
|
||||
>
|
||||
Configure Linear
|
||||
</Link>{" "}
|
||||
to see your issues here.
|
||||
<Trans i18nKey="linear:notConfiguredNotice">
|
||||
Linear is not configured.{" "}
|
||||
<Link
|
||||
href="/settings/integrations/linear"
|
||||
className="underline font-medium cursor-pointer"
|
||||
/>{" "}
|
||||
to see your issues here.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
@@ -97,8 +98,9 @@ function useLinearPageData(workspaceId?: string) {
|
||||
}
|
||||
|
||||
function AssigneeCell({ issue }: { issue: LinearIssue }) {
|
||||
const { t } = useTranslation();
|
||||
if (!issue.assigneeName) {
|
||||
return <span className="text-xs text-muted-foreground">Unassigned</span>;
|
||||
return <span className="text-xs text-muted-foreground">{t("linear:unassigned")}</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
@@ -120,6 +122,7 @@ function IssueRow({
|
||||
onOpen: (i: LinearIssue) => void;
|
||||
onStartTask: (i: LinearIssue) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const relative = formatRelative(issue.updated);
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-3 border-b last:border-b-0">
|
||||
@@ -127,7 +130,7 @@ function IssueRow({
|
||||
type="button"
|
||||
onClick={() => onOpen(issue)}
|
||||
className="flex-1 min-w-0 space-y-1 text-left cursor-pointer rounded -mx-2 px-2 py-1 hover:bg-muted/50 transition-colors"
|
||||
title="Open issue details"
|
||||
title={t("linear:openIssueDetails")}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{issue.identifier}</span>
|
||||
@@ -143,12 +146,16 @@ function IssueRow({
|
||||
</Badge>
|
||||
)}
|
||||
<AssigneeCell issue={issue} />
|
||||
{relative && <span className="text-xs text-muted-foreground">· updated {relative}</span>}
|
||||
{relative && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("linear:updatedAgo", { time: relative })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button asChild variant="ghost" size="icon-sm" className="cursor-pointer">
|
||||
<a href={issue.url} target="_blank" rel="noreferrer" title="Open in Linear">
|
||||
<a href={issue.url} target="_blank" rel="noreferrer" title={t("linear:openInLinear")}>
|
||||
<IconExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
@@ -159,7 +166,7 @@ function IssueRow({
|
||||
onClick={() => onStartTask(issue)}
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
Start task
|
||||
{t("linear:startTask")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -183,12 +190,13 @@ function FilterControls({
|
||||
setAssigned: (v: string) => void;
|
||||
teams: LinearTeam[];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 px-6 py-3 border-b">
|
||||
<div className="relative flex-1 min-w-[280px]">
|
||||
<IconSearch className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search by ID, title, or description"
|
||||
placeholder={t("linear:searchByIdTitleOrDescription")}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-8"
|
||||
@@ -199,10 +207,10 @@ function FilterControls({
|
||||
onValueChange={(v) => setTeamKey(v === "__all__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder="All teams" />
|
||||
<SelectValue placeholder={t("linear:allTeams")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All teams</SelectItem>
|
||||
<SelectItem value="__all__">{t("linear:allTeams")}</SelectItem>
|
||||
{teams.map((t) => (
|
||||
<SelectItem key={t.id} value={t.key}>
|
||||
{t.name}
|
||||
@@ -218,9 +226,9 @@ function FilterControls({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__any__">Any assignee</SelectItem>
|
||||
<SelectItem value="me">Assigned to me</SelectItem>
|
||||
<SelectItem value="unassigned">Unassigned</SelectItem>
|
||||
<SelectItem value="__any__">{t("linear:anyAssignee")}</SelectItem>
|
||||
<SelectItem value="me">{t("linear:assignedToMe")}</SelectItem>
|
||||
<SelectItem value="unassigned">{t("linear:unassigned")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t shrink-0">
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{itemCount === 0 ? "No results" : `${start}–${end}`}
|
||||
{itemCount === 0 ? t("linear:noResults") : `${start}–${end}`}
|
||||
</div>
|
||||
<Pagination className="mx-0 w-auto justify-end">
|
||||
<PaginationContent>
|
||||
@@ -266,7 +275,9 @@ function PaginationBar({
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<span className="px-3 text-sm tabular-nums">Page {page}</span>
|
||||
<span className="px-3 text-sm tabular-nums">
|
||||
{t("linear:page")} {page}
|
||||
</span>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
@@ -295,16 +306,17 @@ function PageShell({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function DisabledNotice() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="p-6 max-w-2xl">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Linear integration is disabled.{" "}
|
||||
{t("linear:integrationDisabled")}{" "}
|
||||
<Link
|
||||
href="/settings/integrations/linear"
|
||||
className="underline font-medium cursor-pointer"
|
||||
>
|
||||
Re-enable it in settings
|
||||
{t("linear:reEnableItInSettings")}
|
||||
</Link>
|
||||
.
|
||||
</AlertDescription>
|
||||
@@ -324,6 +336,7 @@ function ResultsArea({
|
||||
onOpen: (issue: LinearIssue) => void;
|
||||
onStartTask: (issue: LinearIssue) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-6 py-3">
|
||||
{search.error && !search.loading && (
|
||||
@@ -333,14 +346,16 @@ function ResultsArea({
|
||||
)}
|
||||
{!search.error && empty && (
|
||||
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||
No issues match your filters.
|
||||
{t("linear:noIssuesMatchYourFilters")}
|
||||
</div>
|
||||
)}
|
||||
{search.items.map((issue) => (
|
||||
<IssueRow key={issue.id} issue={issue} onOpen={onOpen} onStartTask={onStartTask} />
|
||||
))}
|
||||
{search.loading && search.items.length === 0 && (
|
||||
<div className="text-sm text-muted-foreground py-12 text-center">Loading issues…</div>
|
||||
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||
{t("linear:loadingIssues")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<HTMLDivElement>) => {
|
||||
pointerStart.current = { x: event.clientX, y: event.clientY };
|
||||
@@ -71,7 +73,7 @@ function BoardCard({
|
||||
<CardHeader className="px-3 py-0">
|
||||
<CardTitle className="flex items-start gap-2 text-sm font-medium">
|
||||
<IconGripVertical className="mt-0.5 hidden h-4 w-4 shrink-0 text-muted-foreground md:block" />
|
||||
<span className="line-clamp-2">{item.title || "Untitled work item"}</span>
|
||||
<span className="line-clamp-2">{item.title || t("azuredevops:untitledWorkItem")}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 px-3 text-xs text-muted-foreground">
|
||||
@@ -97,6 +99,7 @@ function MobileColumnNavigator({
|
||||
counts: Map<string, AzureDevOpsBoardWorkItem[]>;
|
||||
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)}
|
||||
>
|
||||
<span>{column?.name ?? "Board column"}</span>
|
||||
<span>{column?.name ?? t("azuredevops:boardColumn")}</span>
|
||||
<span>{counts.get(column?.id ?? "")?.length ?? 0}</span>
|
||||
<IconList className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -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({
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerContent className="max-h-[min(32rem,calc(100dvh-16px-env(safe-area-inset-bottom,0px)))]">
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>Board columns</DrawerTitle>
|
||||
<DrawerTitle>{t("azuredevops:boardColumns")}</DrawerTitle>
|
||||
</DrawerHeader>
|
||||
<div className="min-h-0 overflow-y-auto p-4 pb-[env(safe-area-inset-bottom,0px)]">
|
||||
{columns.map((candidate) => (
|
||||
@@ -189,11 +192,12 @@ function BoardSelectors({
|
||||
boards: Array<{ id: string; name: string }>;
|
||||
onBoardChange: (value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={projectId} onValueChange={onProjectChange}>
|
||||
<SelectTrigger className="w-52" data-testid="azure-board-project-select">
|
||||
<SelectValue placeholder="Project" />
|
||||
<SelectValue placeholder={t("azuredevops:project")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((project) => (
|
||||
@@ -205,7 +209,7 @@ function BoardSelectors({
|
||||
</Select>
|
||||
<Select value={teamId} onValueChange={onTeamChange} disabled={!teams.length}>
|
||||
<SelectTrigger className="w-52" data-testid="azure-board-team-select">
|
||||
<SelectValue placeholder="Team" />
|
||||
<SelectValue placeholder={t("azuredevops:team")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teams.map((team) => (
|
||||
@@ -217,7 +221,7 @@ function BoardSelectors({
|
||||
</Select>
|
||||
<Select value={boardId} onValueChange={onBoardChange} disabled={!boards.length}>
|
||||
<SelectTrigger className="w-52" data-testid="azure-board-select">
|
||||
<SelectValue placeholder="Board" />
|
||||
<SelectValue placeholder={t("azuredevops:board")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{boards.map((board) => (
|
||||
@@ -295,8 +299,11 @@ function useBoardPresentation(
|
||||
}
|
||||
|
||||
function BoardEmptyState() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="p-6 text-sm text-muted-foreground">Select a project to view its board.</div>
|
||||
<div className="p-6 text-sm text-muted-foreground">
|
||||
{t("azuredevops:selectAProjectToViewIts")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -311,6 +318,7 @@ export function AzureDevOpsBoard({
|
||||
quickActions = [],
|
||||
onStartTask,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useResponsiveBreakpoint();
|
||||
const board = useAzureDevOpsBoard(workspaceId, projectId, initialPreference);
|
||||
const [editing, setEditing] = useState<AzureDevOpsBoardWorkItem | null>(null);
|
||||
@@ -338,7 +346,9 @@ export function AzureDevOpsBoard({
|
||||
<AlertDescription>{board.error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{board.loading && <div className="p-6 text-sm text-muted-foreground">Loading board…</div>}
|
||||
{board.loading && (
|
||||
<div className="p-6 text-sm text-muted-foreground">{t("azuredevops:loadingBoard")}</div>
|
||||
)}
|
||||
{!board.loading && board.snapshot && (
|
||||
<>
|
||||
{isMobile && (
|
||||
|
||||
@@ -10,38 +10,52 @@ import {
|
||||
} from "@kandev/ui/dialog";
|
||||
import { Separator } from "@kandev/ui/separator";
|
||||
import type { AzureDevOpsPullRequestFeedback } from "@/lib/types/azure-devops";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function voteLabel(vote: number): string {
|
||||
if (vote >= 10) return "Approved";
|
||||
if (vote >= 5) return "Approved with suggestions";
|
||||
if (vote <= -10) return "Rejected";
|
||||
if (vote <= -5) return "Waiting for author";
|
||||
return "No vote";
|
||||
/** `vote` is Azure DevOps' numeric vote; only the catalog key is copy. */
|
||||
function voteLabelKey(vote: number): string {
|
||||
if (vote >= 10) return "azuredevops:voteApproved";
|
||||
if (vote >= 5) return "azuredevops:voteApprovedWithSuggestions";
|
||||
if (vote <= -10) return "azuredevops:voteRejected";
|
||||
if (vote <= -5) return "azuredevops:voteWaitingForAuthor";
|
||||
return "azuredevops:voteNone";
|
||||
}
|
||||
|
||||
function Summary({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">Review: {feedback.reviewState || "pending"}</Badge>
|
||||
<Badge variant="outline">Policies: {feedback.policyState || "none"}</Badge>
|
||||
<Badge variant="secondary">{feedback.linkedWorkItems.length} linked items</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("azuredevops:reviewLabelled", {
|
||||
state: feedback.reviewState || t("azuredevops:pending"),
|
||||
})}
|
||||
</Badge>
|
||||
<Badge variant="outline">
|
||||
{t("azuredevops:policiesLabelled", {
|
||||
state: feedback.policyState || t("azuredevops:none"),
|
||||
})}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{t("azuredevops:linkedItemsCount", { count: feedback.linkedWorkItems.length })}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Reviewers({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Reviewers</h3>
|
||||
<h3 className="text-sm font-semibold">{t("azuredevops:reviewers")}</h3>
|
||||
{feedback.reviewers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No reviewers.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("azuredevops:noReviewers")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{feedback.reviewers.map((reviewer) => (
|
||||
<div key={reviewer.id} className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="min-w-0 truncate">{reviewer.displayName}</span>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
{voteLabel(reviewer.vote)}
|
||||
{t(voteLabelKey(reviewer.vote))}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
@@ -52,11 +66,12 @@ function Reviewers({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) {
|
||||
}
|
||||
|
||||
function Policies({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Branch policies</h3>
|
||||
<h3 className="text-sm font-semibold">{t("azuredevops:branchPolicies")}</h3>
|
||||
{feedback.policies.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No policy evaluations.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("azuredevops:noPolicyEvaluations")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{feedback.policies.map((policy) => (
|
||||
@@ -74,14 +89,15 @@ function Policies({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) {
|
||||
}
|
||||
|
||||
function Threads({ feedback }: { feedback: AzureDevOpsPullRequestFeedback }) {
|
||||
const { t } = useTranslation();
|
||||
const comments = feedback.threads.flatMap((thread) =>
|
||||
thread.comments.map((comment) => ({ ...comment, threadId: thread.id })),
|
||||
);
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Discussion</h3>
|
||||
<h3 className="text-sm font-semibold">{t("azuredevops:discussion")}</h3>
|
||||
{comments.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No comments.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("azuredevops:noComments")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{comments.map((comment) => (
|
||||
@@ -111,20 +127,29 @@ export function AzureDevOpsFeedbackDialog({
|
||||
feedback: AzureDevOpsPullRequestFeedback | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[85dvh] max-w-2xl overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="break-words">
|
||||
{feedback?.pullRequest.title ?? "Pull request feedback"}
|
||||
{feedback?.pullRequest.title ?? t("azuredevops:pullRequestFeedback")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{feedback
|
||||
? `${feedback.pullRequest.repositoryName} · PR ${feedback.pullRequest.id}`
|
||||
: "Azure DevOps review and policy state"}
|
||||
? // The repository name and id are provider data; the `PR` label
|
||||
// and the separator around them are not, so this is a message
|
||||
// with the data interpolated rather than a bare template.
|
||||
t("azuredevops:pullRequestSubtitle", {
|
||||
repository: feedback.pullRequest.repositoryName,
|
||||
id: feedback.pullRequest.id,
|
||||
})
|
||||
: t("azuredevops:azureDevopsReviewAndPolicyState")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{loading && <p className="text-sm text-muted-foreground">Loading feedback...</p>}
|
||||
{loading && (
|
||||
<p className="text-sm text-muted-foreground">{t("azuredevops:loadingFeedback")}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import type { AzureDevOpsProject, AzureDevOpsRepository } from "@/lib/types/azure-devops";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type AzureDevOpsBrowseMode = "board" | "work-items" | "pull-requests";
|
||||
|
||||
@@ -55,13 +56,14 @@ function ProjectFilter({
|
||||
onChange: (value: string) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const controlId = `azure-devops-filter-project${idSuffix}`;
|
||||
return (
|
||||
<Field compact={compact}>
|
||||
<Label htmlFor={controlId}>Project</Label>
|
||||
<Label htmlFor={controlId}>{t("azuredevops:project")}</Label>
|
||||
<Select value={value || undefined} onValueChange={onChange}>
|
||||
<SelectTrigger id={controlId} className="w-full">
|
||||
<SelectValue placeholder="Select project" />
|
||||
<SelectValue placeholder={t("azuredevops:selectProject")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((project) => (
|
||||
@@ -82,18 +84,21 @@ function PullRequestFilters({
|
||||
onChange,
|
||||
compact,
|
||||
}: Pick<FiltersProps, "idSuffix" | "filters" | "repositories" | "onChange" | "compact">) {
|
||||
const { t } = useTranslation();
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Field compact={compact}>
|
||||
<Label htmlFor={`azure-devops-filter-repository${idSuffix}`}>Repository</Label>
|
||||
<Label htmlFor={`azure-devops-filter-repository${idSuffix}`}>
|
||||
{t("azuredevops:repository")}
|
||||
</Label>
|
||||
<Select
|
||||
value={filters.repositoryId || undefined}
|
||||
onValueChange={(value) => onChange("repositoryId", value)}
|
||||
disabled={repositories.length === 0}
|
||||
>
|
||||
<SelectTrigger id={`azure-devops-filter-repository${idSuffix}`} className="w-full">
|
||||
<SelectValue placeholder="Select repository" />
|
||||
<SelectValue placeholder={t("azuredevops:selectRepository")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{repositories.map((repository) => (
|
||||
@@ -105,22 +110,24 @@ function PullRequestFilters({
|
||||
</Select>
|
||||
</Field>
|
||||
<Field compact={compact}>
|
||||
<Label htmlFor={`azure-devops-filter-status${idSuffix}`}>Status</Label>
|
||||
<Label htmlFor={`azure-devops-filter-status${idSuffix}`}>{t("azuredevops:status")}</Label>
|
||||
<Select value={filters.status} onValueChange={(value) => onChange("status", value)}>
|
||||
<SelectTrigger id={`azure-devops-filter-status${idSuffix}`} className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="abandoned">Abandoned</SelectItem>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="active">{t("azuredevops:statusActive")}</SelectItem>
|
||||
<SelectItem value="completed">{t("azuredevops:statusCompleted")}</SelectItem>
|
||||
<SelectItem value="abandoned">{t("azuredevops:statusAbandoned")}</SelectItem>
|
||||
<SelectItem value="all">{t("azuredevops:statusAll")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<AdvancedFilters open={advanced} onOpenChange={setAdvanced} compact={compact}>
|
||||
<Field compact={compact}>
|
||||
<Label htmlFor={`azure-devops-filter-creator${idSuffix}`}>Creator ID</Label>
|
||||
<Label htmlFor={`azure-devops-filter-creator${idSuffix}`}>
|
||||
{t("azuredevops:creatorId")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`azure-devops-filter-creator${idSuffix}`}
|
||||
value={filters.creator}
|
||||
@@ -128,7 +135,9 @@ function PullRequestFilters({
|
||||
/>
|
||||
</Field>
|
||||
<Field compact={compact}>
|
||||
<Label htmlFor={`azure-devops-filter-reviewer${idSuffix}`}>Reviewer ID</Label>
|
||||
<Label htmlFor={`azure-devops-filter-reviewer${idSuffix}`}>
|
||||
{t("azuredevops:reviewerId")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`azure-devops-filter-reviewer${idSuffix}`}
|
||||
value={filters.reviewer}
|
||||
@@ -151,6 +160,7 @@ function AdvancedFilters({
|
||||
compact?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={onOpenChange} className={cn(compact && "contents")}>
|
||||
<Button
|
||||
@@ -162,7 +172,7 @@ function AdvancedFilters({
|
||||
aria-expanded={open}
|
||||
>
|
||||
<IconAdjustments className="h-4 w-4" />
|
||||
Advanced
|
||||
{t("azuredevops:advanced")}
|
||||
<IconChevronDown className={cn("h-3.5 w-3.5 transition-transform", open && "rotate-180")} />
|
||||
</Button>
|
||||
<CollapsibleContent className={cn(compact && "basis-full")}>
|
||||
@@ -180,11 +190,14 @@ function WorkItemFilters({
|
||||
onChange,
|
||||
compact,
|
||||
}: Pick<FiltersProps, "idSuffix" | "filters" | "onChange" | "compact">) {
|
||||
const { t } = useTranslation();
|
||||
const [advanced, setAdvanced] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Field compact={compact}>
|
||||
<Label htmlFor={`azure-devops-filter-limit${idSuffix}`}>Result limit</Label>
|
||||
<Label htmlFor={`azure-devops-filter-limit${idSuffix}`}>
|
||||
{t("azuredevops:resultLimit")}
|
||||
</Label>
|
||||
<Select
|
||||
value={String(filters.top)}
|
||||
onValueChange={(value) => onChange("top", Number(value))}
|
||||
@@ -227,6 +240,7 @@ export function AzureDevOpsFilters({
|
||||
onSearch,
|
||||
compact,
|
||||
}: FiltersProps) {
|
||||
const { t } = useTranslation();
|
||||
const disabled =
|
||||
loading ||
|
||||
!filters.projectId ||
|
||||
@@ -267,7 +281,7 @@ export function AzureDevOpsFilters({
|
||||
data-testid={`azure-devops-search-button${idSuffix}`}
|
||||
>
|
||||
<IconSearch className="h-4 w-4" />
|
||||
{loading ? "Loading..." : "Search"}
|
||||
{loading ? t("azuredevops:loading") : t("common:presetIconSearch")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type Props = {
|
||||
skip: number;
|
||||
@@ -16,6 +17,7 @@ export function AzureDevOpsPullRequestPagination({
|
||||
pageSize,
|
||||
onPage,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
if (skip === 0 && count < pageSize) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-between border-t px-4 py-2">
|
||||
@@ -30,7 +32,7 @@ export function AzureDevOpsPullRequestPagination({
|
||||
onClick={() => onPage(Math.max(0, skip - pageSize))}
|
||||
disabled={loading || skip === 0}
|
||||
className="cursor-pointer"
|
||||
aria-label="Previous pull request page"
|
||||
aria-label={t("azuredevops:previousPullRequestPage")}
|
||||
>
|
||||
<IconChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -41,7 +43,7 @@ export function AzureDevOpsPullRequestPagination({
|
||||
onClick={() => onPage(skip + pageSize)}
|
||||
disabled={loading || count < pageSize}
|
||||
className="cursor-pointer"
|
||||
aria-label="Next pull request page"
|
||||
aria-label={t("azuredevops:nextPullRequestPage")}
|
||||
>
|
||||
<IconChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -19,29 +19,46 @@ import type {
|
||||
AzureDevOpsPullRequest,
|
||||
AzureDevOpsWorkItem,
|
||||
} from "@/lib/types/azure-devops";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function EmptyResult({ loading, error }: { loading: boolean; error: string | null }) {
|
||||
if (loading) return <div className="p-6 text-sm text-muted-foreground">Loading results...</div>;
|
||||
const { t } = useTranslation();
|
||||
if (loading)
|
||||
return (
|
||||
<div className="p-6 text-sm text-muted-foreground">{t("azuredevops:loadingResults")}</div>
|
||||
);
|
||||
if (error)
|
||||
return (
|
||||
<div className="p-6 text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
return <div className="p-6 text-sm text-muted-foreground">No matching results.</div>;
|
||||
return (
|
||||
<div className="p-6 text-sm text-muted-foreground">{t("azuredevops:noMatchingResults")}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// A discriminant, not copy: the aria-label is a whole sentence per kind, because
|
||||
// interpolating an English noun into a translated sentence is untranslatable.
|
||||
type TaskActionsItemKind = "workItem" | "pullRequest";
|
||||
|
||||
const TASK_ACTIONS_LABEL_KEYS: Record<TaskActionsItemKind, string> = {
|
||||
workItem: "azuredevops:taskActionsForWorkItem",
|
||||
pullRequest: "azuredevops:taskActionsForPullRequest",
|
||||
};
|
||||
|
||||
function TaskActionsMenu<T extends { id: number }>({
|
||||
item,
|
||||
itemLabel,
|
||||
itemKind,
|
||||
actions,
|
||||
onSelect,
|
||||
}: {
|
||||
item: T;
|
||||
itemLabel: string;
|
||||
itemKind: TaskActionsItemKind;
|
||||
actions: AzureDevOpsActionPreset[];
|
||||
onSelect: (item: T, action: AzureDevOpsActionPreset) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -50,9 +67,10 @@ function TaskActionsMenu<T extends { id: number }>({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="cursor-pointer"
|
||||
aria-label={`Task actions for ${itemLabel} ${item.id}`}
|
||||
aria-label={t(TASK_ACTIONS_LABEL_KEYS[itemKind], { id: item.id })}
|
||||
>
|
||||
<IconPlus className="h-4 w-4" /> Task <IconChevronDown className="h-3 w-3" />
|
||||
<IconPlus className="h-4 w-4" /> {t("azuredevops:task")}{" "}
|
||||
<IconChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
@@ -86,6 +104,7 @@ export function AzureDevOpsWorkItemResults({
|
||||
quickActions?: AzureDevOpsActionPreset[];
|
||||
onQuickAction?: (item: AzureDevOpsWorkItem, action: AzureDevOpsActionPreset) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loading || error || items.length === 0)
|
||||
return <EmptyResult loading={loading} error={error} />;
|
||||
return (
|
||||
@@ -112,7 +131,7 @@ export function AzureDevOpsWorkItemResults({
|
||||
</div>
|
||||
<div className="break-words text-sm font-medium">{item.title}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{item.assignedTo || "Unassigned"}
|
||||
{item.assignedTo || t("azuredevops:unassigned")}
|
||||
{item.areaPath ? ` · ${item.areaPath}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,7 +142,7 @@ export function AzureDevOpsWorkItemResults({
|
||||
href={item.webUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Open work item ${item.id} in Azure DevOps`}
|
||||
aria-label={t("azuredevops:openWorkItemInAzureDevops", { id: item.id })}
|
||||
>
|
||||
<IconExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
@@ -132,7 +151,7 @@ export function AzureDevOpsWorkItemResults({
|
||||
{quickActions.length > 0 && onQuickAction ? (
|
||||
<TaskActionsMenu
|
||||
item={item}
|
||||
itemLabel="work item"
|
||||
itemKind="workItem"
|
||||
actions={quickActions}
|
||||
onSelect={onQuickAction}
|
||||
/>
|
||||
@@ -145,7 +164,7 @@ export function AzureDevOpsWorkItemResults({
|
||||
onClick={() => onStartTask(item)}
|
||||
>
|
||||
<IconPlus className="h-4 w-4" />
|
||||
Start task
|
||||
{t("azuredevops:startTask")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -176,6 +195,7 @@ export function AzureDevOpsPullRequestResults({
|
||||
quickActions?: AzureDevOpsActionPreset[];
|
||||
onQuickAction?: (pullRequest: AzureDevOpsPullRequest, action: AzureDevOpsActionPreset) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loading || error || items.length === 0)
|
||||
return <EmptyResult loading={loading} error={error} />;
|
||||
return (
|
||||
@@ -189,7 +209,7 @@ export function AzureDevOpsPullRequestResults({
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">PR {pullRequest.id}</span>
|
||||
<Badge variant="outline">{pullRequest.status}</Badge>
|
||||
{pullRequest.isDraft && <Badge variant="secondary">Draft</Badge>}
|
||||
{pullRequest.isDraft && <Badge variant="secondary">{t("azuredevops:draft")}</Badge>}
|
||||
<span>{pullRequest.repositoryName}</span>
|
||||
</div>
|
||||
<div className="break-words text-sm font-medium">{pullRequest.title}</div>
|
||||
@@ -206,12 +226,12 @@ export function AzureDevOpsPullRequestResults({
|
||||
onClick={() => onFeedback(pullRequest)}
|
||||
>
|
||||
<IconMessageCircle className="h-4 w-4" />
|
||||
Feedback
|
||||
{t("azuredevops:feedback")}
|
||||
</Button>
|
||||
{quickActions.length > 0 && onQuickAction ? (
|
||||
<TaskActionsMenu
|
||||
item={pullRequest}
|
||||
itemLabel="pull request"
|
||||
itemKind="pullRequest"
|
||||
actions={quickActions}
|
||||
onSelect={onQuickAction}
|
||||
/>
|
||||
@@ -224,7 +244,7 @@ export function AzureDevOpsPullRequestResults({
|
||||
onClick={() => onStartTask(pullRequest)}
|
||||
>
|
||||
<IconPlus className="h-4 w-4" />
|
||||
Start task
|
||||
{t("azuredevops:startTask")}
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild variant="ghost" size="icon-sm" className="cursor-pointer">
|
||||
@@ -232,7 +252,7 @@ export function AzureDevOpsPullRequestResults({
|
||||
href={pullRequest.webUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={`Open pull request ${pullRequest.id} in Azure DevOps`}
|
||||
aria-label={t("azuredevops:openPullRequestInAzureDevops", { id: pullRequest.id })}
|
||||
>
|
||||
<IconExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import type { AzureDevOpsPresetKind } from "./azure-devops-presets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function SaveViewForm({
|
||||
kind,
|
||||
@@ -23,6 +24,7 @@ function SaveViewForm({
|
||||
onSave: (label: string) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [label, setLabel] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
@@ -34,7 +36,7 @@ function SaveViewForm({
|
||||
await onSave(trimmed);
|
||||
onClose();
|
||||
} catch {
|
||||
setError("Could not save this view. Try again.");
|
||||
setError(t("azuredevops:couldNotSaveView"));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -42,14 +44,18 @@ function SaveViewForm({
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save Azure DevOps view</DialogTitle>
|
||||
<DialogTitle>{t("azuredevops:saveAzureDevopsView")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save the current {kind === "work_item" ? "work-item query" : "pull-request filters"} for
|
||||
this workspace.
|
||||
{t("azuredevops:saveViewDescription", {
|
||||
kind:
|
||||
kind === "work_item"
|
||||
? t("azuredevops:workItemQuery2")
|
||||
: t("azuredevops:pullRequestFilters"),
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="azure-devops-view-name">Name</Label>
|
||||
<Label htmlFor="azure-devops-view-name">{t("azuredevops:name")}</Label>
|
||||
<Input
|
||||
id="azure-devops-view-name"
|
||||
autoFocus
|
||||
@@ -58,7 +64,7 @@ function SaveViewForm({
|
||||
setLabel(event.target.value);
|
||||
setError("");
|
||||
}}
|
||||
placeholder="e.g. Platform triage"
|
||||
placeholder={t("azuredevops:eGPlatformTriage")}
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
@@ -74,7 +80,7 @@ function SaveViewForm({
|
||||
disabled={saving}
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -82,7 +88,7 @@ function SaveViewForm({
|
||||
disabled={!trimmed || saving}
|
||||
onClick={() => void handleSave()}
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
{saving ? t("azuredevops:saving") : t("azuredevops:save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
||||
@@ -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, unknown>) => 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 <p className="text-sm text-muted-foreground">No description.</p>;
|
||||
const { t } = useTranslation();
|
||||
if (!value.trim())
|
||||
return <p className="text-sm text-muted-foreground">{t("azuredevops:noDescription")}</p>;
|
||||
return (
|
||||
<div
|
||||
data-testid="azure-work-item-detail-description"
|
||||
@@ -86,6 +94,7 @@ function AssignmentActions({
|
||||
saving: boolean;
|
||||
onChange: (action: "assign_current_user" | "unassign") => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
@@ -96,7 +105,7 @@ function AssignmentActions({
|
||||
onClick={() => onChange("assign_current_user")}
|
||||
data-testid="azure-work-item-assign-current-user"
|
||||
>
|
||||
{saving ? "Updating…" : "Assign to me"}
|
||||
{saving ? t("azuredevops:updating") : t("azuredevops:assignToMe")}
|
||||
</Button>
|
||||
{assignedTo && (
|
||||
<Button
|
||||
@@ -108,7 +117,7 @@ function AssignmentActions({
|
||||
onClick={() => onChange("unassign")}
|
||||
data-testid="azure-work-item-unassign"
|
||||
>
|
||||
Unassign
|
||||
{t("azuredevops:unassign")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -128,6 +137,7 @@ function BoardActions({
|
||||
columnDone?: boolean,
|
||||
) => Promise<AzureDevOpsBoardWorkItem | undefined>;
|
||||
}) {
|
||||
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 (
|
||||
<div className="space-y-2" data-testid="azure-work-item-board-actions">
|
||||
<Label htmlFor="azure-work-item-column">Move to column</Label>
|
||||
<Label htmlFor="azure-work-item-column">{t("azuredevops:moveToColumn")}</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Select value={columnId} onValueChange={setColumnId}>
|
||||
<SelectTrigger
|
||||
@@ -166,8 +176,8 @@ function BoardActions({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="false">In progress</SelectItem>
|
||||
<SelectItem value="true">Done</SelectItem>
|
||||
<SelectItem value="false">{t("azuredevops:inProgress")}</SelectItem>
|
||||
<SelectItem value="true">{t("azuredevops:done")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
@@ -183,7 +193,7 @@ function BoardActions({
|
||||
})
|
||||
}
|
||||
>
|
||||
Move
|
||||
{t("azuredevops:move")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="space-y-2" data-testid="azure-work-item-quick-actions">
|
||||
<Label>Task actions</Label>
|
||||
<Label>{t("azuredevops:taskActions")}</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
@@ -233,13 +244,14 @@ function DetailBody({
|
||||
quickActions: AzureDevOpsActionPreset[];
|
||||
onStartTask?: (item: AzureDevOpsWorkItem, action?: AzureDevOpsActionPreset) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const item = state.item;
|
||||
if (!item) {
|
||||
return (
|
||||
<div className="flex min-h-48 items-center justify-center text-sm text-muted-foreground">
|
||||
{state.loading ? "Loading work item…" : "Work item unavailable."}
|
||||
{state.loading ? t("azuredevops:loadingWorkItem") : t("azuredevops:workItemUnavailable")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -274,7 +286,7 @@ function DetailBody({
|
||||
className="min-h-11 cursor-pointer"
|
||||
onClick={() => void state.refresh()}
|
||||
>
|
||||
<IconRefresh className="h-4 w-4" /> Retry
|
||||
<IconRefresh className="h-4 w-4" /> {t("azuredevops:retry")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -285,7 +297,7 @@ function DetailBody({
|
||||
</Alert>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{azureDevOpsDetailFields(item).map(([label, value]) => (
|
||||
{azureDevOpsDetailFields(item, t).map(([label, value]) => (
|
||||
<div key={label} className="rounded-md border p-3">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="break-words text-sm font-medium">{value}</div>
|
||||
@@ -302,12 +314,12 @@ function DetailBody({
|
||||
</div>
|
||||
)}
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Description</h3>
|
||||
<h3 className="text-sm font-semibold">{t("azuredevops:description")}</h3>
|
||||
<Description value={item.description ?? ""} />
|
||||
</section>
|
||||
{planningFields.length > 0 && (
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Planning and effort</h3>
|
||||
<h3 className="text-sm font-semibold">{t("azuredevops:planningAndEffort")}</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{planningFields.map((field) => (
|
||||
<div key={field.referenceName} className="rounded-md bg-muted/40 p-3 text-sm">
|
||||
@@ -319,7 +331,7 @@ function DetailBody({
|
||||
</section>
|
||||
)}
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Assignment</h3>
|
||||
<h3 className="text-sm font-semibold">{t("azuredevops:assignment")}</h3>
|
||||
<AssignmentActions
|
||||
assignedTo={item.assignedTo}
|
||||
saving={saving}
|
||||
@@ -352,7 +364,7 @@ function DetailBody({
|
||||
<QuickActions actions={quickActions} item={item} onStartTask={onStartTask} />
|
||||
<section className="space-y-2" data-testid="azure-work-item-detail-comments">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">Discussion</h3>
|
||||
<h3 className="text-sm font-semibold">{t("azuredevops:discussion")}</h3>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
@@ -361,14 +373,16 @@ function DetailBody({
|
||||
onClick={() => void state.retryComments()}
|
||||
disabled={state.commentsLoading}
|
||||
>
|
||||
<IconRefresh className="h-4 w-4" /> Retry
|
||||
<IconRefresh className="h-4 w-4" /> {t("azuredevops:retry")}
|
||||
</Button>
|
||||
</div>
|
||||
{state.commentsError && <p className="text-sm text-destructive">{state.commentsError}</p>}
|
||||
{state.comments.map((comment) => (
|
||||
<article key={comment.id} className="rounded-md border p-3">
|
||||
<div className="mb-1 text-xs text-muted-foreground">
|
||||
{comment.author.displayName || comment.author.uniqueName || "Azure DevOps"}
|
||||
{comment.author.displayName ||
|
||||
comment.author.uniqueName ||
|
||||
t("azuredevops:fallbackAuthor")}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap text-sm">{comment.content}</div>
|
||||
</article>
|
||||
@@ -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")}
|
||||
</Button>
|
||||
)}
|
||||
{!state.commentsLoading && !state.commentsError && state.comments.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No discussion yet.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("azuredevops:noDiscussionYet")}</p>
|
||||
)}
|
||||
</section>
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-sm font-semibold">Linked Kandev tasks</h3>
|
||||
<h3 className="text-sm font-semibold">{t("azuredevops:linkedKandevTasks")}</h3>
|
||||
{state.linkedTasksLoading && (
|
||||
<p className="text-sm text-muted-foreground">Loading linked tasks…</p>
|
||||
<p className="text-sm text-muted-foreground">{t("azuredevops:loadingLinkedTasks")}</p>
|
||||
)}
|
||||
{!state.linkedTasksLoading && state.linkedTasks.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground">No linked tasks.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("azuredevops:noLinkedTasks")}</p>
|
||||
)}
|
||||
{state.linkedTasks.map((task) => (
|
||||
<div key={task.id} className="rounded-md border p-3 text-sm">
|
||||
@@ -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 = (
|
||||
<div className="flex items-start justify-between gap-3 border-b px-4 py-4 sm:px-6">
|
||||
<div className="min-w-0">
|
||||
@@ -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")}
|
||||
>
|
||||
<IconExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
@@ -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({
|
||||
<DialogContent className="flex max-h-[90dvh] w-[min(900px,95vw)] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>Azure DevOps work-item details</DialogDescription>
|
||||
<DialogDescription>{t("azuredevops:azureDevopsWorkItemDetails")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div data-testid={open ? "azure-work-item-detail" : undefined} className="contents">
|
||||
{header}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className={isAuth ? "text-muted-foreground" : "text-destructive"}>
|
||||
{isAuth ? `${name} authentication required.` : cleanIntegrationErrorMessage(error)}
|
||||
{isAuth
|
||||
? t("integrations:authRequiredInline", { name })
|
||||
: cleanIntegrationErrorMessage(error)}
|
||||
</span>
|
||||
{isAuth && (
|
||||
<Button asChild size="sm" variant="outline" className="cursor-pointer h-7 text-xs">
|
||||
<Link href={reconnectHref}>Reconnect {name}</Link>
|
||||
<Link href={reconnectHref}>{t("integrations:reconnectProvider", { name })}</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -66,11 +70,13 @@ export function IntegrationAuthErrorMessage({
|
||||
<>
|
||||
<IconLockExclamation className="h-10 w-10 mx-auto text-muted-foreground" />
|
||||
<div className="space-y-1.5">
|
||||
<h2 className="text-lg font-semibold">{name} authentication required</h2>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("integrations:authRequiredHeading", { name })}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">{authErrorBody}</p>
|
||||
</div>
|
||||
<Button asChild size="sm" className="cursor-pointer">
|
||||
<Link href={reconnectHref}>Reconnect {name}</Link>
|
||||
<Link href={reconnectHref}>{t("integrations:reconnectProvider", { name })}</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -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
|
||||
<ValidatedPopover
|
||||
triggerStyle="ghost-icon"
|
||||
triggerIcon={<IconTicket className="h-4 w-4" />}
|
||||
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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<span className="text-xs font-medium">{ticketKey}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open Jira ticket {ticketKey}</TooltipContent>
|
||||
<TooltipContent>
|
||||
{t("jira:openJiraTicket")} {ticketKey}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<JiraTicketDialog
|
||||
open={open}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@kandev/ui/avatar";
|
||||
import { getJiraTicket, transitionJiraTicket } from "@/lib/api/domains/jira-api";
|
||||
import type { JiraStatusCategory, JiraTicket } from "@/lib/types/jira";
|
||||
import { IntegrationAuthErrorMessage } from "@/components/integrations/auth-error-message";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Matches PROJECT-123 anywhere in the string. Jira keys start with letters and
|
||||
// include an uppercase prefix, followed by a dash and one or more digits.
|
||||
@@ -40,7 +41,8 @@ export function formatRelative(iso: string | undefined): string {
|
||||
}
|
||||
|
||||
export function PersonCell({ name, avatar }: { name?: string; avatar?: string }) {
|
||||
if (!name) return <span className="text-muted-foreground">Unassigned</span>;
|
||||
const { t } = useTranslation();
|
||||
if (!name) return <span className="text-muted-foreground">{t("jira:unassigned")}</span>;
|
||||
return (
|
||||
<>
|
||||
<Avatar size="sm" className="size-5">
|
||||
@@ -160,13 +162,14 @@ type JiraErrorMessageProps = {
|
||||
};
|
||||
|
||||
export function JiraErrorMessage({ error, compact }: JiraErrorMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<IntegrationAuthErrorMessage
|
||||
error={error}
|
||||
name="Jira"
|
||||
reconnectHref="/settings/integrations/jira"
|
||||
isAuthError={isJiraAuthError}
|
||||
authErrorBody="Your Jira session expired or needs step-up authentication. Reconnect to view this ticket."
|
||||
authErrorBody={t("jira:yourJiraSessionExpiredOrNeeds")}
|
||||
compact={compact}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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, unknown>) => 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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="!max-w-[min(1280px,95vw)] w-[95vw] max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0 sm:rounded-lg">
|
||||
<DialogTitle className="sr-only">{dialogTitle(ticket, ticketKey)}</DialogTitle>
|
||||
<DialogTitle className="sr-only">{dialogTitle(ticket, ticketKey, t)}</DialogTitle>
|
||||
{errorOnly ? (
|
||||
<div className="flex-1 flex items-center justify-center px-8 py-16">
|
||||
<JiraErrorMessage error={state.error ?? ""} />
|
||||
@@ -101,6 +107,7 @@ function DialogBody({
|
||||
state: TicketState;
|
||||
ticketKey: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{state.error && ticket && (
|
||||
@@ -109,7 +116,9 @@ function DialogBody({
|
||||
</div>
|
||||
)}
|
||||
{!ticket && state.loading && (
|
||||
<div className="text-sm text-muted-foreground py-16 text-center">Loading ticket…</div>
|
||||
<div className="text-sm text-muted-foreground py-16 text-center">
|
||||
{t("jira:loadingTicket")}
|
||||
</div>
|
||||
)}
|
||||
{ticket && <TicketBody ticket={ticket} state={state} ticketKey={ticketKey} />}
|
||||
</div>
|
||||
@@ -125,13 +134,14 @@ function TicketFooter({
|
||||
presets: JiraTaskPreset[];
|
||||
onStartTask: (ticket: JiraTicket, preset: JiraTaskPreset) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-3 border-t bg-muted/20 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="default" className="cursor-pointer gap-1.5">
|
||||
<IconPlus className="h-4 w-4" />
|
||||
Start task
|
||||
{t("jira:startTask")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-60">
|
||||
@@ -164,6 +174,7 @@ type TopBarProps = {
|
||||
};
|
||||
|
||||
function TicketTopBar({ ticket, loading, onRefresh }: TopBarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1 pl-4 pr-12 py-2 border-b shrink-0">
|
||||
{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")}
|
||||
>
|
||||
<a href={ticket.url} target="_blank" rel="noreferrer">
|
||||
<IconExternalLink className="h-4 w-4" />
|
||||
@@ -185,7 +196,7 @@ function TicketTopBar({ ticket, loading, onRefresh }: TopBarProps) {
|
||||
className="cursor-pointer"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title="Refresh"
|
||||
title={t("jira:refresh")}
|
||||
>
|
||||
<IconRefresh className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
@@ -244,14 +255,15 @@ function SectionHeading({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function DescriptionSection({ description }: { description: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<SectionHeading>Description</SectionHeading>
|
||||
<SectionHeading>{t("jira:description")}</SectionHeading>
|
||||
<div className="rounded-md border bg-muted/30 px-4 py-3">
|
||||
{description ? (
|
||||
<div className="text-sm whitespace-pre-wrap leading-relaxed">{description}</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">No description.</div>
|
||||
<div className="text-sm text-muted-foreground italic">{t("jira:noDescription")}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -301,22 +313,23 @@ function StatusBlock({ ticket, transitions, pending, onTransition }: StatusBlock
|
||||
}
|
||||
|
||||
function DetailsCard({ ticket }: { ticket: JiraTicket }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="rounded-md border bg-background overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-muted/30">
|
||||
<SectionHeading>Details</SectionHeading>
|
||||
<SectionHeading>{t("jira:details")}</SectionHeading>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3 text-sm">
|
||||
<DetailRow label="Assignee">
|
||||
<DetailRow label={t("jira:assignee")}>
|
||||
<PersonCell name={ticket.assigneeName} avatar={ticket.assigneeAvatar} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Reporter">
|
||||
<DetailRow label={t("jira:reporter")}>
|
||||
<PersonCell name={ticket.reporterName} avatar={ticket.reporterAvatar} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Priority">
|
||||
<DetailRow label={t("jira:placeholderPriority")}>
|
||||
<IconLabel icon={ticket.priorityIcon} label={ticket.priority} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Project">
|
||||
<DetailRow label={t("jira:project")}>
|
||||
<span className="font-mono text-xs">{ticket.projectKey}</span>
|
||||
</DetailRow>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div className="text-xs text-muted-foreground px-1" title={ticket.updated}>
|
||||
Updated {updated}
|
||||
{t("jira:updatedTime", { time: updated })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center gap-2 flex-wrap px-6 py-2.5 border-b shrink-0 bg-muted/20">
|
||||
<ProjectPill
|
||||
@@ -47,7 +49,7 @@ export function FilterBar({
|
||||
onClick={onClear}
|
||||
className="cursor-pointer h-7 text-xs ml-1"
|
||||
>
|
||||
Clear filters
|
||||
{t("jira:clearFilters")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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<PillShellProps, "label" | "summary" | "disabledHint">) {
|
||||
}: Pick<PillShellProps, "id" | "label" | "summary" | "disabledHint">) {
|
||||
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 = (
|
||||
<div
|
||||
data-testid={`jira-filter-pill-${label.toLowerCase()}`}
|
||||
data-testid={`jira-filter-pill-${id}`}
|
||||
data-disabled="true"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-disabled="true"
|
||||
aria-label={disabledHint ?? `${label} filter disabled`}
|
||||
aria-label={disabledHint ?? t("jira:filterDisabled", { label })}
|
||||
className="inline-flex items-stretch rounded-md border text-xs overflow-hidden bg-background opacity-50"
|
||||
>
|
||||
<span className="px-2.5 py-1.5 flex items-center gap-1.5 cursor-not-allowed">
|
||||
@@ -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 <DisabledPill label={label} summary={summary} disabledHint={disabledHint} />;
|
||||
return <DisabledPill id={id} label={label} summary={summary} disabledHint={disabledHint} />;
|
||||
}
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
@@ -79,7 +93,7 @@ function PillShell({
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`jira-filter-pill-${label.toLowerCase()}`}
|
||||
data-testid={`jira-filter-pill-${id}`}
|
||||
className="cursor-pointer px-2.5 py-1.5 flex items-center gap-1.5 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
@@ -92,7 +106,7 @@ function PillShell({
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="cursor-pointer px-1.5 border-l hover:bg-muted flex items-center"
|
||||
title={`Clear ${label.toLowerCase()}`}
|
||||
title={t("jira:clear", { lowerCase: label.toLowerCase() })}
|
||||
>
|
||||
<IconX className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -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 (
|
||||
<PillShell
|
||||
label="Project"
|
||||
id="project"
|
||||
label={t("jira:project")}
|
||||
summary={value.length > 0 ? joinSummary(value, 2) : null}
|
||||
active={value.length > 0}
|
||||
onClear={() => onChange([])}
|
||||
@@ -143,13 +159,13 @@ export function ProjectPill({ projects, value, onChange }: ProjectPillProps) {
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search projects…"
|
||||
placeholder={t("jira:searchProjects")}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">No projects match.</div>
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">{t("jira:noProjectsMatch")}</div>
|
||||
)}
|
||||
{filtered.map((p) => (
|
||||
<label
|
||||
@@ -180,10 +196,8 @@ type StatusPillProps = {
|
||||
hasProjectSelected: boolean;
|
||||
};
|
||||
|
||||
const NO_PROJECT_HINT = "Select a project to filter by status";
|
||||
const NO_STATUSES_HINT = "No statuses available for the selected project";
|
||||
|
||||
export function StatusPill({ options, value, onChange, hasProjectSelected }: StatusPillProps) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
@@ -200,24 +214,27 @@ export function StatusPill({ options, value, onChange, hasProjectSelected }: Sta
|
||||
|
||||
return (
|
||||
<PillShell
|
||||
label="Status"
|
||||
id="status"
|
||||
label={t("common:status")}
|
||||
summary={summary}
|
||||
active={value.length > 0}
|
||||
onClear={() => onChange([])}
|
||||
disabled={options.length === 0}
|
||||
disabledHint={hasProjectSelected ? NO_STATUSES_HINT : NO_PROJECT_HINT}
|
||||
disabledHint={
|
||||
hasProjectSelected ? t("jira:noStatusesForProject") : t("jira:selectProjectToFilterStatus")
|
||||
}
|
||||
>
|
||||
<div className="p-2 border-b">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search statuses…"
|
||||
placeholder={t("jira:searchStatuses")}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto py-1">
|
||||
{filtered.length === 0 && (
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">No statuses match.</div>
|
||||
<div className="px-3 py-2 text-xs text-muted-foreground">{t("jira:noStatusesMatch")}</div>
|
||||
)}
|
||||
{filtered.map((o) => (
|
||||
<label
|
||||
@@ -239,18 +256,22 @@ type AssigneePillProps = {
|
||||
onChange: (a: AssigneeFilter) => void;
|
||||
};
|
||||
|
||||
const ASSIGNEE_OPTIONS: { value: AssigneeFilter; label: string }[] = [
|
||||
{ value: "anyone", label: "Anyone" },
|
||||
{ value: "me", label: "Me" },
|
||||
{ value: "unassigned", label: "Unassigned" },
|
||||
/** `value` is the persisted AssigneeFilter; only the catalog key is copy. */
|
||||
const ASSIGNEE_OPTIONS: { value: AssigneeFilter; labelKey: string }[] = [
|
||||
{ value: "anyone", labelKey: "jira:assigneeAnyone" },
|
||||
{ value: "me", labelKey: "jira:assigneeMe" },
|
||||
{ value: "unassigned", labelKey: "jira:assigneeUnassigned" },
|
||||
];
|
||||
|
||||
export function AssigneePill({ value, onChange }: AssigneePillProps) {
|
||||
const { t } = useTranslation();
|
||||
const active = value !== "anyone";
|
||||
const summary = active ? (ASSIGNEE_OPTIONS.find((o) => o.value === value)?.label ?? null) : null;
|
||||
const activeOption = active ? ASSIGNEE_OPTIONS.find((o) => o.value === value) : undefined;
|
||||
const summary = activeOption ? t(activeOption.labelKey) : null;
|
||||
return (
|
||||
<PillShell
|
||||
label="Assignee"
|
||||
id="assignee"
|
||||
label={t("jira:assignee")}
|
||||
summary={summary}
|
||||
active={active}
|
||||
onClear={() => onChange("anyone")}
|
||||
@@ -265,7 +286,7 @@ export function AssigneePill({ value, onChange }: AssigneePillProps) {
|
||||
value === o.value ? "font-medium" : ""
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
{t(o.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from "react";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Textarea } from "@kandev/ui/textarea";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type JqlEditorProps = {
|
||||
composedJql: string;
|
||||
@@ -15,6 +16,7 @@ type JqlEditorProps = {
|
||||
// Inline editor that exposes the JQL being sent to Jira. Users can tweak it
|
||||
// for one-off advanced queries; pressing Reset returns to pill-driven mode.
|
||||
export function JqlEditor({ composedJql, customJql, onApply, onReset }: JqlEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const source = customJql ?? composedJql;
|
||||
const [draft, setDraft] = useState(source);
|
||||
const [prevSource, setPrevSource] = useState(source);
|
||||
@@ -33,9 +35,9 @@ export function JqlEditor({ composedJql, customJql, onApply, onReset }: JqlEdito
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<IconInfoCircle className="h-3.5 w-3.5" />
|
||||
{overriding ? (
|
||||
<span>Custom JQL override — pill filters are paused.</span>
|
||||
<span>{t("jira:customJqlOverridePillFiltersAre")}</span>
|
||||
) : (
|
||||
<span>JQL is generated from the pills above. Edit to override.</span>
|
||||
<span>{t("jira:jqlIsGeneratedFromThePills")}</span>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
@@ -51,7 +53,7 @@ export function JqlEditor({ composedJql, customJql, onApply, onReset }: JqlEdito
|
||||
disabled={!dirty || !draft.trim()}
|
||||
className="cursor-pointer h-7 text-xs"
|
||||
>
|
||||
Apply
|
||||
{t("jira:apply")}
|
||||
</Button>
|
||||
{overriding && (
|
||||
<Button
|
||||
@@ -60,7 +62,7 @@ export function JqlEditor({ composedJql, customJql, onApply, onReset }: JqlEdito
|
||||
onClick={onReset}
|
||||
className="cursor-pointer h-7 text-xs"
|
||||
>
|
||||
Reset to pills
|
||||
{t("jira:resetToPills")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -22,11 +22,13 @@ import {
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import type { SavedView } from "./use-saved-views";
|
||||
import type { SortKey } from "./filter-model";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const SORT_OPTIONS: { value: SortKey; label: string }[] = [
|
||||
{ value: "updated", label: "Updated" },
|
||||
{ value: "created", label: "Created" },
|
||||
{ value: "priority", label: "Priority" },
|
||||
/** `value` is the persisted SortKey; only the catalog key is copy. */
|
||||
const SORT_OPTIONS: { value: SortKey; labelKey: string }[] = [
|
||||
{ value: "updated", labelKey: "jira:sortUpdated" },
|
||||
{ value: "created", labelKey: "jira:sortCreated" },
|
||||
{ value: "priority", labelKey: "jira:sortPriority" },
|
||||
];
|
||||
|
||||
type ListToolbarProps = {
|
||||
@@ -62,8 +64,10 @@ export function ListToolbar({
|
||||
showJqlEditor,
|
||||
onToggleJqlEditor,
|
||||
}: ListToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const activeView = views.find((v) => v.id === activeViewId);
|
||||
const sortLabel = SORT_OPTIONS.find((o) => o.value === sort)?.label ?? "Updated";
|
||||
const sortOption = SORT_OPTIONS.find((o) => o.value === sort);
|
||||
const sortLabel = t(sortOption?.labelKey ?? "jira:sortUpdated");
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-6 py-2.5 border-b shrink-0 flex-wrap">
|
||||
<SearchInput value={searchText} onChange={onSearchChange} />
|
||||
@@ -77,7 +81,7 @@ export function ListToolbar({
|
||||
<SaveViewButton onSave={onSaveView} />
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground tabular-nums mr-2">
|
||||
{loading ? "Loading…" : `${count} ticket${count === 1 ? "" : "s"} on this page`}
|
||||
{loading ? t("jira:loading2") : t("jira:ticketOnThisPage", { count })}
|
||||
</span>
|
||||
<SortDropdown sort={sort} sortLabel={sortLabel} onSortChange={onSortChange} />
|
||||
<Button
|
||||
@@ -86,7 +90,7 @@ export function ListToolbar({
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="cursor-pointer h-7 w-7"
|
||||
title="Refresh"
|
||||
title={t("jira:refresh")}
|
||||
>
|
||||
<IconRefresh className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
@@ -95,7 +99,7 @@ export function ListToolbar({
|
||||
size="sm"
|
||||
onClick={onToggleJqlEditor}
|
||||
className="cursor-pointer h-7 text-xs gap-1.5"
|
||||
title="Toggle raw JQL editor"
|
||||
title={t("jira:toggleRawJqlEditor")}
|
||||
>
|
||||
<IconCode className="h-3.5 w-3.5" />
|
||||
JQL
|
||||
@@ -114,12 +118,13 @@ function SortDropdown({
|
||||
sortLabel: string;
|
||||
onSortChange: (sort: SortKey) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="cursor-pointer h-7 text-xs gap-1.5">
|
||||
<IconArrowsSort className="h-3.5 w-3.5" />
|
||||
Sort: {sortLabel}
|
||||
{t("jira:sortLabelled", { label: sortLabel })}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
@@ -130,7 +135,7 @@ function SortDropdown({
|
||||
onCheckedChange={() => onSortChange(o.value)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{o.label}
|
||||
{t(o.labelKey)}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
@@ -139,13 +144,14 @@ function SortDropdown({
|
||||
}
|
||||
|
||||
function SearchInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="relative flex-1 max-w-md min-w-[200px]">
|
||||
<IconSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="Search ticket key or text…"
|
||||
placeholder={t("jira:searchTicketKeyOrText")}
|
||||
className="h-8 text-xs pl-8"
|
||||
/>
|
||||
</div>
|
||||
@@ -165,6 +171,7 @@ function ViewsDropdown({
|
||||
onDelete: (id: string) => void;
|
||||
activeName: string | undefined;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const builtin = views.filter((v) => v.builtin);
|
||||
const custom = views.filter((v) => !v.builtin);
|
||||
@@ -173,12 +180,12 @@ function ViewsDropdown({
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="cursor-pointer h-8 text-xs gap-1.5">
|
||||
<IconBookmark className="h-3.5 w-3.5" />
|
||||
{activeName ?? "No view"}
|
||||
{activeName ?? t("jira:noView")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-60 p-0">
|
||||
<ViewsGroup
|
||||
label="Built-in"
|
||||
label={t("jira:builtIn")}
|
||||
views={builtin}
|
||||
activeViewId={activeViewId}
|
||||
onSelect={(id) => {
|
||||
@@ -191,7 +198,7 @@ function ViewsDropdown({
|
||||
<>
|
||||
<div className="border-t" />
|
||||
<ViewsGroup
|
||||
label="Saved"
|
||||
label={t("jira:saved")}
|
||||
views={custom}
|
||||
activeViewId={activeViewId}
|
||||
onSelect={(id) => {
|
||||
@@ -249,6 +256,7 @@ function ViewRow({
|
||||
onSelect: (id: string) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="group flex items-center px-2">
|
||||
<button
|
||||
@@ -267,7 +275,7 @@ function ViewRow({
|
||||
onDelete(view.id);
|
||||
}}
|
||||
className="cursor-pointer opacity-0 group-hover:opacity-100 p-1 rounded hover:bg-muted"
|
||||
title="Delete view"
|
||||
title={t("jira:deleteView")}
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
@@ -277,6 +285,7 @@ function ViewRow({
|
||||
}
|
||||
|
||||
function SaveViewButton({ onSave }: { onSave: (name: string) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const submit = () => {
|
||||
@@ -293,14 +302,14 @@ function SaveViewButton({ onSave }: { onSave: (name: string) => void }) {
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="cursor-pointer h-8 text-xs gap-1.5"
|
||||
title="Save current filters as a view"
|
||||
title={t("jira:saveCurrentFiltersAsAView")}
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
Save view
|
||||
{t("jira:saveView")}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-64 p-3 space-y-2">
|
||||
<div className="text-xs font-semibold">Save current filters as…</div>
|
||||
<div className="text-xs font-semibold">{t("jira:saveCurrentFiltersAs")}</div>
|
||||
<Input
|
||||
autoFocus
|
||||
value={name}
|
||||
@@ -308,7 +317,7 @@ function SaveViewButton({ onSave }: { onSave: (name: string) => void }) {
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
}}
|
||||
placeholder="My open bugs"
|
||||
placeholder={t("jira:myOpenBugs")}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<div className="flex justify-end gap-1">
|
||||
@@ -318,7 +327,7 @@ function SaveViewButton({ onSave }: { onSave: (name: string) => void }) {
|
||||
onClick={() => setOpen(false)}
|
||||
className="cursor-pointer h-7 text-xs"
|
||||
>
|
||||
Cancel
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -326,7 +335,7 @@ function SaveViewButton({ onSave }: { onSave: (name: string) => void }) {
|
||||
disabled={!name.trim()}
|
||||
className="cursor-pointer h-7 text-xs"
|
||||
>
|
||||
Save
|
||||
{t("common:save")}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@kandev/ui/pagination";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type ResultsPaginationProps = {
|
||||
page: number;
|
||||
@@ -27,6 +28,7 @@ export function ResultsPagination({
|
||||
onNext,
|
||||
onPrev,
|
||||
}: ResultsPaginationProps) {
|
||||
const { t } = useTranslation();
|
||||
if (page === 1 && isLast) return null;
|
||||
const start = (page - 1) * pageSize + 1;
|
||||
const end = (page - 1) * pageSize + itemCount;
|
||||
@@ -36,7 +38,7 @@ export function ResultsPagination({
|
||||
return (
|
||||
<div className="flex items-center justify-between px-6 py-3 border-t shrink-0">
|
||||
<div className="text-xs text-muted-foreground tabular-nums">
|
||||
{itemCount === 0 ? "No results" : `${start}–${end}`}
|
||||
{itemCount === 0 ? t("jira:noResults") : `${start}–${end}`}
|
||||
</div>
|
||||
<Pagination className="mx-0 w-auto justify-end">
|
||||
<PaginationContent>
|
||||
@@ -52,7 +54,7 @@ export function ResultsPagination({
|
||||
/>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<span className="px-3 text-sm tabular-nums">Page {page}</span>
|
||||
<span className="px-3 text-sm tabular-nums">{t("jira:pageNumber", { page })}</span>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import type { JiraTicket } from "@/lib/types/jira";
|
||||
import { formatRelative, statusBadgeClass } from "@/components/jira/jira-ticket-common";
|
||||
import type { JiraTaskPreset } from "./presets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type TicketRowProps = {
|
||||
ticket: JiraTicket;
|
||||
@@ -22,8 +23,9 @@ type TicketRowProps = {
|
||||
};
|
||||
|
||||
function AssigneeCell({ ticket }: { ticket: JiraTicket }) {
|
||||
const { t } = useTranslation();
|
||||
if (!ticket.assigneeName) {
|
||||
return <span className="text-xs text-muted-foreground">Unassigned</span>;
|
||||
return <span className="text-xs text-muted-foreground">{t("jira:unassigned")}</span>;
|
||||
}
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
@@ -47,12 +49,13 @@ function StartTaskMenu({
|
||||
presets: JiraTaskPreset[];
|
||||
onStartTask: TicketRowProps["onStartTask"];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="cursor-pointer h-7 px-2 gap-1 text-xs">
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
Start task
|
||||
{t("jira:startTask")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
@@ -78,6 +81,7 @@ function StartTaskMenu({
|
||||
}
|
||||
|
||||
export function TicketRow({ ticket, presets, onStartTask, onOpen }: TicketRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const relative = formatRelative(ticket.updated);
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-3 border-b last:border-b-0">
|
||||
@@ -85,7 +89,7 @@ export function TicketRow({ ticket, presets, onStartTask, onOpen }: TicketRowPro
|
||||
type="button"
|
||||
onClick={() => onOpen?.(ticket)}
|
||||
className="flex-1 min-w-0 space-y-1 text-left cursor-pointer rounded -mx-2 px-2 py-1 hover:bg-muted/50 transition-colors"
|
||||
title="Open ticket details"
|
||||
title={t("jira:openTicketDetails")}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span className="font-mono">{ticket.key}</span>
|
||||
@@ -102,12 +106,16 @@ export function TicketRow({ ticket, presets, onStartTask, onOpen }: TicketRowPro
|
||||
</Badge>
|
||||
)}
|
||||
<AssigneeCell ticket={ticket} />
|
||||
{relative && <span className="text-xs text-muted-foreground">· updated {relative}</span>}
|
||||
{relative && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("jira:updatedAgo", { time: relative })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button asChild variant="ghost" size="icon-sm" className="cursor-pointer">
|
||||
<a href={ticket.url} target="_blank" rel="noreferrer" title="Open in Atlassian">
|
||||
<a href={ticket.url} target="_blank" rel="noreferrer" title={t("jira:openInAtlassian")}>
|
||||
<IconExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { LinearIssue } from "@/lib/types/linear";
|
||||
import { LINEAR_KEY_RE } from "./linear-issue-common";
|
||||
import { useLinearAvailable } from "@/hooks/domains/linear/use-linear-availability";
|
||||
import { ValidatedPopover } from "@/components/integrations/validated-popover";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type LinearImportBarProps = {
|
||||
workspaceId: string | null;
|
||||
@@ -14,6 +15,7 @@ type LinearImportBarProps = {
|
||||
};
|
||||
|
||||
export function LinearImportBar({ workspaceId, disabled, onImport }: LinearImportBarProps) {
|
||||
const { t } = useTranslation();
|
||||
const available = useLinearAvailable(workspaceId);
|
||||
if (!available || !workspaceId) return null;
|
||||
|
||||
@@ -21,19 +23,19 @@ export function LinearImportBar({ workspaceId, disabled, onImport }: LinearImpor
|
||||
<ValidatedPopover
|
||||
triggerStyle="ghost-icon"
|
||||
triggerIcon={<IconHexagon className="h-4 w-4" />}
|
||||
triggerAriaLabel="Import from Linear"
|
||||
triggerAriaLabel={t("linear:importFromLinear")}
|
||||
triggerDisabled={disabled}
|
||||
testIdPrefix="linear-import"
|
||||
tooltip="Import from Linear issue URL or identifier"
|
||||
tooltip={t("linear:importFromLinearIssueUrlOr")}
|
||||
align="start"
|
||||
headline="Import Linear issue"
|
||||
placeholder="ENG-123 or paste issue URL"
|
||||
headline={t("linear:importLinearIssue")}
|
||||
placeholder={t("linear:eng123OrPasteIssueUrl")}
|
||||
extractKey={(raw) => raw.toUpperCase().match(LINEAR_KEY_RE)?.[0] ?? null}
|
||||
validationHint="Paste a Linear issue URL or identifier (ENG-123)"
|
||||
validationHint={t("linear:pasteALinearIssueUrlOr")}
|
||||
fetch={(key) => getLinearIssue(key, { workspaceId })}
|
||||
onSuccess={(_key, issue) => onImport(issue)}
|
||||
submitLabel="Import"
|
||||
submittingLabel="Loading..."
|
||||
submitLabel={t("linear:import")}
|
||||
submittingLabel={t("linear:loading")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { LinearIssueDialog } from "./linear-issue-dialog";
|
||||
import { extractLinearKey } from "./linear-issue-common";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export { extractLinearKey };
|
||||
|
||||
@@ -17,6 +18,7 @@ type LinearIssueButtonProps = {
|
||||
// LinearIssueButton sits in the task top bar. It extracts a Linear identifier
|
||||
// from the task title and opens a full issue dialog on click.
|
||||
export function LinearIssueButton({ workspaceId, taskTitle }: LinearIssueButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const identifier = extractLinearKey(taskTitle);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -36,7 +38,9 @@ export function LinearIssueButton({ workspaceId, taskTitle }: LinearIssueButtonP
|
||||
<span className="text-xs font-medium">{identifier}</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Open Linear issue {identifier}</TooltipContent>
|
||||
<TooltipContent>
|
||||
{t("linear:openLinearIssue")} {identifier}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<LinearIssueDialog
|
||||
open={open}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Avatar, AvatarFallback, AvatarImage } from "@kandev/ui/avatar";
|
||||
import { getLinearIssue, setLinearIssueState } from "@/lib/api/domains/linear-api";
|
||||
import type { LinearIssue, LinearStateCategory } from "@/lib/types/linear";
|
||||
import { IntegrationAuthErrorMessage } from "@/components/integrations/auth-error-message";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Matches Linear identifiers like ENG-123. Linear team keys are always
|
||||
// uppercase and 1+ chars; we require a leading capital letter to avoid catching
|
||||
@@ -42,7 +43,8 @@ export function formatRelative(iso: string | undefined): string {
|
||||
}
|
||||
|
||||
export function PersonCell({ name, avatar }: { name?: string; avatar?: string }) {
|
||||
if (!name) return <span className="text-muted-foreground">Unassigned</span>;
|
||||
const { t } = useTranslation();
|
||||
if (!name) return <span className="text-muted-foreground">{t("linear:unassigned")}</span>;
|
||||
return (
|
||||
<>
|
||||
<Avatar size="sm" className="size-5">
|
||||
@@ -163,13 +165,14 @@ type LinearErrorMessageProps = {
|
||||
};
|
||||
|
||||
export function LinearErrorMessage({ error, compact }: LinearErrorMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<IntegrationAuthErrorMessage
|
||||
error={error}
|
||||
name="Linear"
|
||||
reconnectHref="/settings/integrations/linear"
|
||||
isAuthError={isLinearAuthError}
|
||||
authErrorBody="Your Linear API key is invalid or has been revoked. Reconnect to view this issue."
|
||||
authErrorBody={t("linear:yourLinearApiKeyIsInvalid")}
|
||||
compact={compact}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useIssueState,
|
||||
type IssueState,
|
||||
} from "./linear-issue-common";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type LinearIssueDialogProps = {
|
||||
open: boolean;
|
||||
@@ -26,8 +27,12 @@ type LinearIssueDialogProps = {
|
||||
onStartTask?: (issue: LinearIssue) => void;
|
||||
};
|
||||
|
||||
function dialogTitle(issue: LinearIssue | null, identifier: string | null | undefined): string {
|
||||
return issue?.title ?? identifier ?? "Linear issue";
|
||||
function dialogTitle(
|
||||
issue: LinearIssue | null,
|
||||
identifier: string | null | undefined,
|
||||
t: (key: string, values?: Record<string, unknown>) => string,
|
||||
): string {
|
||||
return issue?.title ?? identifier ?? t("linear:fallbackIssue");
|
||||
}
|
||||
|
||||
function effectiveIdentifier(
|
||||
@@ -45,6 +50,7 @@ export function LinearIssueDialog({
|
||||
initialIssue,
|
||||
onStartTask,
|
||||
}: LinearIssueDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const enabled = open && !!workspaceId && !!identifier;
|
||||
const state = useIssueState(workspaceId ?? "", identifier ?? "", enabled);
|
||||
const issue = state.issue ?? initialIssue ?? null;
|
||||
@@ -64,7 +70,7 @@ export function LinearIssueDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="!max-w-[min(1280px,95vw)] w-[95vw] max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0 sm:rounded-lg">
|
||||
<DialogTitle className="sr-only">{dialogTitle(issue, identifier)}</DialogTitle>
|
||||
<DialogTitle className="sr-only">{dialogTitle(issue, identifier, t)}</DialogTitle>
|
||||
{errorOnly ? (
|
||||
<div className="flex-1 flex items-center justify-center px-8 py-16">
|
||||
<LinearErrorMessage error={state.error ?? ""} />
|
||||
@@ -90,11 +96,12 @@ export function LinearIssueDialog({
|
||||
}
|
||||
|
||||
function IssueFooter({ onStart }: { onStart: () => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-3 border-t bg-muted/20 shrink-0">
|
||||
<Button size="sm" variant="default" className="cursor-pointer gap-1.5" onClick={onStart}>
|
||||
<IconPlus className="h-4 w-4" />
|
||||
Start task
|
||||
{t("linear:startTask")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -109,6 +116,7 @@ function DialogBody({
|
||||
state: IssueState;
|
||||
identifier: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{state.error && issue && (
|
||||
@@ -117,7 +125,9 @@ function DialogBody({
|
||||
</div>
|
||||
)}
|
||||
{!issue && state.loading && (
|
||||
<div className="text-sm text-muted-foreground py-16 text-center">Loading issue…</div>
|
||||
<div className="text-sm text-muted-foreground py-16 text-center">
|
||||
{t("linear:loadingIssue")}
|
||||
</div>
|
||||
)}
|
||||
{issue && <IssueBody issue={issue} state={state} identifier={identifier} />}
|
||||
</div>
|
||||
@@ -131,6 +141,7 @@ type TopBarProps = {
|
||||
};
|
||||
|
||||
function IssueTopBar({ issue, loading, onRefresh }: TopBarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-1 pl-4 pr-12 py-2 border-b shrink-0">
|
||||
{issue?.url && (
|
||||
@@ -139,7 +150,7 @@ function IssueTopBar({ issue, loading, onRefresh }: TopBarProps) {
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="cursor-pointer"
|
||||
title="Open in Linear"
|
||||
title={t("linear:openInLinear")}
|
||||
>
|
||||
<a href={issue.url} target="_blank" rel="noreferrer">
|
||||
<IconExternalLink className="h-4 w-4" />
|
||||
@@ -152,7 +163,7 @@ function IssueTopBar({ issue, loading, onRefresh }: TopBarProps) {
|
||||
className="cursor-pointer"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title="Refresh"
|
||||
title={t("linear:refresh")}
|
||||
>
|
||||
<IconRefresh className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</Button>
|
||||
@@ -211,14 +222,15 @@ function SectionHeading({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
|
||||
function DescriptionSection({ description }: { description: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="space-y-2">
|
||||
<SectionHeading>Description</SectionHeading>
|
||||
<SectionHeading>{t("linear:description")}</SectionHeading>
|
||||
<div className="rounded-md border bg-muted/30 px-4 py-3">
|
||||
{description ? (
|
||||
<div className="text-sm whitespace-pre-wrap leading-relaxed">{description}</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground italic">No description.</div>
|
||||
<div className="text-sm text-muted-foreground italic">{t("linear:noDescription")}</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -274,22 +286,23 @@ function StateBlock({ issue, states, pending, onChange }: StateBlockProps) {
|
||||
}
|
||||
|
||||
function DetailsCard({ issue }: { issue: LinearIssue }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<section className="rounded-md border bg-background overflow-hidden">
|
||||
<div className="px-4 py-3 border-b bg-muted/30">
|
||||
<SectionHeading>Details</SectionHeading>
|
||||
<SectionHeading>{t("linear:details")}</SectionHeading>
|
||||
</div>
|
||||
<div className="px-4 py-3 space-y-3 text-sm">
|
||||
<DetailRow label="Assignee">
|
||||
<DetailRow label={t("linear:assignee")}>
|
||||
<PersonCell name={issue.assigneeName} avatar={issue.assigneeIcon} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Creator">
|
||||
<DetailRow label={t("linear:creator")}>
|
||||
<PersonCell name={issue.creatorName} avatar={issue.creatorIcon} />
|
||||
</DetailRow>
|
||||
<DetailRow label="Priority">
|
||||
<DetailRow label={t("linear:priority")}>
|
||||
<span className={priorityClass(issue.priority)}>{issue.priorityLabel || "—"}</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Team">
|
||||
<DetailRow label={t("linear:team")}>
|
||||
<span className="font-mono text-xs">{issue.teamKey}</span>
|
||||
</DetailRow>
|
||||
</div>
|
||||
@@ -307,11 +320,12 @@ function DetailRow({ label, children }: { label: string; children: React.ReactNo
|
||||
}
|
||||
|
||||
function MetaFooter({ issue }: { issue: LinearIssue }) {
|
||||
const { t } = useTranslation();
|
||||
const updated = formatRelative(issue.updated);
|
||||
if (!updated) return null;
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground px-1" title={issue.updated}>
|
||||
Updated {updated}
|
||||
{t("linear:updatedTime", { time: updated })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useSentryAvailable } from "@/hooks/domains/sentry/use-sentry-availability";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { SentryIssueDialog } from "./sentry-issue-dialog";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// SentryIssueButton opens the browse/search dialog. It renders nothing when
|
||||
// the Sentry integration is not available (toggle off or unauthenticated).
|
||||
export function SentryIssueButton() {
|
||||
const { t } = useTranslation();
|
||||
const workspaceId = useAppStore((s) => s.workspaces.activeId);
|
||||
const available = useSentryAvailable(workspaceId);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -31,7 +33,7 @@ export function SentryIssueButton() {
|
||||
<span className="text-xs font-medium">Sentry</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Browse Sentry issues</TooltipContent>
|
||||
<TooltipContent>{t("sentry:browseSentryIssues")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<SentryIssueDialog open={open} onOpenChange={setOpen} workspaceId={workspaceId ?? ""} />
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { formatDistanceToNow } from "date-fns";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import type { SentryIssue, SentryLevel, SentryStatus } from "@/lib/types/sentry";
|
||||
import { IntegrationAuthErrorMessage } from "@/components/integrations/auth-error-message";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
// Sentry short IDs look like "PROJ-123" — alphanumeric uppercase project slug
|
||||
// followed by a numeric counter. Underscores and hyphens are allowed in the
|
||||
@@ -86,11 +87,20 @@ export function SentryIssueRow({ issue }: { issue: SentryIssue }) {
|
||||
}
|
||||
|
||||
function SentryIssueMeta({ issue, lastSeen }: { issue: SentryIssue; lastSeen: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
{issue.count != null && issue.count !== "" && <span>events {issue.count}</span>}
|
||||
{typeof issue.userCount === "number" && <span>users {issue.userCount}</span>}
|
||||
{lastSeen && <span title={issue.lastSeen}>Last seen {lastSeen}</span>}
|
||||
{issue.count != null && issue.count !== "" && (
|
||||
<span>{t("sentry:eventsCount", { count: issue.count })}</span>
|
||||
)}
|
||||
{typeof issue.userCount === "number" && (
|
||||
<span>{t("sentry:usersCount", { count: issue.userCount })}</span>
|
||||
)}
|
||||
{lastSeen && (
|
||||
<span title={issue.lastSeen}>
|
||||
{t("sentry:lastSeen")} {lastSeen}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -109,13 +119,14 @@ type SentryErrorMessageProps = {
|
||||
};
|
||||
|
||||
export function SentryErrorMessage({ error, compact }: SentryErrorMessageProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<IntegrationAuthErrorMessage
|
||||
error={error}
|
||||
name="Sentry"
|
||||
reconnectHref="/settings/integrations/sentry"
|
||||
isAuthError={isSentryAuthError}
|
||||
authErrorBody="Your Sentry auth token is invalid or has been revoked. Reconnect to view this issue."
|
||||
authErrorBody={t("sentry:yourSentryAuthTokenIsInvalid")}
|
||||
compact={compact}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -109,4 +109,19 @@ describe("SentryIssueDialog", () => {
|
||||
|
||||
expect(document.querySelector(".animate-spin")).toBeNull();
|
||||
});
|
||||
|
||||
// The query placeholder is Sentry search syntax the user copies. It must stay
|
||||
// outside the catalog: a translated `is:` or `release:` is invalid syntax and
|
||||
// silently returns nothing. Only the surrounding "e.g." wrapper is localized.
|
||||
it("keeps the Sentry query syntax out of the translated placeholder", async () => {
|
||||
mockAvailability();
|
||||
vi.mocked(listSentryProjects).mockResolvedValue({
|
||||
projects: [{ id: "project-1", slug: "web", name: "Web", orgSlug: "acme" }],
|
||||
} as never);
|
||||
|
||||
render(<SentryIssueDialog open={true} onOpenChange={vi.fn()} workspaceId="workspace-1" />);
|
||||
|
||||
const query = await screen.findByLabelText("Query");
|
||||
expect(query.getAttribute("placeholder")).toContain("is:unresolved release:1.2.3");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,10 +27,15 @@ import {
|
||||
levelBadgeClass,
|
||||
statusBadgeClass,
|
||||
} from "./sentry-issue-common";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const LEVELS: SentryLevel[] = ["fatal", "error", "warning", "info", "debug"];
|
||||
const STATUSES: SentryStatus[] = ["unresolved", "resolved", "ignored"];
|
||||
const PERIODS = ["1h", "24h", "7d", "14d", "30d"] as const;
|
||||
// Sentry search syntax, shown as the query placeholder. Deliberately not a
|
||||
// catalog value: a translated `is:` or `release:` is invalid syntax, and the
|
||||
// placeholder is text users copy. Only the "e.g." wrapper is translated.
|
||||
const SENTRY_QUERY_EXAMPLE = "is:unresolved release:1.2.3";
|
||||
type Period = (typeof PERIODS)[number];
|
||||
|
||||
type FilterState = {
|
||||
@@ -60,12 +65,13 @@ type SentryIssueDialogProps = {
|
||||
};
|
||||
|
||||
export function SentryIssueDialog({ open, onOpenChange, workspaceId }: SentryIssueDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const dialog = useDialogState(open, workspaceId);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="!max-w-[min(1080px,95vw)] w-[95vw] max-h-[90vh] overflow-hidden flex flex-col gap-0 p-0 sm:rounded-lg">
|
||||
<DialogTitle className="sr-only">Browse Sentry issues</DialogTitle>
|
||||
<DialogTitle className="sr-only">{t("sentry:browseSentryIssues")}</DialogTitle>
|
||||
<FiltersBar state={dialog} />
|
||||
<ResultsBody state={dialog} />
|
||||
</DialogContent>
|
||||
@@ -95,7 +101,17 @@ interface DialogState {
|
||||
instancesState: SentryAvailabilityState;
|
||||
}
|
||||
|
||||
// Catalog key for the first unmet search precondition, or null when the search
|
||||
// can run. Returns the key rather than the message so the caller resolves it at
|
||||
// render-time locale.
|
||||
function searchBlockerKey(instanceId: string, orgSlug: string): string | null {
|
||||
if (!instanceId) return "sentry:selectInstanceToSearch";
|
||||
if (!orgSlug) return "sentry:orgSlugRequired";
|
||||
return null;
|
||||
}
|
||||
|
||||
function useDialogState(open: boolean, workspaceId?: string): DialogState {
|
||||
const { t } = useTranslation();
|
||||
const { healthy, state: instancesState } = useSentryInstances(workspaceId);
|
||||
const [instanceId, setInstanceId] = useState("");
|
||||
const [filter, setFilter] = useState<FilterState>(initialFilter);
|
||||
@@ -169,12 +185,9 @@ function useDialogState(open: boolean, workspaceId?: string): DialogState {
|
||||
|
||||
const search = useCallback(
|
||||
async (nextCursorValue?: string) => {
|
||||
if (!instanceId) {
|
||||
setError("Select a Sentry instance to search");
|
||||
return;
|
||||
}
|
||||
if (!filter.orgSlug) {
|
||||
setError("Organization slug is required");
|
||||
const blockerKey = searchBlockerKey(instanceId, filter.orgSlug);
|
||||
if (blockerKey) {
|
||||
setError(t(blockerKey));
|
||||
return;
|
||||
}
|
||||
if (!workspaceId) return;
|
||||
@@ -198,7 +211,10 @@ function useDialogState(open: boolean, workspaceId?: string): DialogState {
|
||||
if (seq === searchSeq.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[workspaceId, instanceId, filter],
|
||||
// `t` is a dependency now that the precondition messages are translated:
|
||||
// i18next hands out a new `t` per language, so omitting it would leave this
|
||||
// closure reporting the previous locale's message after a switch.
|
||||
[workspaceId, instanceId, filter, t],
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -289,19 +305,20 @@ function useBrowseProjects({
|
||||
}
|
||||
|
||||
function FiltersBar({ state }: { state: DialogState }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="border-b px-5 py-4 space-y-3 shrink-0">
|
||||
<InstanceSelectRow state={state} />
|
||||
<FilterTopRow state={state} />
|
||||
<FilterChipRow
|
||||
label="Level"
|
||||
label={t("sentry:level")}
|
||||
options={LEVELS}
|
||||
selected={state.filter.levels}
|
||||
onToggle={(next) => state.updateFilter("levels", next as SentryLevel[])}
|
||||
chipClass={(v) => levelBadgeClass(v as SentryLevel)}
|
||||
/>
|
||||
<FilterChipRow
|
||||
label="Status"
|
||||
label={t("common:status")}
|
||||
options={STATUSES}
|
||||
selected={state.filter.statuses}
|
||||
onToggle={(next) => state.updateFilter("statuses", next as SentryStatus[])}
|
||||
@@ -316,28 +333,27 @@ function FiltersBar({ state }: { state: DialogState }) {
|
||||
// healthy instances, a hint when none are usable, and nothing when a sole
|
||||
// instance was auto-selected.
|
||||
function InstanceSelectRow({ state }: { state: DialogState }) {
|
||||
const { t } = useTranslation();
|
||||
const { healthy, instanceId, setInstanceId, instancesState } = state;
|
||||
if (instancesState === "empty") {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No Sentry instances configured for this workspace.
|
||||
{t("sentry:noSentryInstancesConfiguredForThis")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (instancesState === "unhealthy") {
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No healthy Sentry instance — check the integration settings.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("sentry:noHealthySentryInstanceCheckThe")}</p>
|
||||
);
|
||||
}
|
||||
if (healthy.length <= 1) return null;
|
||||
return (
|
||||
<div className="space-y-1 max-w-sm">
|
||||
<Label className="text-xs text-muted-foreground">Sentry instance</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t("sentry:sentryInstance")}</Label>
|
||||
<Select value={instanceId || undefined} onValueChange={setInstanceId}>
|
||||
<SelectTrigger className="h-8 text-xs" data-testid="sentry-browse-instance-select">
|
||||
<SelectValue placeholder="Select an instance to browse" />
|
||||
<SelectValue placeholder={t("sentry:selectAnInstanceToBrowse")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{healthy.map((instance) => (
|
||||
@@ -352,12 +368,13 @@ function InstanceSelectRow({ state }: { state: DialogState }) {
|
||||
}
|
||||
|
||||
function FilterTopRow({ state }: { state: DialogState }) {
|
||||
const { t } = useTranslation();
|
||||
const { filter, updateFilter, projects } = state;
|
||||
return (
|
||||
<div className="grid gap-2 sm:grid-cols-[1fr_1fr_180px_120px]">
|
||||
<LabeledInput
|
||||
id="sentry-search-org"
|
||||
label="Organization"
|
||||
label={t("sentry:organization")}
|
||||
value={filter.orgSlug}
|
||||
onChange={(v) => updateFilter("orgSlug", v)}
|
||||
placeholder="my-org"
|
||||
@@ -369,10 +386,10 @@ function FilterTopRow({ state }: { state: DialogState }) {
|
||||
/>
|
||||
<LabeledInput
|
||||
id="sentry-search-env"
|
||||
label="Environment"
|
||||
label={t("sentry:environment")}
|
||||
value={filter.environment}
|
||||
onChange={(v) => updateFilter("environment", v)}
|
||||
placeholder="production"
|
||||
placeholder={t("sentry:production")}
|
||||
/>
|
||||
<PeriodSelect value={filter.statsPeriod} onChange={(v) => updateFilter("statsPeriod", v)} />
|
||||
</div>
|
||||
@@ -411,15 +428,16 @@ type ProjectFilterSelectProps = {
|
||||
};
|
||||
|
||||
function ProjectFilterSelect({ value, projects, onChange }: ProjectFilterSelectProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Project</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t("sentry:project")}</Label>
|
||||
<Select value={value || "__all__"} onValueChange={(v) => onChange(v === "__all__" ? "" : v)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="All projects" />
|
||||
<SelectValue placeholder={t("sentry:allProjects")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">All projects</SelectItem>
|
||||
<SelectItem value="__all__">{t("sentry:allProjects")}</SelectItem>
|
||||
{projects.map((p) => (
|
||||
<SelectItem key={p.id} value={p.slug}>
|
||||
{p.name}
|
||||
@@ -432,9 +450,10 @@ function ProjectFilterSelect({ value, projects, onChange }: ProjectFilterSelectP
|
||||
}
|
||||
|
||||
function PeriodSelect({ value, onChange }: { value: Period; onChange: (v: Period) => void }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">Period</Label>
|
||||
<Label className="text-xs text-muted-foreground">{t("sentry:period")}</Label>
|
||||
<Select value={value} onValueChange={(v) => onChange(v as Period)}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
@@ -487,18 +506,19 @@ function FilterChipRow({ label, options, selected, onToggle, chipClass }: ChipRo
|
||||
}
|
||||
|
||||
function SearchActionRow({ state }: { state: DialogState }) {
|
||||
const { t } = useTranslation();
|
||||
const { filter, updateFilter, loading, search, instanceId } = state;
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label htmlFor="sentry-search-query" className="text-xs text-muted-foreground">
|
||||
Query
|
||||
{t("sentry:query")}
|
||||
</Label>
|
||||
<Input
|
||||
id="sentry-search-query"
|
||||
value={filter.query}
|
||||
onChange={(e) => updateFilter("query", e.target.value)}
|
||||
placeholder="is:unresolved release:1.2.3"
|
||||
placeholder={t("sentry:queryExample", { query: SENTRY_QUERY_EXAMPLE })}
|
||||
className="h-8 text-xs"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -520,13 +540,14 @@ function SearchActionRow({ state }: { state: DialogState }) {
|
||||
) : (
|
||||
<IconSearch className="h-4 w-4" />
|
||||
)}
|
||||
Search
|
||||
{t("common:presetIconSearch")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResultsBody({ state }: { state: DialogState }) {
|
||||
const { t } = useTranslation();
|
||||
const { issues, loading, error, isLast, nextCursor, search, instanceId } = state;
|
||||
const empty = useMemo(() => !loading && !error && issues.length === 0, [loading, error, issues]);
|
||||
|
||||
@@ -536,8 +557,8 @@ function ResultsBody({ state }: { state: DialogState }) {
|
||||
{empty && (
|
||||
<div className="text-sm text-muted-foreground py-12 text-center">
|
||||
{instanceId
|
||||
? "No issues yet. Run a search to begin."
|
||||
: "Select a Sentry instance to browse its issues."}
|
||||
? t("sentry:noIssuesYetRunASearch")
|
||||
: t("sentry:selectASentryInstanceToBrowse")}
|
||||
</div>
|
||||
)}
|
||||
{issues.map((issue) => (
|
||||
@@ -561,7 +582,7 @@ function ResultsBody({ state }: { state: DialogState }) {
|
||||
disabled={loading}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Load more
|
||||
{t("sentry:loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -9,11 +9,13 @@ const MULTI_REPO_SUPPORTED_EXECUTOR_TYPES = new Set(["worktree", "local_docker",
|
||||
*
|
||||
* 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.
|
||||
* dialog. The Automations migration translated all three but left the file off
|
||||
* `i18nGuardFiles`, arguing that allowlisting it would claim a completeness the
|
||||
* task-create dialog has not had. That reasoning was wrong, and the entry was
|
||||
* added here: the completeness the allowlist tracks is the FILE's, not the
|
||||
* consuming directory's, and this file holds no other copy. Left off, it could
|
||||
* silently drift back — which is the one thing the list exists to prevent.
|
||||
* 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;
|
||||
|
||||
@@ -1439,4 +1439,59 @@ export const i18nGuardFiles = [
|
||||
"components/integrations/presets-scope-bar-base.tsx",
|
||||
"components/github/my-github/presets-scope-bar.tsx",
|
||||
"components/gitlab/my-gitlab/presets-scope-bar.tsx",
|
||||
// Azure DevOps, Jira, Linear and Sentry TASK surfaces: the board / work-item /
|
||||
// PR views, the ticket and issue dialogs and their launchers, the My-Jira and
|
||||
// My-Linear browse pages, and the four `/azure-devops`, `/jira`, `/linear`
|
||||
// page clients. With the settings halves listed far above, this completes all
|
||||
// four directories.
|
||||
//
|
||||
// Two shared files ride along because each is now converted COMPLETELY, which
|
||||
// is the criterion for listing — shared-ness is not:
|
||||
// - `components/integrations/auth-error-message.tsx`: props-driven apart from
|
||||
// one heading and one link label, rendered by the Jira, Linear and Sentry
|
||||
// issue-common surfaces.
|
||||
// - `components/task-create-dialog-multi-repo-guard.ts`: migrated by the
|
||||
// Automations PR, which then argued itself out of listing it. See the
|
||||
// corrected comment in that file.
|
||||
//
|
||||
// `components/jira/my-jira/filter-pills.tsx` carried the same defect review
|
||||
// found in the GitLab sidebar: `data-testid` was built as
|
||||
// `jira-filter-pill-${label.toLowerCase()}` from a label this PR translates.
|
||||
// Here it was a LIVE break — `e2e/tests/integrations/jira-default-project.spec.ts`
|
||||
// selects `jira-filter-pill-project` and `-status` by name. The pill id and the
|
||||
// visible label are now separate props.
|
||||
//
|
||||
// Deliberately left in English: JQL and WIQL (query languages, and the bare
|
||||
// acronyms label them), every Azure DevOps / Jira / Linear / Sentry state,
|
||||
// level, priority and work-item type, all provider ids, keys and names, and
|
||||
// the persisted prompt templates the settings halves already recorded.
|
||||
"app/azure-devops/azure-devops-page-client.tsx",
|
||||
"app/jira/jira-page-client.tsx",
|
||||
"app/linear/linear-page-client.tsx",
|
||||
"components/azure-devops/azure-devops-board.tsx",
|
||||
"components/azure-devops/azure-devops-feedback-dialog.tsx",
|
||||
"components/azure-devops/azure-devops-filters.tsx",
|
||||
"components/azure-devops/azure-devops-pull-request-pagination.tsx",
|
||||
"components/azure-devops/azure-devops-results.tsx",
|
||||
"components/azure-devops/azure-devops-save-view-dialog.tsx",
|
||||
"components/azure-devops/azure-devops-work-item-detail.tsx",
|
||||
"components/integrations/auth-error-message.tsx",
|
||||
"components/jira/jira-import-bar.tsx",
|
||||
"components/jira/jira-ticket-button.tsx",
|
||||
"components/jira/jira-ticket-common.tsx",
|
||||
"components/jira/jira-ticket-dialog.tsx",
|
||||
"components/jira/my-jira/filter-pills.tsx",
|
||||
"components/jira/my-jira/jql-editor.tsx",
|
||||
"components/jira/my-jira/list-toolbar.tsx",
|
||||
"components/jira/my-jira/results-pagination.tsx",
|
||||
"components/jira/my-jira/ticket-row.tsx",
|
||||
"components/linear/linear-import-bar.tsx",
|
||||
"components/linear/linear-issue-button.tsx",
|
||||
"components/linear/linear-issue-common.tsx",
|
||||
"components/linear/linear-issue-dialog.tsx",
|
||||
"components/sentry/sentry-issue-button.tsx",
|
||||
"components/sentry/sentry-issue-common.tsx",
|
||||
"components/jira/my-jira/filter-bar.tsx",
|
||||
"components/sentry/sentry-issue-dialog.tsx",
|
||||
"components/task-create-dialog-multi-repo-guard.ts",
|
||||
];
|
||||
|
||||
@@ -128,7 +128,15 @@ const NAMESPACE_RULES = [
|
||||
[/^(app|components)\/jira\//, "jira"],
|
||||
[/^(app|components)\/linear\//, "linear"],
|
||||
[/^components\/sentry\//, "sentry"],
|
||||
[/^(app|components)\/azure-devops\//, "azureDevops"],
|
||||
// `azuredevops`, not `azureDevops`: the shipped catalog is
|
||||
// `src/locales/en/azuredevops.json` and every existing reference is
|
||||
// `t("azuredevops:…")`. The camelCase form wrote a SECOND catalog file
|
||||
// beside the real one — verified by running this on
|
||||
// `azure-devops-work-item-detail.tsx`, which produced `azureDevops.json`
|
||||
// alongside `azuredevops.json`. Nothing would have failed: the new keys
|
||||
// resolve from the new namespace, so `i18n:check` stays green while the
|
||||
// directory's copy is split across two files.
|
||||
[/^(app|components)\/azure-devops\//, "azuredevops"],
|
||||
[/^components\/automations\//, "automations"],
|
||||
[/^components\/plugins\//, "plugins"],
|
||||
[/^components\/(integrations|vcs)\//, "integrations"],
|
||||
|
||||
@@ -7,10 +7,21 @@
|
||||
"addWorkItemAction": "Add work item action",
|
||||
"addWorkItemQuery": "Add work item query",
|
||||
"addWorkItemWatch": "Add work-item watch",
|
||||
"advanced": "Advanced",
|
||||
"agentProfileId": "Agent profile ID",
|
||||
"assignment": "Assignment",
|
||||
"assignToMe": "Assign to me",
|
||||
"azureDevopsFilters": "Azure DevOps filters",
|
||||
"azureDevopsReviewAndPolicyState": "Azure DevOps review and policy state",
|
||||
"azureDevopsWorkItemDetails": "Azure DevOps work-item details",
|
||||
"azureProjectId": "Azure project ID",
|
||||
"azureRepositoryIdOptional": "Azure repository ID (optional)",
|
||||
"baseBranch": "Base branch",
|
||||
"board": "Board",
|
||||
"boardColumn": "Board column",
|
||||
"boardColumns": "Board columns",
|
||||
"boardsAndRepos": "{{organizationUrl}} · Boards and Repos",
|
||||
"branchPolicies": "Branch policies",
|
||||
"checkedAt": "checked {{at}}",
|
||||
"cleanupAlways": "always",
|
||||
"cleanupAuto": "auto",
|
||||
@@ -20,12 +31,14 @@
|
||||
"cleanupPolicyAuto": "Auto",
|
||||
"cleanupPolicyNever": "Never",
|
||||
"closeWatchEditor": "Close watch editor",
|
||||
"closeWorkItemDetails": "Close work item details",
|
||||
"configurationRemoved": "Azure DevOps configuration removed",
|
||||
"configurationSaved": "Azure DevOps configuration saved",
|
||||
"connected": "Connected",
|
||||
"connectedAs": "Connected as {{name}}",
|
||||
"connectionFailed": "Connection failed",
|
||||
"couldNotLoadQuickActions": "Could not load existing quick actions: {{error}}",
|
||||
"couldNotSaveView": "Could not save this view. Try again.",
|
||||
"createPersonalAccessToken": "Create personal access token",
|
||||
"createWatch": "Create watch",
|
||||
"creator": "Creator",
|
||||
@@ -37,11 +50,16 @@
|
||||
"defaultQueryInvalid": "Every default query needs a label and valid filters.",
|
||||
"delete": "Delete",
|
||||
"deleteWatchConfirm": "Delete this watch?",
|
||||
"description": "Description",
|
||||
"disable": "Disable",
|
||||
"disabled": "Disabled",
|
||||
"discussion": "Discussion",
|
||||
"done": "Done",
|
||||
"draft": "Draft",
|
||||
"edit": "Edit",
|
||||
"editPrompt": "Edit prompt",
|
||||
"editWatchTitle": "Edit {{kind}} watch",
|
||||
"eGPlatformTriage": "e.g. Platform triage",
|
||||
"enable": "Enable",
|
||||
"enabled": "Enabled",
|
||||
"enterValidOrganizationUrl": "Enter a valid organization URL to open its token settings.",
|
||||
@@ -50,6 +68,15 @@
|
||||
"failedToLoadQuickActions": "Failed to load quick actions.",
|
||||
"failedToSaveDefaultQueries": "Failed to save default queries",
|
||||
"failedToSaveQuickActions": "Failed to save quick actions",
|
||||
"fallbackAuthor": "Azure DevOps",
|
||||
"fallbackUnassigned": "Unassigned",
|
||||
"fallbackUnknown": "Unknown",
|
||||
"fallbackWorkItem": "Work item",
|
||||
"feedback": "Feedback",
|
||||
"fieldArea": "Area",
|
||||
"fieldAssignee": "Assignee",
|
||||
"fieldState": "State",
|
||||
"fieldType": "Type",
|
||||
"group": "Group",
|
||||
"groupCreated": "Created",
|
||||
"groupInbox": "Inbox",
|
||||
@@ -60,19 +87,49 @@
|
||||
"icon": "Icon",
|
||||
"identityIdPlaceholder": "{{token}} or identity ID",
|
||||
"identityPlaceholder": "{{token}} or identity",
|
||||
"inProgress": "In progress",
|
||||
"integrationDescription": "Azure DevOps Services organization, project, and PAT for this workspace.",
|
||||
"integrationTitle": "Azure DevOps integration",
|
||||
"kandevRepositoryId": "Kandev repository ID",
|
||||
"label": "Label",
|
||||
"linkedItemsCount_one": "{{count}} linked item",
|
||||
"linkedItemsCount_other": "{{count}} linked items",
|
||||
"linkedKandevTasks": "Linked Kandev tasks",
|
||||
"loading": "Loading...",
|
||||
"loading2": "Loading…",
|
||||
"loadingBoard": "Loading board…",
|
||||
"loadingFeedback": "Loading feedback...",
|
||||
"loadingLinkedTasks": "Loading linked tasks…",
|
||||
"loadingProjects": "Loading projects...",
|
||||
"loadingResults": "Loading results...",
|
||||
"loadingWatchers": "Loading watchers…",
|
||||
"loadingWorkItem": "Loading work item…",
|
||||
"loadOlderDiscussion": "Load older discussion",
|
||||
"maxInflightTasks": "Max in-flight tasks (0 = unlimited)",
|
||||
"move": "Move",
|
||||
"moveToColumn": "Move to column",
|
||||
"name": "Name",
|
||||
"newMatches_one": "{{count}} new match.",
|
||||
"newMatches_other": "{{count}} new matches.",
|
||||
"newWatchTitle": "New {{kind}} watch",
|
||||
"nextBoardColumn": "Next board column",
|
||||
"nextPullRequestPage": "Next pull request page",
|
||||
"noComments": "No comments.",
|
||||
"noDescription": "No description.",
|
||||
"noDiscussionYet": "No discussion yet.",
|
||||
"noLinkedTasks": "No linked tasks.",
|
||||
"noMatchingResults": "No matching results.",
|
||||
"none": "none",
|
||||
"noPolicyEvaluations": "No policy evaluations.",
|
||||
"noPullRequestWatches": "No pull-request watches yet.",
|
||||
"noReviewers": "No reviewers.",
|
||||
"notCheckedYet": "Not checked yet",
|
||||
"notConnectedNotice": "Azure DevOps is not connected for this workspace. <2>Configure Azure DevOps</2>",
|
||||
"noWorkItemWatches": "No work-item watches yet.",
|
||||
"openAzureDevopsFilters": "Open Azure DevOps filters",
|
||||
"openPullRequestInAzureDevops": "Open pull request {{id}} in Azure DevOps",
|
||||
"openWorkItemInAzureDevops": "Open work item {{id}} in Azure DevOps",
|
||||
"openWorkItemInAzureDevops2": "Open work item in Azure DevOps",
|
||||
"optional": "Optional",
|
||||
"organizationUrl": "Organization URL",
|
||||
"pastePat": "Paste PAT",
|
||||
@@ -81,18 +138,28 @@
|
||||
"patStepChooseScopes": "Choose this organization, a short expiration, and Custom defined scopes.",
|
||||
"patStepCreateAndPaste": "Create the token, copy it, and paste it into this field.",
|
||||
"patStepOpenTokenSettings": "Open token settings and select New Token.",
|
||||
"pending": "pending",
|
||||
"personalAccessToken": "Personal Access Token",
|
||||
"placeholderTitleDescription": "Title of the Azure DevOps work item or pull request",
|
||||
"placeholderUrlDescription": "URL of the Azure DevOps work item or pull request",
|
||||
"planningAndEffort": "Planning and effort",
|
||||
"policiesLabelled": "Policies: {{state}}",
|
||||
"pollIntervalSeconds": "Poll interval (seconds)",
|
||||
"previousBoardColumn": "Previous board column",
|
||||
"previousPullRequestPage": "Previous pull request page",
|
||||
"project": "Project",
|
||||
"prompt": "Prompt",
|
||||
"promptPlaceholdersHint": "Type {{open}} to see available placeholders. <3>{{url}}</3> and <6>{{title}}</6> are substituted when the action runs.",
|
||||
"pullRequestAny": "any",
|
||||
"pullRequestFeedback": "Pull request feedback",
|
||||
"pullRequestFilter": "Pull-request filter",
|
||||
"pullRequestFilters": "pull-request filters",
|
||||
"pullRequestKindLower": "pull request",
|
||||
"pullRequestKindNoun": "pull-request",
|
||||
"pullRequestKindTitle": "Pull request",
|
||||
"pullRequests": "Pull requests",
|
||||
"pullRequestsTab": "Pull requests",
|
||||
"pullRequestSubtitle": "{{repository}} · PR {{id}}",
|
||||
"pullRequestWatches": "Pull-request watches",
|
||||
"pullRequestWatchesDescription": "Create tasks automatically from matching Azure DevOps pull requests.",
|
||||
"pullRequestWatchFieldsRequired": "Project, repository, workflow, step, agent, and executor are required.",
|
||||
@@ -112,16 +179,29 @@
|
||||
"removeConfigurationConfirm": "Remove Azure DevOps configuration?",
|
||||
"removeFailed": "Remove failed: {{error}}",
|
||||
"removeQueryAria": "Remove {{kind}} query {{index}}",
|
||||
"repository": "Repository",
|
||||
"resetWatchConfirm_one": "Reset this {{policy}} watch and remove {{count}} task?",
|
||||
"resetWatchConfirm_other": "Reset this {{policy}} watch and remove {{count}} tasks?",
|
||||
"resultLimit": "Result limit",
|
||||
"resultsCount_one": "{{count}} result",
|
||||
"resultsCount_other": "{{count}} results",
|
||||
"retry": "Retry",
|
||||
"reviewer": "Reviewer",
|
||||
"reviewerId": "Reviewer ID",
|
||||
"reviewers": "Reviewers",
|
||||
"reviewLabelled": "Review: {{state}}",
|
||||
"runNow": "Run now",
|
||||
"save": "Save",
|
||||
"saveAzureDevopsView": "Save Azure DevOps view",
|
||||
"savedCredential": "Saved credential",
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"saveViewDescription": "Save the current {{kind}} for this workspace.",
|
||||
"saving": "Saving...",
|
||||
"savingEllipsis": "Saving…",
|
||||
"selectAProjectToViewIts": "Select a project to view its board.",
|
||||
"selectProject": "Select project",
|
||||
"selectRepository": "Select repository",
|
||||
"startTask": "Start task",
|
||||
"status": "Status",
|
||||
"statusAbandoned": "Abandoned",
|
||||
"statusActive": "Active",
|
||||
@@ -129,11 +209,25 @@
|
||||
"statusAny": "Any status",
|
||||
"statusCompleted": "Completed",
|
||||
"statusLine": "Status: {{status}}",
|
||||
"task": "Task",
|
||||
"taskActions": "Task actions",
|
||||
"taskActionsForPullRequest": "Task actions for pull request {{id}}",
|
||||
"taskActionsForWorkItem": "Task actions for work item {{id}}",
|
||||
"team": "Team",
|
||||
"testConnection": "Test connection",
|
||||
"testing": "Testing...",
|
||||
"top": "Top",
|
||||
"unassign": "Unassign",
|
||||
"unassigned": "Unassigned",
|
||||
"untitledWorkItem": "Untitled work item",
|
||||
"update": "Update",
|
||||
"updateWatch": "Update watch",
|
||||
"updating": "Updating…",
|
||||
"voteApproved": "Approved",
|
||||
"voteApprovedWithSuggestions": "Approved with suggestions",
|
||||
"voteNone": "No vote",
|
||||
"voteRejected": "Rejected",
|
||||
"voteWaitingForAuthor": "Waiting for author",
|
||||
"watchDeleted": "Watch deleted.",
|
||||
"watchDisabled": "Watch disabled.",
|
||||
"watchEnabled": "Watch enabled.",
|
||||
@@ -149,7 +243,10 @@
|
||||
"workItemKindNoun": "work-item",
|
||||
"workItemKindTitle": "Work item",
|
||||
"workItemQuery": "Work-item query",
|
||||
"workItemQuery2": "work-item query",
|
||||
"workItems": "Work items",
|
||||
"workItemsTab": "Work items",
|
||||
"workItemUnavailable": "Work item unavailable.",
|
||||
"workItemWatches": "Work-item watches",
|
||||
"workItemWatchesDescription": "Create tasks automatically from matching Azure DevOps work-item queries.",
|
||||
"workItemWatchFieldsRequired": "Project, WIQL, repository, workflow, step, agent, and executor are required."
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"authRequiredHeading": "{{name}} authentication required",
|
||||
"authRequiredInline": "{{name}} authentication required.",
|
||||
"deleteSavedQuery": "Delete saved query",
|
||||
"noSavedQueriesYet": "No saved queries yet",
|
||||
"reconnectProvider": "Reconnect {{name}}",
|
||||
"saveCurrentQuery": "Save current query",
|
||||
"savedQueries": "Saved"
|
||||
}
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
{
|
||||
"aCredentialIsRequired": "A credential is required.",
|
||||
"aJiraSiteUrlIsRequired": "A Jira site URL is required.",
|
||||
"actions": "Actions",
|
||||
"active": "Active",
|
||||
"addPreset": "Add preset",
|
||||
"agentProfile": "Agent Profile",
|
||||
"aJiraSiteUrlIsRequired": "A Jira site URL is required.",
|
||||
"anEmailAddressIsRequired": "An email address is required.",
|
||||
"apiToken": "API token",
|
||||
"apiTokenRecommended": "API token (recommended)",
|
||||
"apply": "Apply",
|
||||
"assignee": "Assignee",
|
||||
"assigneeAnyone": "Anyone",
|
||||
"assigneeMe": "Me",
|
||||
"assigneeUnassigned": "Unassigned",
|
||||
"atlassianCloud": "Atlassian Cloud",
|
||||
"atlassianJqlHelp": "Atlassian JQL. The watcher polls this query and creates one task per newly-matching ticket.",
|
||||
"authenticationMethod": "Authentication method",
|
||||
"availablePlaceholders": "Available placeholders:",
|
||||
"browserSessionCookie": "Browser session cookie",
|
||||
"builtIn": "Built-in",
|
||||
"capOnOpenTasksCreatedBy": "Cap on open tasks created by this watcher. Leave blank for no cap. New matches are deferred to the next poll when the cap is reached.",
|
||||
"checkFailed": "Check failed: {{error}}",
|
||||
"checkingJiraStatus": "Checking Jira status…",
|
||||
"checkNow": "Check now",
|
||||
"connectThisWorkspaceToAtlassianCloud": "Connect this workspace to Atlassian Cloud or a self-hosted Jira Server / Data Center instance. Credentials are stored encrypted server-side for the selected workspace.",
|
||||
"clear": "Clear {{lowerCase}}",
|
||||
"clearFilters": "Clear filters",
|
||||
"connectedAs": "Connected as {{name}}",
|
||||
"connectThisWorkspaceToAtlassianCloud": "Connect this workspace to Atlassian Cloud or a self-hosted Jira Server / Data Center instance. Credentials are stored encrypted server-side for the selected workspace.",
|
||||
"cookieExpired": "Cookie expired — paste a fresh one",
|
||||
"cookieExpiresInDays_one": "Cookie expires in {{count}} day",
|
||||
"cookieExpiresInDays_other": "Cookie expires in {{count}} days",
|
||||
@@ -30,10 +39,14 @@
|
||||
"createJiraWatcher": "Create JIRA Watcher",
|
||||
"createPatNoProfileLink": "Create a Personal Access Token from your Jira profile → Personal Access Tokens. Required scopes: read & write.",
|
||||
"createPatWithProfileLink": "Create a Personal Access Token from your Jira profile (<1>{{url}}</1>) → Personal Access Tokens. Required scopes: read & write.",
|
||||
"customJqlOverridePillFiltersAre": "Custom JQL override — pill filters are paused.",
|
||||
"defaultProjectKeyOptional": "Default project key (optional)",
|
||||
"delete": "Delete",
|
||||
"deleteFailed": "Delete failed: {{error}}",
|
||||
"deleteThisJiraWatcher": "Delete this JIRA watcher?",
|
||||
"deleteView": "Delete view",
|
||||
"description": "Description",
|
||||
"details": "Details",
|
||||
"editJiraWatcher": "Edit JIRA Watcher",
|
||||
"editPrompt": "Edit prompt",
|
||||
"email": "Email",
|
||||
@@ -44,6 +57,8 @@
|
||||
"expiryUnknown": "Expiry unknown",
|
||||
"failedToLoadJiraConfig": "Failed to load Jira config: {{error}}",
|
||||
"failedToSaveTaskPresets": "Failed to save task presets",
|
||||
"fallbackTicket": "Jira ticket",
|
||||
"filterDisabled": "{{label}} filter disabled",
|
||||
"foundNewTickets_one": "Found {{count}} new ticket — tasks will appear shortly.",
|
||||
"foundNewTickets_other": "Found {{count}} new tickets — tasks will appear shortly.",
|
||||
"hidePrompt": "Hide prompt",
|
||||
@@ -52,6 +67,10 @@
|
||||
"hintOptional": "Hint (optional)",
|
||||
"howOftenToReRunThe": "How often to re-run the JQL. Minimum 60s, maximum 3600s.",
|
||||
"icon": "Icon",
|
||||
"import": "Import",
|
||||
"importFromJira": "Import from Jira",
|
||||
"importFromJiraTicketUrlOr": "Import from Jira ticket URL or key",
|
||||
"importJiraTicket": "Import Jira ticket",
|
||||
"initialStepForNewTasks": "Initial step for new tasks.",
|
||||
"instanceType": "Instance type",
|
||||
"interval": "Interval",
|
||||
@@ -64,24 +83,42 @@
|
||||
"jiraWatchers": "JIRA watchers",
|
||||
"jiraWatchersDescription": "Poll a JQL query and auto-create a Kandev task for each newly-matching ticket.",
|
||||
"jqlError": "JQL error: {{error}}",
|
||||
"jqlIsGeneratedFromThePills": "JQL is generated from the pills above. Edit to override.",
|
||||
"label": "Label",
|
||||
"lastPolled": "Last Polled",
|
||||
"loading": "Loading...",
|
||||
"loading2": "Loading…",
|
||||
"loadingTicket": "Loading ticket…",
|
||||
"matchedTickets_one": "Matched {{count}} ticket in this page.",
|
||||
"matchedTickets_other": "Matched {{count}} tickets in this page.",
|
||||
"maxInFlightTasks": "Max in-flight tasks",
|
||||
"myOpenBugs": "My open bugs",
|
||||
"never": "Never",
|
||||
"newWatcher": "New watcher",
|
||||
"noCap": "(no cap)",
|
||||
"noDescription": "No description.",
|
||||
"noJiraWatchersConfiguredCreateOne": "No JIRA watchers configured. Create one to auto-create tasks from JQL queries.",
|
||||
"noNewTicketsMatched": "No new tickets matched.",
|
||||
"noProjectsMatch": "No projects match.",
|
||||
"noResults": "No results",
|
||||
"noStatusesForProject": "No statuses available for the selected project",
|
||||
"noStatusesMatch": "No statuses match.",
|
||||
"notConfiguredNotice": "Jira is not configured. <2>Configure Jira</2> to see your tickets here.",
|
||||
"noTicketsFound": "No tickets found.",
|
||||
"noView": "No view",
|
||||
"openInAtlassian": "Open in Atlassian",
|
||||
"openJiraTicket": "Open Jira ticket",
|
||||
"openTicketDetails": "Open ticket details",
|
||||
"optionalFallsBackToStepDefault": "Optional — falls back to step default.",
|
||||
"pasteATokenToTestTheConnection": "Paste a token to test the connection",
|
||||
"pageNumber": "Page {{page}}",
|
||||
"pasteAJiraTicketUrlOr": "Paste a Jira ticket URL or key (PROJ-123)",
|
||||
"pasteApiTokenHere": "paste API token here",
|
||||
"pasteATokenToTestTheConnection": "Paste a token to test the connection",
|
||||
"pastePersonalAccessTokenHere": "paste personal access token here",
|
||||
"pasteSessionTokenValue": "paste {{cookieName}} value",
|
||||
"pause": "Pause",
|
||||
"pauseOrResumePolling": "Pause or resume polling.",
|
||||
"paused": "Paused",
|
||||
"pauseOrResumePolling": "Pause or resume polling.",
|
||||
"personalAccessToken": "Personal Access Token",
|
||||
"placeholderAssigneeDisplayName": "Assignee display name",
|
||||
"placeholderIssueType": "Issue type",
|
||||
@@ -107,26 +144,44 @@
|
||||
"presetPlaceholderTicketSummary": "Ticket summary",
|
||||
"presetPlaceholderTicketUrl": "URL of the Jira ticket",
|
||||
"presetPromptPlaceholderHelp": "Type {{token}} to see available placeholders. <4>{{key}}</4>, <7>{{url}}</7>, <10>{{title}}</10>, and <13>{{description}}</13> are substituted when the action runs.",
|
||||
"proj123OrPasteTicketUrl": "PROJ-123 or paste ticket URL",
|
||||
"project": "Project",
|
||||
"promptFieldHelp": "The prompt sent to the agent for each new ticket. Type {{token}} to insert placeholders.",
|
||||
"refresh": "Refresh",
|
||||
"remove": "Remove",
|
||||
"removeConfiguration": "Remove configuration",
|
||||
"removeJiraConfiguration": "Remove Jira configuration?",
|
||||
"reporter": "Reporter",
|
||||
"resetCompleteDeletedTasks_one": "Reset complete — deleted {{count}} task; next poll will re-import matches.",
|
||||
"resetCompleteDeletedTasks_other": "Reset complete — deleted {{count}} tasks; next poll will re-import matches.",
|
||||
"resetCompleteNoTasksDeleted": "Reset complete — next poll will re-import matches.",
|
||||
"resetFailed": "Reset failed: {{error}}",
|
||||
"resetToPills": "Reset to pills",
|
||||
"resetWatch": "Reset watch",
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"saveCurrentFiltersAs": "Save current filters as…",
|
||||
"saveCurrentFiltersAsAView": "Save current filters as a view",
|
||||
"saved": "Saved",
|
||||
"savedLeaveBlank": "(saved — leave blank to keep the current value)",
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"saveView": "Save view",
|
||||
"saving": "Saving…",
|
||||
"searchProjects": "Search projects…",
|
||||
"searchStatuses": "Search statuses…",
|
||||
"searchTicketKeyOrText": "Search ticket key or text…",
|
||||
"selectProjectToFilterStatus": "Select a project to filter by status",
|
||||
"selectWorkflow": "Select workflow",
|
||||
"selectWorkspace": "Select workspace",
|
||||
"selfHostedJiraServerOrDataCenter": "Self-hosted Jira (Server or Data Center).",
|
||||
"serverDataCenter": "Server / Data Center",
|
||||
"sessionTokenValue": "Session token value",
|
||||
"showSessionTokenHelp": "Show how to copy the session token",
|
||||
"siteUrl": "Site URL",
|
||||
"sitesHostedAt": "Sites hosted at {{domain}}.",
|
||||
"siteUrl": "Site URL",
|
||||
"sortCreated": "Created",
|
||||
"sortLabelled": "Sort: {{label}}",
|
||||
"sortPriority": "Priority",
|
||||
"sortUpdated": "Updated",
|
||||
"startTask": "Start task",
|
||||
"taskPresets": "Task presets",
|
||||
"taskPresetsDescription": "Prompts shown on {{route}} when starting a task from a ticket.",
|
||||
"taskPresetsSaved": "Task presets saved",
|
||||
@@ -135,15 +190,23 @@
|
||||
"tasksCreatedByThisWatcherLand": "Tasks created by this watcher land in the selected workspace.",
|
||||
"testConnection": "Test connection",
|
||||
"testFailed": "Failed: {{error}}",
|
||||
"testJql": "Test JQL",
|
||||
"testingConnection": "Testing...",
|
||||
"testingJql": "Testing…",
|
||||
"testJql": "Test JQL",
|
||||
"ticketOnThisPage_one": "{{count}} ticket on this page",
|
||||
"ticketOnThisPage_other": "{{count}} tickets on this page",
|
||||
"ticketsAcrossYourAtlassianProjects": "Tickets across your Atlassian projects.",
|
||||
"toggleRawJqlEditor": "Toggle raw JQL editor",
|
||||
"unassigned": "Unassigned",
|
||||
"update": "Update",
|
||||
"updatedAgo": "· updated {{time}}",
|
||||
"updatedTime": "Updated {{time}}",
|
||||
"updateFailed": "Update failed: {{error}}",
|
||||
"watchDialogDescription": "Poll a JQL query and auto-create a Kandev task for each newly-matching ticket. Optionally bind a repository so each task runs against that codebase, or leave it unset to run with no repository.",
|
||||
"watcherCreated": "Watcher created",
|
||||
"watcherDeleted": "Watcher deleted",
|
||||
"watcherUpdated": "Watcher updated",
|
||||
"workflow": "Workflow",
|
||||
"workflowStep": "Workflow Step"
|
||||
"workflowStep": "Workflow Step",
|
||||
"yourJiraSessionExpiredOrNeeds": "Your Jira session expired or needs step-up authentication. Reconnect to view this ticket."
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
"actions": "Actions",
|
||||
"active": "Active",
|
||||
"agentProfile": "Agent Profile",
|
||||
"allTeams": "All teams",
|
||||
"anApiKeyIsRequired": "An API key is required.",
|
||||
"any": "(any)",
|
||||
"anyAssignee": "Any assignee",
|
||||
"anyTeam": "(any team)",
|
||||
"apiKey": "API key",
|
||||
"assignedToMe": "Assigned to me",
|
||||
"assignee": "Assignee",
|
||||
"assigneeHelp": "Filter by who an issue is assigned to.",
|
||||
"availablePlaceholders": "Available placeholders:",
|
||||
@@ -26,12 +29,15 @@
|
||||
"delete": "Delete",
|
||||
"deleteFailed": "Delete failed: {{error}}",
|
||||
"deleteThisLinearWatcher": "Delete this Linear watcher?",
|
||||
"description": "Description",
|
||||
"details": "Details",
|
||||
"dispatchOrder": "Dispatch order",
|
||||
"dispatchOrderHelp": "When the in-flight cap is reached, issues are dispatched in this order so the most important ones run first.",
|
||||
"editLinearWatcher": "Edit Linear Watcher",
|
||||
"enable": "Enable",
|
||||
"enabled": "Enabled",
|
||||
"enabledHelp": "Pause or resume polling.",
|
||||
"eng123OrPasteIssueUrl": "ENG-123 or paste issue URL",
|
||||
"enterAPositiveIntegerOrBlank": "Enter a positive integer or leave blank.",
|
||||
"estimateMax": "Estimate max",
|
||||
"estimateMaxHelp": "Upper bound in points (optional).",
|
||||
@@ -41,10 +47,16 @@
|
||||
"estimateMinPlaceholder": "e.g. 1",
|
||||
"executorProfile": "Executor Profile",
|
||||
"failedToLoadConfig": "Failed to load Linear config: {{error}}",
|
||||
"fallbackIssue": "Linear issue",
|
||||
"fallsBackToStepDefault": "Optional — falls back to step default.",
|
||||
"filter": "Filter",
|
||||
"foundNewIssues_one": "Found {{count}} new issue — tasks will appear shortly.",
|
||||
"foundNewIssues_other": "Found {{count}} new issues — tasks will appear shortly.",
|
||||
"import": "Import",
|
||||
"importFromLinear": "Import from Linear",
|
||||
"importFromLinearIssueUrlOr": "Import from Linear issue URL or identifier",
|
||||
"importLinearIssue": "Import Linear issue",
|
||||
"integrationDisabled": "Linear integration is disabled.",
|
||||
"interval": "Interval",
|
||||
"intervalMinutes_one": "{{count}}m",
|
||||
"intervalMinutes_other": "{{count}}m",
|
||||
@@ -57,7 +69,10 @@
|
||||
"linearWatcher": "Linear watcher",
|
||||
"linearWatchers": "Linear watchers",
|
||||
"linearWatchersDescription": "Poll a Linear filter and auto-create a Kandev task for each newly-matching issue.",
|
||||
"loading": "Loading...",
|
||||
"loadingEllipsis": "Loading…",
|
||||
"loadingIssue": "Loading issue…",
|
||||
"loadingIssues": "Loading issues…",
|
||||
"loadingLabels": "Loading labels…",
|
||||
"loadingStates": "Loading states…",
|
||||
"loadingTeams": "Loading teams…",
|
||||
@@ -68,10 +83,19 @@
|
||||
"newWatcher": "New watcher",
|
||||
"noCap": "(no cap)",
|
||||
"noDefault": "No default",
|
||||
"noDescription": "No description.",
|
||||
"noIssuesMatchYourFilters": "No issues match your filters.",
|
||||
"noLabelsAvailableForThisTeam": "No labels available for this team.",
|
||||
"noLinearWatchersConfiguredCreateOne": "No Linear watchers configured. Create one to auto-create tasks from filtered issues.",
|
||||
"noNewIssuesMatched": "No new issues matched.",
|
||||
"noResults": "No results",
|
||||
"notConfiguredNotice": "Linear is not configured. <2>Configure Linear</2> to see your issues here.",
|
||||
"noWorkflowStatesAvailable": "No workflow states available.",
|
||||
"openInLinear": "Open in Linear",
|
||||
"openIssueDetails": "Open issue details",
|
||||
"openLinearIssue": "Open Linear issue",
|
||||
"page": "Page",
|
||||
"pasteALinearIssueUrlOr": "Paste a Linear issue URL or identifier (ENG-123)",
|
||||
"pasteAnApiKeyToTest": "Paste an API key to test the connection",
|
||||
"pause": "Pause",
|
||||
"paused": "Paused",
|
||||
@@ -98,6 +122,8 @@
|
||||
"query": "Query",
|
||||
"queryHelp": "Free-text match across title and description (optional).",
|
||||
"queryPlaceholder": "auth bug",
|
||||
"reEnableItInSettings": "Re-enable it in settings",
|
||||
"refresh": "Refresh",
|
||||
"removeConfiguration": "Remove configuration",
|
||||
"removeLinearConfigurationConfirm": "Remove Linear configuration?",
|
||||
"resetCompleteDeletedTasks_one": "Reset complete — deleted {{count}} task; next poll will re-import matches.",
|
||||
@@ -105,9 +131,10 @@
|
||||
"resetCompleteNoTasksDeleted": "Reset complete — next poll will re-import matches.",
|
||||
"resetFailed": "Reset failed: {{error}}",
|
||||
"resetWatch": "Reset watch",
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"savedLeaveBlank": "(saved — leave blank to keep the current value)",
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"saving": "Saving…",
|
||||
"searchByIdTitleOrDescription": "Search by ID, title, or description",
|
||||
"selectWorkflow": "Select workflow",
|
||||
"selectWorkspace": "Select workspace",
|
||||
"sortCreatedAsc": "Created (oldest first)",
|
||||
@@ -117,6 +144,7 @@
|
||||
"sortPriorityDesc": "Priority (high → low)",
|
||||
"sortUpdatedAsc": "Updated (least recently updated first)",
|
||||
"sortUpdatedDesc": "Updated (recently updated first)",
|
||||
"startTask": "Start task",
|
||||
"states": "States",
|
||||
"statesHelp": "Click states to toggle. Empty matches every state on the team.",
|
||||
"statesHelpNoTeam": "Pick a team to choose specific workflow states.",
|
||||
@@ -130,6 +158,8 @@
|
||||
"unknownAccount": "Unknown account",
|
||||
"unknownError": "Unknown error",
|
||||
"update": "Update",
|
||||
"updatedAgo": "· updated {{time}}",
|
||||
"updatedTime": "Updated {{time}}",
|
||||
"updateFailed": "Update failed: {{error}}",
|
||||
"watchDialogDescription": "Poll Linear with a structured filter and auto-create a Kandev task for each newly-matching issue. Optionally bind a repository so each task runs against that codebase, or leave it unset to run with no repository.",
|
||||
"watcherCreated": "Watcher created",
|
||||
@@ -139,5 +169,6 @@
|
||||
"workflowHelp": "Tasks are created in this workflow.",
|
||||
"workflowStep": "Workflow Step",
|
||||
"workflowStepHelp": "Initial step for new tasks.",
|
||||
"workspaceHelp": "Tasks created by this watcher land in the selected workspace."
|
||||
"workspaceHelp": "Tasks created by this watcher land in the selected workspace.",
|
||||
"yourLinearApiKeyIsInvalid": "Your Linear API key is invalid or has been revoked. Reconnect to view this issue."
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
"active": "Active",
|
||||
"addInstance": "Add instance",
|
||||
"agentProfile": "Agent Profile",
|
||||
"allProjects": "All projects",
|
||||
"any": "(any)",
|
||||
"authToken": "Auth token",
|
||||
"availablePlaceholders": "Available placeholders:",
|
||||
"browseSentryIssues": "Browse Sentry issues",
|
||||
"cantDeleteInstanceInUse_one": "Can't delete \"{{name}}\": {{count}} watch still bound to it. Reassign or remove those watchers first.",
|
||||
"cantDeleteInstanceInUse_other": "Can't delete \"{{name}}\": {{count}} watches still bound to it. Reassign or remove those watchers first.",
|
||||
"checkFailed": "Check failed: {{error}}",
|
||||
@@ -28,6 +30,8 @@
|
||||
"enterNameAndTokenBeforeSaving": "Enter an instance name and auth token before saving.",
|
||||
"environment": "Environment",
|
||||
"environmentHelp": "Optional — restrict to one environment.",
|
||||
"eventsCount_one": "events {{count}}",
|
||||
"eventsCount_other": "events {{count}}",
|
||||
"executorProfile": "Executor Profile",
|
||||
"failedToLoadInstances": "Failed to load Sentry instances: {{error}}",
|
||||
"fallsBackToStepDefault": "Optional — falls back to step default.",
|
||||
@@ -47,6 +51,8 @@
|
||||
"intervalMinutes_one": "{{count}}m",
|
||||
"intervalMinutes_other": "{{count}}m",
|
||||
"lastPolled": "Last Polled",
|
||||
"lastSeen": "Last seen",
|
||||
"level": "Level",
|
||||
"levelDebug": "debug",
|
||||
"levelError": "error",
|
||||
"levelFatal": "fatal",
|
||||
@@ -54,6 +60,7 @@
|
||||
"levelWarning": "warning",
|
||||
"levels": "Levels",
|
||||
"levelsHelp": "Click to toggle. Matches issues at ANY of the selected levels.",
|
||||
"loadMore": "Load more",
|
||||
"loadingEllipsis": "Loading…",
|
||||
"maxInflightTasks": "Max in-flight tasks",
|
||||
"maxInflightTasksHelp": "Cap on open tasks created by this watcher. Leave blank for no cap. New matches are deferred to the next poll when the cap is reached.",
|
||||
@@ -64,18 +71,24 @@
|
||||
"newInstance": "New Instance",
|
||||
"newWatcher": "New watcher",
|
||||
"noCap": "(no cap)",
|
||||
"noHealthySentryInstanceCheckThe": "No healthy Sentry instance — check the integration settings.",
|
||||
"noInstancesInWorkspace": "No Sentry instances in this workspace",
|
||||
"noInstancesYet": "No Sentry instances yet. Add one to connect this workspace.",
|
||||
"noIssuesYetRunASearch": "No issues yet. Run a search to begin.",
|
||||
"noNewIssuesMatched": "No new issues matched.",
|
||||
"noOrganizationsAvailable": "No organizations available",
|
||||
"noProjectsAvailable": "No projects available",
|
||||
"noProjectsAvailableSentence": "No projects available.",
|
||||
"noSentryInstancesConfiguredForThis": "No Sentry instances configured for this workspace.",
|
||||
"noWatchersConfigured": "No Sentry watchers configured. Create one to auto-create tasks from filtered issues.",
|
||||
"orgSlugRequired": "Organization slug is required",
|
||||
"organization": "Organization",
|
||||
"organizationSlug": "Organization slug",
|
||||
"organizationSlugHelp": "The Sentry org to poll.",
|
||||
"pasteAnAuthTokenToTest": "Paste an auth token to test the connection",
|
||||
"pause": "Pause",
|
||||
"paused": "Paused",
|
||||
"period": "Period",
|
||||
"placeholderAssigneeDisplayName": "Assignee display name",
|
||||
"placeholderCulprit": "Culprit (function or file)",
|
||||
"placeholderEventCount": "Total event count",
|
||||
@@ -90,12 +103,15 @@
|
||||
"placeholderUserCount": "Unique users affected",
|
||||
"pollIntervalHelp": "How often to re-run the search. Minimum 60s, maximum 3600s.",
|
||||
"pollIntervalSeconds": "Poll Interval (seconds)",
|
||||
"production": "production",
|
||||
"project": "Project",
|
||||
"projectSlug": "Project slug",
|
||||
"promptFieldHelp": "The prompt sent to the agent for each new issue. Type {{token}} to insert placeholders.",
|
||||
"projectSlugHelp": "The Sentry project(s) to poll. Select one or more.",
|
||||
"projectsSelected_one": "{{count}} project selected",
|
||||
"projectsSelected_other": "{{count}} projects selected",
|
||||
"promptFieldHelp": "The prompt sent to the agent for each new issue. Type {{token}} to insert placeholders.",
|
||||
"query": "Query",
|
||||
"queryExample": "e.g. {{query}}",
|
||||
"queryHelp": "Free-text Sentry search query (optional).",
|
||||
"removeInstanceConfirm": "Remove Sentry instance \"{{name}}\"?",
|
||||
"requiredTokenScopes": "Required token scopes",
|
||||
@@ -111,7 +127,10 @@
|
||||
"scopeOrgRead": "Organization — resolve the org and list issues",
|
||||
"scopeProjectRead": "Project — list projects and scope searches",
|
||||
"searchProjects": "Search projects...",
|
||||
"selectASentryInstanceToBrowse": "Select a Sentry instance to browse its issues.",
|
||||
"selectAnInstance": "Select an instance",
|
||||
"selectAnInstanceToBrowse": "Select an instance to browse",
|
||||
"selectInstanceToSearch": "Select a Sentry instance to search",
|
||||
"selectOrganization": "Select organization",
|
||||
"selectProjects": "Select projects",
|
||||
"selectWorkflow": "Select workflow",
|
||||
@@ -142,6 +161,8 @@
|
||||
"update": "Update",
|
||||
"updateFailed": "Update failed: {{error}}",
|
||||
"useStepDefault": "(use step default)",
|
||||
"usersCount_one": "users {{count}}",
|
||||
"usersCount_other": "users {{count}}",
|
||||
"watchDialogDescription": "Poll Sentry with a structured filter and auto-create a Kandev task for each newly-matching issue. Optionally bind a repository so each task runs against that codebase, or leave it unset to run with no repository.",
|
||||
"watcherCreated": "Watcher created",
|
||||
"watcherDeleted": "Watcher deleted",
|
||||
@@ -150,5 +171,6 @@
|
||||
"workflowHelp": "Tasks are created in this workflow.",
|
||||
"workflowStep": "Workflow Step",
|
||||
"workflowStepHelp": "Initial step for new tasks.",
|
||||
"workspaceHelp": "Tasks created by this watcher land in the selected workspace."
|
||||
"workspaceHelp": "Tasks created by this watcher land in the selected workspace.",
|
||||
"yourSentryAuthTokenIsInvalid": "Your Sentry auth token is invalid or has been revoked. Reconnect to view this issue."
|
||||
}
|
||||
|
||||
@@ -7,10 +7,21 @@
|
||||
"addWorkItemAction": "Àďď ŵōŕķ ĩţēḿ àćţĩōń",
|
||||
"addWorkItemQuery": "Àďď ŵōŕķ ĩţēḿ qũēŕŷ",
|
||||
"addWorkItemWatch": "Àďď ŵōŕķ-ĩţēḿ ŵàţćĥ",
|
||||
"advanced": "Àďvàńćēď",
|
||||
"agentProfileId": "Àĝēńţ ƥŕōƒĩĺē ĨĎ",
|
||||
"assignment": "Àśśĩĝńḿēńţ",
|
||||
"assignToMe": "Àśśĩĝń ţō ḿē",
|
||||
"azureDevopsFilters": "Àźũŕē ĎēvŌƥś ƒĩĺţēŕś",
|
||||
"azureDevopsReviewAndPolicyState": "Àźũŕē ĎēvŌƥś ŕēvĩēŵ àńď ƥōĺĩćŷ śţàţē",
|
||||
"azureDevopsWorkItemDetails": "Àźũŕē ĎēvŌƥś ŵōŕķ-ĩţēḿ ďēţàĩĺś",
|
||||
"azureProjectId": "Àźũŕē ƥŕōĵēćţ ĨĎ",
|
||||
"azureRepositoryIdOptional": "Àźũŕē ŕēƥōśĩţōŕŷ ĨĎ (ōƥţĩōńàĺ)",
|
||||
"baseBranch": "Ɓàśē ƀŕàńćĥ",
|
||||
"board": "Ɓōàŕď",
|
||||
"boardColumn": "Ɓōàŕď ćōĺũḿń",
|
||||
"boardColumns": "Ɓōàŕď ćōĺũḿńś",
|
||||
"boardsAndRepos": "{{organizationUrl}} · Ɓōàŕďś àńď Ŕēƥōś",
|
||||
"branchPolicies": "Ɓŕàńćĥ ƥōĺĩćĩēś",
|
||||
"checkedAt": "ćĥēćķēď {{at}}",
|
||||
"cleanupAlways": "àĺŵàŷś",
|
||||
"cleanupAuto": "àũţō",
|
||||
@@ -20,12 +31,14 @@
|
||||
"cleanupPolicyAuto": "Àũţō",
|
||||
"cleanupPolicyNever": "Ńēvēŕ",
|
||||
"closeWatchEditor": "Ćĺōśē ŵàţćĥ ēďĩţōŕ",
|
||||
"closeWorkItemDetails": "Ćĺōśē ŵōŕķ ĩţēḿ ďēţàĩĺś",
|
||||
"configurationRemoved": "Àźũŕē ĎēvŌƥś ćōńƒĩĝũŕàţĩōń ŕēḿōvēď",
|
||||
"configurationSaved": "Àźũŕē ĎēvŌƥś ćōńƒĩĝũŕàţĩōń śàvēď",
|
||||
"connected": "Ćōńńēćţēď",
|
||||
"connectedAs": "Ćōńńēćţēď àś {{name}}",
|
||||
"connectionFailed": "Ćōńńēćţĩōń ƒàĩĺēď",
|
||||
"couldNotLoadQuickActions": "Ćōũĺď ńōţ ĺōàď ēxĩśţĩńĝ qũĩćķ àćţĩōńś: {{error}}",
|
||||
"couldNotSaveView": "Ćōũĺď ńōţ śàvē ţĥĩś vĩēŵ. Ţŕŷ àĝàĩń.",
|
||||
"createPersonalAccessToken": "Ćŕēàţē ƥēŕśōńàĺ àććēśś ţōķēń",
|
||||
"createWatch": "Ćŕēàţē ŵàţćĥ",
|
||||
"creator": "Ćŕēàţōŕ",
|
||||
@@ -37,11 +50,16 @@
|
||||
"defaultQueryInvalid": "Ēvēŕŷ ďēƒàũĺţ qũēŕŷ ńēēďś à ĺàƀēĺ àńď vàĺĩď ƒĩĺţēŕś.",
|
||||
"delete": "Ďēĺēţē",
|
||||
"deleteWatchConfirm": "Ďēĺēţē ţĥĩś ŵàţćĥ?",
|
||||
"description": "Ďēśćŕĩƥţĩōń",
|
||||
"disable": "Ďĩśàƀĺē",
|
||||
"disabled": "Ďĩśàƀĺēď",
|
||||
"discussion": "Ďĩśćũśśĩōń",
|
||||
"done": "Ďōńē",
|
||||
"draft": "Ďŕàƒţ",
|
||||
"edit": "Ēďĩţ",
|
||||
"editPrompt": "Ēďĩţ ƥŕōḿƥţ",
|
||||
"editWatchTitle": "Ēďĩţ {{kind}} ŵàţćĥ",
|
||||
"eGPlatformTriage": "ē.ĝ. Ƥĺàţƒōŕḿ ţŕĩàĝē",
|
||||
"enable": "Ēńàƀĺē",
|
||||
"enabled": "Ēńàƀĺēď",
|
||||
"enterValidOrganizationUrl": "Ēńţēŕ à vàĺĩď ōŕĝàńĩźàţĩōń ŨŔĹ ţō ōƥēń ĩţś ţōķēń śēţţĩńĝś.",
|
||||
@@ -50,6 +68,15 @@
|
||||
"failedToLoadQuickActions": "Ƒàĩĺēď ţō ĺōàď qũĩćķ àćţĩōńś.",
|
||||
"failedToSaveDefaultQueries": "Ƒàĩĺēď ţō śàvē ďēƒàũĺţ qũēŕĩēś",
|
||||
"failedToSaveQuickActions": "Ƒàĩĺēď ţō śàvē qũĩćķ àćţĩōńś",
|
||||
"fallbackAuthor": "Àźũŕē ĎēvŌƥś",
|
||||
"fallbackUnassigned": "Ũńàśśĩĝńēď",
|
||||
"fallbackUnknown": "Ũńķńōŵń",
|
||||
"fallbackWorkItem": "Ŵōŕķ ĩţēḿ",
|
||||
"feedback": "Ƒēēďƀàćķ",
|
||||
"fieldArea": "Àŕēà",
|
||||
"fieldAssignee": "Àśśĩĝńēē",
|
||||
"fieldState": "Śţàţē",
|
||||
"fieldType": "Ţŷƥē",
|
||||
"group": "Ĝŕōũƥ",
|
||||
"groupCreated": "Ćŕēàţēď",
|
||||
"groupInbox": "Ĩńƀōx",
|
||||
@@ -60,19 +87,49 @@
|
||||
"icon": "Ĩćōń",
|
||||
"identityIdPlaceholder": "{{token}} ōŕ ĩďēńţĩţŷ ĨĎ",
|
||||
"identityPlaceholder": "{{token}} ōŕ ĩďēńţĩţŷ",
|
||||
"inProgress": "Ĩń ƥŕōĝŕēśś",
|
||||
"integrationDescription": "Àźũŕē ĎēvŌƥś Śēŕvĩćēś ōŕĝàńĩźàţĩōń, ƥŕōĵēćţ, àńď ƤÀŢ ƒōŕ ţĥĩś ŵōŕķśƥàćē.",
|
||||
"integrationTitle": "Àźũŕē ĎēvŌƥś ĩńţēĝŕàţĩōń",
|
||||
"kandevRepositoryId": "Ķàńďēv ŕēƥōśĩţōŕŷ ĨĎ",
|
||||
"label": "Ĺàƀēĺ",
|
||||
"linkedItemsCount_one": "{{count}} ĺĩńķēď ĩţēḿ",
|
||||
"linkedItemsCount_other": "{{count}} ĺĩńķēď ĩţēḿś",
|
||||
"linkedKandevTasks": "Ĺĩńķēď Ķàńďēv ţàśķś",
|
||||
"loading": "Ĺōàďĩńĝ...",
|
||||
"loading2": "Ĺōàďĩńĝ…",
|
||||
"loadingBoard": "Ĺōàďĩńĝ ƀōàŕď…",
|
||||
"loadingFeedback": "Ĺōàďĩńĝ ƒēēďƀàćķ...",
|
||||
"loadingLinkedTasks": "Ĺōàďĩńĝ ĺĩńķēď ţàśķś…",
|
||||
"loadingProjects": "Ĺōàďĩńĝ ƥŕōĵēćţś...",
|
||||
"loadingResults": "Ĺōàďĩńĝ ŕēśũĺţś...",
|
||||
"loadingWatchers": "Ĺōàďĩńĝ ŵàţćĥēŕś…",
|
||||
"loadingWorkItem": "Ĺōàďĩńĝ ŵōŕķ ĩţēḿ…",
|
||||
"loadOlderDiscussion": "Ĺōàď ōĺďēŕ ďĩśćũśśĩōń",
|
||||
"maxInflightTasks": "Ḿàx ĩń-ƒĺĩĝĥţ ţàśķś (0 = ũńĺĩḿĩţēď)",
|
||||
"move": "Ḿōvē",
|
||||
"moveToColumn": "Ḿōvē ţō ćōĺũḿń",
|
||||
"name": "Ńàḿē",
|
||||
"newMatches_one": "{{count}} ńēŵ ḿàţćĥ.",
|
||||
"newMatches_other": "{{count}} ńēŵ ḿàţćĥēś.",
|
||||
"newWatchTitle": "Ńēŵ {{kind}} ŵàţćĥ",
|
||||
"nextBoardColumn": "Ńēxţ ƀōàŕď ćōĺũḿń",
|
||||
"nextPullRequestPage": "Ńēxţ ƥũĺĺ ŕēqũēśţ ƥàĝē",
|
||||
"noComments": "Ńō ćōḿḿēńţś.",
|
||||
"noDescription": "Ńō ďēśćŕĩƥţĩōń.",
|
||||
"noDiscussionYet": "Ńō ďĩśćũśśĩōń ŷēţ.",
|
||||
"noLinkedTasks": "Ńō ĺĩńķēď ţàśķś.",
|
||||
"noMatchingResults": "Ńō ḿàţćĥĩńĝ ŕēśũĺţś.",
|
||||
"none": "ńōńē",
|
||||
"noPolicyEvaluations": "Ńō ƥōĺĩćŷ ēvàĺũàţĩōńś.",
|
||||
"noPullRequestWatches": "Ńō ƥũĺĺ-ŕēqũēśţ ŵàţćĥēś ŷēţ.",
|
||||
"noReviewers": "Ńō ŕēvĩēŵēŕś.",
|
||||
"notCheckedYet": "Ńōţ ćĥēćķēď ŷēţ",
|
||||
"notConnectedNotice": "Àźũŕē ĎēvŌƥś ĩś ńōţ ćōńńēćţēď ƒōŕ ţĥĩś ŵōŕķśƥàćē. <2>Ćōńƒĩĝũŕē Àźũŕē ĎēvŌƥś</2>",
|
||||
"noWorkItemWatches": "Ńō ŵōŕķ-ĩţēḿ ŵàţćĥēś ŷēţ.",
|
||||
"openAzureDevopsFilters": "Ōƥēń Àźũŕē ĎēvŌƥś ƒĩĺţēŕś",
|
||||
"openPullRequestInAzureDevops": "Ōƥēń ƥũĺĺ ŕēqũēśţ {{id}} ĩń Àźũŕē ĎēvŌƥś",
|
||||
"openWorkItemInAzureDevops": "Ōƥēń ŵōŕķ ĩţēḿ {{id}} ĩń Àźũŕē ĎēvŌƥś",
|
||||
"openWorkItemInAzureDevops2": "Ōƥēń ŵōŕķ ĩţēḿ ĩń Àźũŕē ĎēvŌƥś",
|
||||
"optional": "Ōƥţĩōńàĺ",
|
||||
"organizationUrl": "Ōŕĝàńĩźàţĩōń ŨŔĹ",
|
||||
"pastePat": "Ƥàśţē ƤÀŢ",
|
||||
@@ -81,18 +138,28 @@
|
||||
"patStepChooseScopes": "Ćĥōōśē ţĥĩś ōŕĝàńĩźàţĩōń, à śĥōŕţ ēxƥĩŕàţĩōń, àńď Ćũśţōḿ ďēƒĩńēď śćōƥēś.",
|
||||
"patStepCreateAndPaste": "Ćŕēàţē ţĥē ţōķēń, ćōƥŷ ĩţ, àńď ƥàśţē ĩţ ĩńţō ţĥĩś ƒĩēĺď.",
|
||||
"patStepOpenTokenSettings": "Ōƥēń ţōķēń śēţţĩńĝś àńď śēĺēćţ Ńēŵ Ţōķēń.",
|
||||
"pending": "ƥēńďĩńĝ",
|
||||
"personalAccessToken": "Ƥēŕśōńàĺ Àććēśś Ţōķēń",
|
||||
"placeholderTitleDescription": "Ţĩţĺē ōƒ ţĥē Àźũŕē ĎēvŌƥś ŵōŕķ ĩţēḿ ōŕ ƥũĺĺ ŕēqũēśţ",
|
||||
"placeholderUrlDescription": "ŨŔĹ ōƒ ţĥē Àźũŕē ĎēvŌƥś ŵōŕķ ĩţēḿ ōŕ ƥũĺĺ ŕēqũēśţ",
|
||||
"planningAndEffort": "Ƥĺàńńĩńĝ àńď ēƒƒōŕţ",
|
||||
"policiesLabelled": "Ƥōĺĩćĩēś: {{state}}",
|
||||
"pollIntervalSeconds": "Ƥōĺĺ ĩńţēŕvàĺ (śēćōńďś)",
|
||||
"previousBoardColumn": "Ƥŕēvĩōũś ƀōàŕď ćōĺũḿń",
|
||||
"previousPullRequestPage": "Ƥŕēvĩōũś ƥũĺĺ ŕēqũēśţ ƥàĝē",
|
||||
"project": "Ƥŕōĵēćţ",
|
||||
"prompt": "Ƥŕōḿƥţ",
|
||||
"promptPlaceholdersHint": "Ţŷƥē {{open}} ţō śēē àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś. <3>{{url}}</3> àńď <6>{{title}}</6> àŕē śũƀśţĩţũţēď ŵĥēń ţĥē àćţĩōń ŕũńś.",
|
||||
"pullRequestAny": "àńŷ",
|
||||
"pullRequestFeedback": "Ƥũĺĺ ŕēqũēśţ ƒēēďƀàćķ",
|
||||
"pullRequestFilter": "Ƥũĺĺ-ŕēqũēśţ ƒĩĺţēŕ",
|
||||
"pullRequestFilters": "ƥũĺĺ-ŕēqũēśţ ƒĩĺţēŕś",
|
||||
"pullRequestKindLower": "ƥũĺĺ ŕēqũēśţ",
|
||||
"pullRequestKindNoun": "ƥũĺĺ-ŕēqũēśţ",
|
||||
"pullRequestKindTitle": "Ƥũĺĺ ŕēqũēśţ",
|
||||
"pullRequests": "Ƥũĺĺ ŕēqũēśţś",
|
||||
"pullRequestsTab": "Ƥũĺĺ ŕēqũēśţś",
|
||||
"pullRequestSubtitle": "{{repository}} · ƤŔ {{id}}",
|
||||
"pullRequestWatches": "Ƥũĺĺ-ŕēqũēśţ ŵàţćĥēś",
|
||||
"pullRequestWatchesDescription": "Ćŕēàţē ţàśķś àũţōḿàţĩćàĺĺŷ ƒŕōḿ ḿàţćĥĩńĝ Àźũŕē ĎēvŌƥś ƥũĺĺ ŕēqũēśţś.",
|
||||
"pullRequestWatchFieldsRequired": "Ƥŕōĵēćţ, ŕēƥōśĩţōŕŷ, ŵōŕķƒĺōŵ, śţēƥ, àĝēńţ, àńď ēxēćũţōŕ àŕē ŕēqũĩŕēď.",
|
||||
@@ -112,16 +179,29 @@
|
||||
"removeConfigurationConfirm": "Ŕēḿōvē Àźũŕē ĎēvŌƥś ćōńƒĩĝũŕàţĩōń?",
|
||||
"removeFailed": "Ŕēḿōvē ƒàĩĺēď: {{error}}",
|
||||
"removeQueryAria": "Ŕēḿōvē {{kind}} qũēŕŷ {{index}}",
|
||||
"repository": "Ŕēƥōśĩţōŕŷ",
|
||||
"resetWatchConfirm_one": "Ŕēśēţ ţĥĩś {{policy}} ŵàţćĥ àńď ŕēḿōvē {{count}} ţàśķ?",
|
||||
"resetWatchConfirm_other": "Ŕēśēţ ţĥĩś {{policy}} ŵàţćĥ àńď ŕēḿōvē {{count}} ţàśķś?",
|
||||
"resultLimit": "Ŕēśũĺţ ĺĩḿĩţ",
|
||||
"resultsCount_one": "{{count}} ŕēśũĺţ",
|
||||
"resultsCount_other": "{{count}} ŕēśũĺţś",
|
||||
"retry": "Ŕēţŕŷ",
|
||||
"reviewer": "Ŕēvĩēŵēŕ",
|
||||
"reviewerId": "Ŕēvĩēŵēŕ ĨĎ",
|
||||
"reviewers": "Ŕēvĩēŵēŕś",
|
||||
"reviewLabelled": "Ŕēvĩēŵ: {{state}}",
|
||||
"runNow": "Ŕũń ńōŵ",
|
||||
"save": "Śàvē",
|
||||
"saveAzureDevopsView": "Śàvē Àźũŕē ĎēvŌƥś vĩēŵ",
|
||||
"savedCredential": "Śàvēď ćŕēďēńţĩàĺ",
|
||||
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
|
||||
"saveViewDescription": "Śàvē ţĥē ćũŕŕēńţ {{kind}} ƒōŕ ţĥĩś ŵōŕķśƥàćē.",
|
||||
"saving": "Śàvĩńĝ...",
|
||||
"savingEllipsis": "Śàvĩńĝ…",
|
||||
"selectAProjectToViewIts": "Śēĺēćţ à ƥŕōĵēćţ ţō vĩēŵ ĩţś ƀōàŕď.",
|
||||
"selectProject": "Śēĺēćţ ƥŕōĵēćţ",
|
||||
"selectRepository": "Śēĺēćţ ŕēƥōśĩţōŕŷ",
|
||||
"startTask": "Śţàŕţ ţàśķ",
|
||||
"status": "Śţàţũś",
|
||||
"statusAbandoned": "Àƀàńďōńēď",
|
||||
"statusActive": "Àćţĩvē",
|
||||
@@ -129,11 +209,25 @@
|
||||
"statusAny": "Àńŷ śţàţũś",
|
||||
"statusCompleted": "Ćōḿƥĺēţēď",
|
||||
"statusLine": "Śţàţũś: {{status}}",
|
||||
"task": "Ţàśķ",
|
||||
"taskActions": "Ţàśķ àćţĩōńś",
|
||||
"taskActionsForPullRequest": "Ţàśķ àćţĩōńś ƒōŕ ƥũĺĺ ŕēqũēśţ {{id}}",
|
||||
"taskActionsForWorkItem": "Ţàśķ àćţĩōńś ƒōŕ ŵōŕķ ĩţēḿ {{id}}",
|
||||
"team": "Ţēàḿ",
|
||||
"testConnection": "Ţēśţ ćōńńēćţĩōń",
|
||||
"testing": "Ţēśţĩńĝ...",
|
||||
"top": "Ţōƥ",
|
||||
"unassign": "Ũńàśśĩĝń",
|
||||
"unassigned": "Ũńàśśĩĝńēď",
|
||||
"untitledWorkItem": "Ũńţĩţĺēď ŵōŕķ ĩţēḿ",
|
||||
"update": "Ũƥďàţē",
|
||||
"updateWatch": "Ũƥďàţē ŵàţćĥ",
|
||||
"updating": "Ũƥďàţĩńĝ…",
|
||||
"voteApproved": "Àƥƥŕōvēď",
|
||||
"voteApprovedWithSuggestions": "Àƥƥŕōvēď ŵĩţĥ śũĝĝēśţĩōńś",
|
||||
"voteNone": "Ńō vōţē",
|
||||
"voteRejected": "Ŕēĵēćţēď",
|
||||
"voteWaitingForAuthor": "Ŵàĩţĩńĝ ƒōŕ àũţĥōŕ",
|
||||
"watchDeleted": "Ŵàţćĥ ďēĺēţēď.",
|
||||
"watchDisabled": "Ŵàţćĥ ďĩśàƀĺēď.",
|
||||
"watchEnabled": "Ŵàţćĥ ēńàƀĺēď.",
|
||||
@@ -149,7 +243,10 @@
|
||||
"workItemKindNoun": "ŵōŕķ-ĩţēḿ",
|
||||
"workItemKindTitle": "Ŵōŕķ ĩţēḿ",
|
||||
"workItemQuery": "Ŵōŕķ-ĩţēḿ qũēŕŷ",
|
||||
"workItemQuery2": "ŵōŕķ-ĩţēḿ qũēŕŷ",
|
||||
"workItems": "Ŵōŕķ ĩţēḿś",
|
||||
"workItemsTab": "Ŵōŕķ ĩţēḿś",
|
||||
"workItemUnavailable": "Ŵōŕķ ĩţēḿ ũńàvàĩĺàƀĺē.",
|
||||
"workItemWatches": "Ŵōŕķ-ĩţēḿ ŵàţćĥēś",
|
||||
"workItemWatchesDescription": "Ćŕēàţē ţàśķś àũţōḿàţĩćàĺĺŷ ƒŕōḿ ḿàţćĥĩńĝ Àźũŕē ĎēvŌƥś ŵōŕķ-ĩţēḿ qũēŕĩēś.",
|
||||
"workItemWatchFieldsRequired": "Ƥŕōĵēćţ, ŴĨQĹ, ŕēƥōśĩţōŕŷ, ŵōŕķƒĺōŵ, śţēƥ, àĝēńţ, àńď ēxēćũţōŕ àŕē ŕēqũĩŕēď."
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"commandGoToStats": "Ĝō ţō Śţàţś",
|
||||
"commandGoToStatsKeywords": "śţàţś, śţàţĩśţĩćś, àńàĺŷţĩćś, ḿēţŕĩćś",
|
||||
"commandGroupActions": "Àćţĩōńś",
|
||||
"commandGroupCommands": "Ćōmmàńďś",
|
||||
"commandGroupCommands": "Ćōḿḿàńďś",
|
||||
"commandGroupNavigation": "Ńàvĩĝàţĩōń",
|
||||
"commandGroupPreferences": "Ƥŕēƒēŕēńćēś",
|
||||
"commandGroupSettings": "Śēţţĩńĝś",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"authRequiredHeading": "{{name}} àũţĥēńţĩćàţĩōń ŕēqũĩŕēď",
|
||||
"authRequiredInline": "{{name}} àũţĥēńţĩćàţĩōń ŕēqũĩŕēď.",
|
||||
"deleteSavedQuery": "Ďēĺēţē śàvēď qũēŕŷ",
|
||||
"noSavedQueriesYet": "Ńō śàvēď qũēŕĩēś ŷēţ",
|
||||
"reconnectProvider": "Ŕēćōńńēćţ {{name}}",
|
||||
"saveCurrentQuery": "Śàvē ćũŕŕēńţ qũēŕŷ",
|
||||
"savedQueries": "Śàvēď"
|
||||
}
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
{
|
||||
"aCredentialIsRequired": "À ćŕēďēńţĩàĺ ĩś ŕēqũĩŕēď.",
|
||||
"aJiraSiteUrlIsRequired": "À Ĵĩŕà śĩţē ŨŔĹ ĩś ŕēqũĩŕēď.",
|
||||
"actions": "Àćţĩōńś",
|
||||
"active": "Àćţĩvē",
|
||||
"addPreset": "Àďď ƥŕēśēţ",
|
||||
"agentProfile": "Àĝēńţ Ƥŕōƒĩĺē",
|
||||
"aJiraSiteUrlIsRequired": "À Ĵĩŕà śĩţē ŨŔĹ ĩś ŕēqũĩŕēď.",
|
||||
"anEmailAddressIsRequired": "Àń ēḿàĩĺ àďďŕēśś ĩś ŕēqũĩŕēď.",
|
||||
"apiToken": "ÀƤĨ ţōķēń",
|
||||
"apiTokenRecommended": "ÀƤĨ ţōķēń (ŕēćōḿḿēńďēď)",
|
||||
"apply": "Àƥƥĺŷ",
|
||||
"assignee": "Àśśĩĝńēē",
|
||||
"assigneeAnyone": "Àńŷōńē",
|
||||
"assigneeMe": "Ḿē",
|
||||
"assigneeUnassigned": "Ũńàśśĩĝńēď",
|
||||
"atlassianCloud": "Àţĺàśśĩàń Ćĺōũď",
|
||||
"atlassianJqlHelp": "Àţĺàśśĩàń ĴQĹ. Ţĥē ŵàţćĥēŕ ƥōĺĺś ţĥĩś qũēŕŷ àńď ćŕēàţēś ōńē ţàśķ ƥēŕ ńēŵĺŷ-ḿàţćĥĩńĝ ţĩćķēţ.",
|
||||
"authenticationMethod": "Àũţĥēńţĩćàţĩōń ḿēţĥōď",
|
||||
"availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
|
||||
"browserSessionCookie": "Ɓŕōŵśēŕ śēśśĩōń ćōōķĩē",
|
||||
"builtIn": "Ɓũĩĺţ-ĩń",
|
||||
"capOnOpenTasksCreatedBy": "Ćàƥ ōń ōƥēń ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ. Ĺēàvē ƀĺàńķ ƒōŕ ńō ćàƥ. Ńēŵ ḿàţćĥēś àŕē ďēƒēŕŕēď ţō ţĥē ńēxţ ƥōĺĺ ŵĥēń ţĥē ćàƥ ĩś ŕēàćĥēď.",
|
||||
"checkFailed": "Ćĥēćķ ƒàĩĺēď: {{error}}",
|
||||
"checkingJiraStatus": "Ćĥēćķĩńĝ Ĵĩŕà śţàţũś…",
|
||||
"checkNow": "Ćĥēćķ ńōŵ",
|
||||
"connectThisWorkspaceToAtlassianCloud": "Ćōńńēćţ ţĥĩś ŵōŕķśƥàćē ţō Àţĺàśśĩàń Ćĺōũď ōŕ à śēĺƒ-ĥōśţēď Ĵĩŕà Śēŕvēŕ / Ďàţà Ćēńţēŕ ĩńśţàńćē. Ćŕēďēńţĩàĺś àŕē śţōŕēď ēńćŕŷƥţēď śēŕvēŕ-śĩďē ƒōŕ ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
|
||||
"clear": "Ćĺēàŕ {{lowerCase}}",
|
||||
"clearFilters": "Ćĺēàŕ ƒĩĺţēŕś",
|
||||
"connectedAs": "Ćōńńēćţēď àś {{name}}",
|
||||
"connectThisWorkspaceToAtlassianCloud": "Ćōńńēćţ ţĥĩś ŵōŕķśƥàćē ţō Àţĺàśśĩàń Ćĺōũď ōŕ à śēĺƒ-ĥōśţēď Ĵĩŕà Śēŕvēŕ / Ďàţà Ćēńţēŕ ĩńśţàńćē. Ćŕēďēńţĩàĺś àŕē śţōŕēď ēńćŕŷƥţēď śēŕvēŕ-śĩďē ƒōŕ ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
|
||||
"cookieExpired": "Ćōōķĩē ēxƥĩŕēď — ƥàśţē à ƒŕēśĥ ōńē",
|
||||
"cookieExpiresInDays_one": "Ćōōķĩē ēxƥĩŕēś ĩń {{count}} ďàŷ",
|
||||
"cookieExpiresInDays_other": "Ćōōķĩē ēxƥĩŕēś ĩń {{count}} ďàŷś",
|
||||
@@ -30,10 +39,14 @@
|
||||
"createJiraWatcher": "Ćŕēàţē ĴĨŔÀ Ŵàţćĥēŕ",
|
||||
"createPatNoProfileLink": "Ćŕēàţē à Ƥēŕśōńàĺ Àććēśś Ţōķēń ƒŕōḿ ŷōũŕ Ĵĩŕà ƥŕōƒĩĺē → Ƥēŕśōńàĺ Àććēśś Ţōķēńś. Ŕēqũĩŕēď śćōƥēś: ŕēàď & ŵŕĩţē.",
|
||||
"createPatWithProfileLink": "Ćŕēàţē à Ƥēŕśōńàĺ Àććēśś Ţōķēń ƒŕōḿ ŷōũŕ Ĵĩŕà ƥŕōƒĩĺē (<1>{{url}}</1>) → Ƥēŕśōńàĺ Àććēśś Ţōķēńś. Ŕēqũĩŕēď śćōƥēś: ŕēàď & ŵŕĩţē.",
|
||||
"customJqlOverridePillFiltersAre": "Ćũśţōḿ ĴQĹ ōvēŕŕĩďē — ƥĩĺĺ ƒĩĺţēŕś àŕē ƥàũśēď.",
|
||||
"defaultProjectKeyOptional": "Ďēƒàũĺţ ƥŕōĵēćţ ķēŷ (ōƥţĩōńàĺ)",
|
||||
"delete": "Ďēĺēţē",
|
||||
"deleteFailed": "Ďēĺēţē ƒàĩĺēď: {{error}}",
|
||||
"deleteThisJiraWatcher": "Ďēĺēţē ţĥĩś ĴĨŔÀ ŵàţćĥēŕ?",
|
||||
"deleteView": "Ďēĺēţē vĩēŵ",
|
||||
"description": "Ďēśćŕĩƥţĩōń",
|
||||
"details": "Ďēţàĩĺś",
|
||||
"editJiraWatcher": "Ēďĩţ ĴĨŔÀ Ŵàţćĥēŕ",
|
||||
"editPrompt": "Ēďĩţ ƥŕōḿƥţ",
|
||||
"email": "Ēḿàĩĺ",
|
||||
@@ -44,6 +57,8 @@
|
||||
"expiryUnknown": "Ēxƥĩŕŷ ũńķńōŵń",
|
||||
"failedToLoadJiraConfig": "Ƒàĩĺēď ţō ĺōàď Ĵĩŕà ćōńƒĩĝ: {{error}}",
|
||||
"failedToSaveTaskPresets": "Ƒàĩĺēď ţō śàvē ţàśķ ƥŕēśēţś",
|
||||
"fallbackTicket": "Ĵĩŕà ţĩćķēţ",
|
||||
"filterDisabled": "{{label}} ƒĩĺţēŕ ďĩśàƀĺēď",
|
||||
"foundNewTickets_one": "Ƒōũńď {{count}} ńēŵ ţĩćķēţ — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
|
||||
"foundNewTickets_other": "Ƒōũńď {{count}} ńēŵ ţĩćķēţś — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
|
||||
"hidePrompt": "Ĥĩďē ƥŕōḿƥţ",
|
||||
@@ -52,6 +67,10 @@
|
||||
"hintOptional": "Ĥĩńţ (ōƥţĩōńàĺ)",
|
||||
"howOftenToReRunThe": "Ĥōŵ ōƒţēń ţō ŕē-ŕũń ţĥē ĴQĹ. Ḿĩńĩḿũḿ 60ś, ḿàxĩḿũḿ 3600ś.",
|
||||
"icon": "Ĩćōń",
|
||||
"import": "Ĩḿƥōŕţ",
|
||||
"importFromJira": "Ĩḿƥōŕţ ƒŕōḿ Ĵĩŕà",
|
||||
"importFromJiraTicketUrlOr": "Ĩḿƥōŕţ ƒŕōḿ Ĵĩŕà ţĩćķēţ ŨŔĹ ōŕ ķēŷ",
|
||||
"importJiraTicket": "Ĩḿƥōŕţ Ĵĩŕà ţĩćķēţ",
|
||||
"initialStepForNewTasks": "Ĩńĩţĩàĺ śţēƥ ƒōŕ ńēŵ ţàśķś.",
|
||||
"instanceType": "Ĩńśţàńćē ţŷƥē",
|
||||
"interval": "Ĩńţēŕvàĺ",
|
||||
@@ -64,24 +83,42 @@
|
||||
"jiraWatchers": "ĴĨŔÀ ŵàţćĥēŕś",
|
||||
"jiraWatchersDescription": "Ƥōĺĺ à ĴQĹ qũēŕŷ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ţĩćķēţ.",
|
||||
"jqlError": "ĴQĹ ēŕŕōŕ: {{error}}",
|
||||
"jqlIsGeneratedFromThePills": "ĴQĹ ĩś ĝēńēŕàţēď ƒŕōḿ ţĥē ƥĩĺĺś àƀōvē. Ēďĩţ ţō ōvēŕŕĩďē.",
|
||||
"label": "Ĺàƀēĺ",
|
||||
"lastPolled": "Ĺàśţ Ƥōĺĺēď",
|
||||
"loading": "Ĺōàďĩńĝ...",
|
||||
"loading2": "Ĺōàďĩńĝ…",
|
||||
"loadingTicket": "Ĺōàďĩńĝ ţĩćķēţ…",
|
||||
"matchedTickets_one": "Ḿàţćĥēď {{count}} ţĩćķēţ ĩń ţĥĩś ƥàĝē.",
|
||||
"matchedTickets_other": "Ḿàţćĥēď {{count}} ţĩćķēţś ĩń ţĥĩś ƥàĝē.",
|
||||
"maxInFlightTasks": "Ḿàx ĩń-ƒĺĩĝĥţ ţàśķś",
|
||||
"myOpenBugs": "Ḿŷ ōƥēń ƀũĝś",
|
||||
"never": "Ńēvēŕ",
|
||||
"newWatcher": "Ńēŵ ŵàţćĥēŕ",
|
||||
"noCap": "(ńō ćàƥ)",
|
||||
"noDescription": "Ńō ďēśćŕĩƥţĩōń.",
|
||||
"noJiraWatchersConfiguredCreateOne": "Ńō ĴĨŔÀ ŵàţćĥēŕś ćōńƒĩĝũŕēď. Ćŕēàţē ōńē ţō àũţō-ćŕēàţē ţàśķś ƒŕōḿ ĴQĹ qũēŕĩēś.",
|
||||
"noNewTicketsMatched": "Ńō ńēŵ ţĩćķēţś ḿàţćĥēď.",
|
||||
"noProjectsMatch": "Ńō ƥŕōĵēćţś ḿàţćĥ.",
|
||||
"noResults": "Ńō ŕēśũĺţś",
|
||||
"noStatusesForProject": "Ńō śţàţũśēś àvàĩĺàƀĺē ƒōŕ ţĥē śēĺēćţēď ƥŕōĵēćţ",
|
||||
"noStatusesMatch": "Ńō śţàţũśēś ḿàţćĥ.",
|
||||
"notConfiguredNotice": "Ĵĩŕà ĩś ńōţ ćōńƒĩĝũŕēď. <2>Ćōńƒĩĝũŕē Ĵĩŕà</2> ţō śēē ŷōũŕ ţĩćķēţś ĥēŕē.",
|
||||
"noTicketsFound": "Ńō ţĩćķēţś ƒōũńď.",
|
||||
"noView": "Ńō vĩēŵ",
|
||||
"openInAtlassian": "Ōƥēń ĩń Àţĺàśśĩàń",
|
||||
"openJiraTicket": "Ōƥēń Ĵĩŕà ţĩćķēţ",
|
||||
"openTicketDetails": "Ōƥēń ţĩćķēţ ďēţàĩĺś",
|
||||
"optionalFallsBackToStepDefault": "Ōƥţĩōńàĺ — ƒàĺĺś ƀàćķ ţō śţēƥ ďēƒàũĺţ.",
|
||||
"pasteATokenToTestTheConnection": "Ƥàśţē à ţōķēń ţō ţēśţ ţĥē ćōńńēćţĩōń",
|
||||
"pageNumber": "Ƥàĝē {{page}}",
|
||||
"pasteAJiraTicketUrlOr": "Ƥàśţē à Ĵĩŕà ţĩćķēţ ŨŔĹ ōŕ ķēŷ (ƤŔŌĴ-123)",
|
||||
"pasteApiTokenHere": "ƥàśţē ÀƤĨ ţōķēń ĥēŕē",
|
||||
"pasteATokenToTestTheConnection": "Ƥàśţē à ţōķēń ţō ţēśţ ţĥē ćōńńēćţĩōń",
|
||||
"pastePersonalAccessTokenHere": "ƥàśţē ƥēŕśōńàĺ àććēśś ţōķēń ĥēŕē",
|
||||
"pasteSessionTokenValue": "ƥàśţē {{cookieName}} vàĺũē",
|
||||
"pause": "Ƥàũśē",
|
||||
"pauseOrResumePolling": "Ƥàũśē ōŕ ŕēśũḿē ƥōĺĺĩńĝ.",
|
||||
"paused": "Ƥàũśēď",
|
||||
"pauseOrResumePolling": "Ƥàũśē ōŕ ŕēśũḿē ƥōĺĺĩńĝ.",
|
||||
"personalAccessToken": "Ƥēŕśōńàĺ Àććēśś Ţōķēń",
|
||||
"placeholderAssigneeDisplayName": "Àśśĩĝńēē ďĩśƥĺàŷ ńàḿē",
|
||||
"placeholderIssueType": "Ĩśśũē ţŷƥē",
|
||||
@@ -107,26 +144,44 @@
|
||||
"presetPlaceholderTicketSummary": "Ţĩćķēţ śũḿḿàŕŷ",
|
||||
"presetPlaceholderTicketUrl": "ŨŔĹ ōƒ ţĥē Ĵĩŕà ţĩćķēţ",
|
||||
"presetPromptPlaceholderHelp": "Ţŷƥē {{token}} ţō śēē àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś. <4>{{key}}</4>, <7>{{url}}</7>, <10>{{title}}</10>, àńď <13>{{description}}</13> àŕē śũƀśţĩţũţēď ŵĥēń ţĥē àćţĩōń ŕũńś.",
|
||||
"proj123OrPasteTicketUrl": "ƤŔŌĴ-123 ōŕ ƥàśţē ţĩćķēţ ŨŔĹ",
|
||||
"project": "Ƥŕōĵēćţ",
|
||||
"promptFieldHelp": "Ţĥē ƥŕōḿƥţ śēńţ ţō ţĥē àĝēńţ ƒōŕ ēàćĥ ńēŵ ţĩćķēţ. Ţŷƥē {{token}} ţō ĩńśēŕţ ƥĺàćēĥōĺďēŕś.",
|
||||
"refresh": "Ŕēƒŕēśĥ",
|
||||
"remove": "Ŕēḿōvē",
|
||||
"removeConfiguration": "Ŕēḿōvē ćōńƒĩĝũŕàţĩōń",
|
||||
"removeJiraConfiguration": "Ŕēḿōvē Ĵĩŕà ćōńƒĩĝũŕàţĩōń?",
|
||||
"reporter": "Ŕēƥōŕţēŕ",
|
||||
"resetCompleteDeletedTasks_one": "Ŕēśēţ ćōḿƥĺēţē — ďēĺēţēď {{count}} ţàśķ; ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
|
||||
"resetCompleteDeletedTasks_other": "Ŕēśēţ ćōḿƥĺēţē — ďēĺēţēď {{count}} ţàśķś; ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
|
||||
"resetCompleteNoTasksDeleted": "Ŕēśēţ ćōḿƥĺēţē — ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
|
||||
"resetFailed": "Ŕēśēţ ƒàĩĺēď: {{error}}",
|
||||
"resetToPills": "Ŕēśēţ ţō ƥĩĺĺś",
|
||||
"resetWatch": "Ŕēśēţ ŵàţćĥ",
|
||||
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
|
||||
"saveCurrentFiltersAs": "Śàvē ćũŕŕēńţ ƒĩĺţēŕś àś…",
|
||||
"saveCurrentFiltersAsAView": "Śàvē ćũŕŕēńţ ƒĩĺţēŕś àś à vĩēŵ",
|
||||
"saved": "Śàvēď",
|
||||
"savedLeaveBlank": "(śàvēď — ĺēàvē ƀĺàńķ ţō ķēēƥ ţĥē ćũŕŕēńţ vàĺũē)",
|
||||
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
|
||||
"saveView": "Śàvē vĩēŵ",
|
||||
"saving": "Śàvĩńĝ…",
|
||||
"searchProjects": "Śēàŕćĥ ƥŕōĵēćţś…",
|
||||
"searchStatuses": "Śēàŕćĥ śţàţũśēś…",
|
||||
"searchTicketKeyOrText": "Śēàŕćĥ ţĩćķēţ ķēŷ ōŕ ţēxţ…",
|
||||
"selectProjectToFilterStatus": "Śēĺēćţ à ƥŕōĵēćţ ţō ƒĩĺţēŕ ƀŷ śţàţũś",
|
||||
"selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ",
|
||||
"selectWorkspace": "Śēĺēćţ ŵōŕķśƥàćē",
|
||||
"selfHostedJiraServerOrDataCenter": "Śēĺƒ-ĥōśţēď Ĵĩŕà (Śēŕvēŕ ōŕ Ďàţà Ćēńţēŕ).",
|
||||
"serverDataCenter": "Śēŕvēŕ / Ďàţà Ćēńţēŕ",
|
||||
"sessionTokenValue": "Śēśśĩōń ţōķēń vàĺũē",
|
||||
"showSessionTokenHelp": "Śĥōŵ ĥōŵ ţō ćōƥŷ ţĥē śēśśĩōń ţōķēń",
|
||||
"siteUrl": "Śĩţē ŨŔĹ",
|
||||
"sitesHostedAt": "Śĩţēś ĥōśţēď àţ {{domain}}.",
|
||||
"siteUrl": "Śĩţē ŨŔĹ",
|
||||
"sortCreated": "Ćŕēàţēď",
|
||||
"sortLabelled": "Śōŕţ: {{label}}",
|
||||
"sortPriority": "Ƥŕĩōŕĩţŷ",
|
||||
"sortUpdated": "Ũƥďàţēď",
|
||||
"startTask": "Śţàŕţ ţàśķ",
|
||||
"taskPresets": "Ţàśķ ƥŕēśēţś",
|
||||
"taskPresetsDescription": "Ƥŕōḿƥţś śĥōŵń ōń {{route}} ŵĥēń śţàŕţĩńĝ à ţàśķ ƒŕōḿ à ţĩćķēţ.",
|
||||
"taskPresetsSaved": "Ţàśķ ƥŕēśēţś śàvēď",
|
||||
@@ -135,15 +190,23 @@
|
||||
"tasksCreatedByThisWatcherLand": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
|
||||
"testConnection": "Ţēśţ ćōńńēćţĩōń",
|
||||
"testFailed": "Ƒàĩĺēď: {{error}}",
|
||||
"testJql": "Ţēśţ ĴQĹ",
|
||||
"testingConnection": "Ţēśţĩńĝ...",
|
||||
"testingJql": "Ţēśţĩńĝ…",
|
||||
"testJql": "Ţēśţ ĴQĹ",
|
||||
"ticketOnThisPage_one": "{{count}} ţĩćķēţ ōń ţĥĩś ƥàĝē",
|
||||
"ticketOnThisPage_other": "{{count}} ţĩćķēţś ōń ţĥĩś ƥàĝē",
|
||||
"ticketsAcrossYourAtlassianProjects": "Ţĩćķēţś àćŕōśś ŷōũŕ Àţĺàśśĩàń ƥŕōĵēćţś.",
|
||||
"toggleRawJqlEditor": "Ţōĝĝĺē ŕàŵ ĴQĹ ēďĩţōŕ",
|
||||
"unassigned": "Ũńàśśĩĝńēď",
|
||||
"update": "Ũƥďàţē",
|
||||
"updatedAgo": "· ũƥďàţēď {{time}}",
|
||||
"updatedTime": "Ũƥďàţēď {{time}}",
|
||||
"updateFailed": "Ũƥďàţē ƒàĩĺēď: {{error}}",
|
||||
"watchDialogDescription": "Ƥōĺĺ à ĴQĹ qũēŕŷ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ţĩćķēţ. Ōƥţĩōńàĺĺŷ ƀĩńď à ŕēƥōśĩţōŕŷ śō ēàćĥ ţàśķ ŕũńś àĝàĩńśţ ţĥàţ ćōďēƀàśē, ōŕ ĺēàvē ĩţ ũńśēţ ţō ŕũń ŵĩţĥ ńō ŕēƥōśĩţōŕŷ.",
|
||||
"watcherCreated": "Ŵàţćĥēŕ ćŕēàţēď",
|
||||
"watcherDeleted": "Ŵàţćĥēŕ ďēĺēţēď",
|
||||
"watcherUpdated": "Ŵàţćĥēŕ ũƥďàţēď",
|
||||
"workflow": "Ŵōŕķƒĺōŵ",
|
||||
"workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ"
|
||||
"workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ",
|
||||
"yourJiraSessionExpiredOrNeeds": "Ŷōũŕ Ĵĩŕà śēśśĩōń ēxƥĩŕēď ōŕ ńēēďś śţēƥ-ũƥ àũţĥēńţĩćàţĩōń. Ŕēćōńńēćţ ţō vĩēŵ ţĥĩś ţĩćķēţ."
|
||||
}
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
"actions": "Àćţĩōńś",
|
||||
"active": "Àćţĩvē",
|
||||
"agentProfile": "Àĝēńţ Ƥŕōƒĩĺē",
|
||||
"allTeams": "Àĺĺ ţēàḿś",
|
||||
"anApiKeyIsRequired": "Àń ÀƤĨ ķēŷ ĩś ŕēqũĩŕēď.",
|
||||
"any": "(àńŷ)",
|
||||
"anyAssignee": "Àńŷ àśśĩĝńēē",
|
||||
"anyTeam": "(àńŷ ţēàḿ)",
|
||||
"apiKey": "ÀƤĨ ķēŷ",
|
||||
"assignedToMe": "Àśśĩĝńēď ţō ḿē",
|
||||
"assignee": "Àśśĩĝńēē",
|
||||
"assigneeHelp": "Ƒĩĺţēŕ ƀŷ ŵĥō àń ĩśśũē ĩś àśśĩĝńēď ţō.",
|
||||
"availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
|
||||
@@ -26,12 +29,15 @@
|
||||
"delete": "Ďēĺēţē",
|
||||
"deleteFailed": "Ďēĺēţē ƒàĩĺēď: {{error}}",
|
||||
"deleteThisLinearWatcher": "Ďēĺēţē ţĥĩś Ĺĩńēàŕ ŵàţćĥēŕ?",
|
||||
"description": "Ďēśćŕĩƥţĩōń",
|
||||
"details": "Ďēţàĩĺś",
|
||||
"dispatchOrder": "Ďĩśƥàţćĥ ōŕďēŕ",
|
||||
"dispatchOrderHelp": "Ŵĥēń ţĥē ĩń-ƒĺĩĝĥţ ćàƥ ĩś ŕēàćĥēď, ĩśśũēś àŕē ďĩśƥàţćĥēď ĩń ţĥĩś ōŕďēŕ śō ţĥē ḿōśţ ĩḿƥōŕţàńţ ōńēś ŕũń ƒĩŕśţ.",
|
||||
"editLinearWatcher": "Ēďĩţ Ĺĩńēàŕ Ŵàţćĥēŕ",
|
||||
"enable": "Ēńàƀĺē",
|
||||
"enabled": "Ēńàƀĺēď",
|
||||
"enabledHelp": "Ƥàũśē ōŕ ŕēśũḿē ƥōĺĺĩńĝ.",
|
||||
"eng123OrPasteIssueUrl": "ĒŃĜ-123 ōŕ ƥàśţē ĩśśũē ŨŔĹ",
|
||||
"enterAPositiveIntegerOrBlank": "Ēńţēŕ à ƥōśĩţĩvē ĩńţēĝēŕ ōŕ ĺēàvē ƀĺàńķ.",
|
||||
"estimateMax": "Ēśţĩḿàţē ḿàx",
|
||||
"estimateMaxHelp": "Ũƥƥēŕ ƀōũńď ĩń ƥōĩńţś (ōƥţĩōńàĺ).",
|
||||
@@ -41,10 +47,16 @@
|
||||
"estimateMinPlaceholder": "ē.ĝ. 1",
|
||||
"executorProfile": "Ēxēćũţōŕ Ƥŕōƒĩĺē",
|
||||
"failedToLoadConfig": "Ƒàĩĺēď ţō ĺōàď Ĺĩńēàŕ ćōńƒĩĝ: {{error}}",
|
||||
"fallbackIssue": "Ĺĩńēàŕ ĩśśũē",
|
||||
"fallsBackToStepDefault": "Ōƥţĩōńàĺ — ƒàĺĺś ƀàćķ ţō śţēƥ ďēƒàũĺţ.",
|
||||
"filter": "Ƒĩĺţēŕ",
|
||||
"foundNewIssues_one": "Ƒōũńď {{count}} ńēŵ ĩśśũē — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
|
||||
"foundNewIssues_other": "Ƒōũńď {{count}} ńēŵ ĩśśũēś — ţàśķś ŵĩĺĺ àƥƥēàŕ śĥōŕţĺŷ.",
|
||||
"import": "Ĩḿƥōŕţ",
|
||||
"importFromLinear": "Ĩḿƥōŕţ ƒŕōḿ Ĺĩńēàŕ",
|
||||
"importFromLinearIssueUrlOr": "Ĩḿƥōŕţ ƒŕōḿ Ĺĩńēàŕ ĩśśũē ŨŔĹ ōŕ ĩďēńţĩƒĩēŕ",
|
||||
"importLinearIssue": "Ĩḿƥōŕţ Ĺĩńēàŕ ĩśśũē",
|
||||
"integrationDisabled": "Ĺĩńēàŕ ĩńţēĝŕàţĩōń ĩś ďĩśàƀĺēď.",
|
||||
"interval": "Ĩńţēŕvàĺ",
|
||||
"intervalMinutes_one": "{{count}}ḿ",
|
||||
"intervalMinutes_other": "{{count}}ḿ",
|
||||
@@ -57,7 +69,10 @@
|
||||
"linearWatcher": "Ĺĩńēàŕ ŵàţćĥēŕ",
|
||||
"linearWatchers": "Ĺĩńēàŕ ŵàţćĥēŕś",
|
||||
"linearWatchersDescription": "Ƥōĺĺ à Ĺĩńēàŕ ƒĩĺţēŕ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ĩśśũē.",
|
||||
"loading": "Ĺōàďĩńĝ...",
|
||||
"loadingEllipsis": "Ĺōàďĩńĝ…",
|
||||
"loadingIssue": "Ĺōàďĩńĝ ĩśśũē…",
|
||||
"loadingIssues": "Ĺōàďĩńĝ ĩśśũēś…",
|
||||
"loadingLabels": "Ĺōàďĩńĝ ĺàƀēĺś…",
|
||||
"loadingStates": "Ĺōàďĩńĝ śţàţēś…",
|
||||
"loadingTeams": "Ĺōàďĩńĝ ţēàḿś…",
|
||||
@@ -68,10 +83,19 @@
|
||||
"newWatcher": "Ńēŵ ŵàţćĥēŕ",
|
||||
"noCap": "(ńō ćàƥ)",
|
||||
"noDefault": "Ńō ďēƒàũĺţ",
|
||||
"noDescription": "Ńō ďēśćŕĩƥţĩōń.",
|
||||
"noIssuesMatchYourFilters": "Ńō ĩśśũēś ḿàţćĥ ŷōũŕ ƒĩĺţēŕś.",
|
||||
"noLabelsAvailableForThisTeam": "Ńō ĺàƀēĺś àvàĩĺàƀĺē ƒōŕ ţĥĩś ţēàḿ.",
|
||||
"noLinearWatchersConfiguredCreateOne": "Ńō Ĺĩńēàŕ ŵàţćĥēŕś ćōńƒĩĝũŕēď. Ćŕēàţē ōńē ţō àũţō-ćŕēàţē ţàśķś ƒŕōḿ ƒĩĺţēŕēď ĩśśũēś.",
|
||||
"noNewIssuesMatched": "Ńō ńēŵ ĩśśũēś ḿàţćĥēď.",
|
||||
"noResults": "Ńō ŕēśũĺţś",
|
||||
"notConfiguredNotice": "Ĺĩńēàŕ ĩś ńōţ ćōńƒĩĝũŕēď. <2>Ćōńƒĩĝũŕē Ĺĩńēàŕ</2> ţō śēē ŷōũŕ ĩśśũēś ĥēŕē.",
|
||||
"noWorkflowStatesAvailable": "Ńō ŵōŕķƒĺōŵ śţàţēś àvàĩĺàƀĺē.",
|
||||
"openInLinear": "Ōƥēń ĩń Ĺĩńēàŕ",
|
||||
"openIssueDetails": "Ōƥēń ĩśśũē ďēţàĩĺś",
|
||||
"openLinearIssue": "Ōƥēń Ĺĩńēàŕ ĩśśũē",
|
||||
"page": "Ƥàĝē",
|
||||
"pasteALinearIssueUrlOr": "Ƥàśţē à Ĺĩńēàŕ ĩśśũē ŨŔĹ ōŕ ĩďēńţĩƒĩēŕ (ĒŃĜ-123)",
|
||||
"pasteAnApiKeyToTest": "Ƥàśţē àń ÀƤĨ ķēŷ ţō ţēśţ ţĥē ćōńńēćţĩōń",
|
||||
"pause": "Ƥàũśē",
|
||||
"paused": "Ƥàũśēď",
|
||||
@@ -98,6 +122,8 @@
|
||||
"query": "Qũēŕŷ",
|
||||
"queryHelp": "Ƒŕēē-ţēxţ ḿàţćĥ àćŕōśś ţĩţĺē àńď ďēśćŕĩƥţĩōń (ōƥţĩōńàĺ).",
|
||||
"queryPlaceholder": "àũţĥ ƀũĝ",
|
||||
"reEnableItInSettings": "Ŕē-ēńàƀĺē ĩţ ĩń śēţţĩńĝś",
|
||||
"refresh": "Ŕēƒŕēśĥ",
|
||||
"removeConfiguration": "Ŕēḿōvē ćōńƒĩĝũŕàţĩōń",
|
||||
"removeLinearConfigurationConfirm": "Ŕēḿōvē Ĺĩńēàŕ ćōńƒĩĝũŕàţĩōń?",
|
||||
"resetCompleteDeletedTasks_one": "Ŕēśēţ ćōḿƥĺēţē — ďēĺēţēď {{count}} ţàśķ; ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
|
||||
@@ -105,9 +131,10 @@
|
||||
"resetCompleteNoTasksDeleted": "Ŕēśēţ ćōḿƥĺēţē — ńēxţ ƥōĺĺ ŵĩĺĺ ŕē-ĩḿƥōŕţ ḿàţćĥēś.",
|
||||
"resetFailed": "Ŕēśēţ ƒàĩĺēď: {{error}}",
|
||||
"resetWatch": "Ŕēśēţ ŵàţćĥ",
|
||||
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
|
||||
"savedLeaveBlank": "(śàvēď — ĺēàvē ƀĺàńķ ţō ķēēƥ ţĥē ćũŕŕēńţ vàĺũē)",
|
||||
"saveFailed": "Śàvē ƒàĩĺēď: {{error}}",
|
||||
"saving": "Śàvĩńĝ…",
|
||||
"searchByIdTitleOrDescription": "Śēàŕćĥ ƀŷ ĨĎ, ţĩţĺē, ōŕ ďēśćŕĩƥţĩōń",
|
||||
"selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ",
|
||||
"selectWorkspace": "Śēĺēćţ ŵōŕķśƥàćē",
|
||||
"sortCreatedAsc": "Ćŕēàţēď (ōĺďēśţ ƒĩŕśţ)",
|
||||
@@ -117,6 +144,7 @@
|
||||
"sortPriorityDesc": "Ƥŕĩōŕĩţŷ (ĥĩĝĥ → ĺōŵ)",
|
||||
"sortUpdatedAsc": "Ũƥďàţēď (ĺēàśţ ŕēćēńţĺŷ ũƥďàţēď ƒĩŕśţ)",
|
||||
"sortUpdatedDesc": "Ũƥďàţēď (ŕēćēńţĺŷ ũƥďàţēď ƒĩŕśţ)",
|
||||
"startTask": "Śţàŕţ ţàśķ",
|
||||
"states": "Śţàţēś",
|
||||
"statesHelp": "Ćĺĩćķ śţàţēś ţō ţōĝĝĺē. Ēḿƥţŷ ḿàţćĥēś ēvēŕŷ śţàţē ōń ţĥē ţēàḿ.",
|
||||
"statesHelpNoTeam": "Ƥĩćķ à ţēàḿ ţō ćĥōōśē śƥēćĩƒĩć ŵōŕķƒĺōŵ śţàţēś.",
|
||||
@@ -130,6 +158,8 @@
|
||||
"unknownAccount": "Ũńķńōŵń àććōũńţ",
|
||||
"unknownError": "Ũńķńōŵń ēŕŕōŕ",
|
||||
"update": "Ũƥďàţē",
|
||||
"updatedAgo": "· ũƥďàţēď {{time}}",
|
||||
"updatedTime": "Ũƥďàţēď {{time}}",
|
||||
"updateFailed": "Ũƥďàţē ƒàĩĺēď: {{error}}",
|
||||
"watchDialogDescription": "Ƥōĺĺ Ĺĩńēàŕ ŵĩţĥ à śţŕũćţũŕēď ƒĩĺţēŕ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ĩśśũē. Ōƥţĩōńàĺĺŷ ƀĩńď à ŕēƥōśĩţōŕŷ śō ēàćĥ ţàśķ ŕũńś àĝàĩńśţ ţĥàţ ćōďēƀàśē, ōŕ ĺēàvē ĩţ ũńśēţ ţō ŕũń ŵĩţĥ ńō ŕēƥōśĩţōŕŷ.",
|
||||
"watcherCreated": "Ŵàţćĥēŕ ćŕēàţēď",
|
||||
@@ -139,5 +169,6 @@
|
||||
"workflowHelp": "Ţàśķś àŕē ćŕēàţēď ĩń ţĥĩś ŵōŕķƒĺōŵ.",
|
||||
"workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ",
|
||||
"workflowStepHelp": "Ĩńĩţĩàĺ śţēƥ ƒōŕ ńēŵ ţàśķś.",
|
||||
"workspaceHelp": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē."
|
||||
"workspaceHelp": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
|
||||
"yourLinearApiKeyIsInvalid": "Ŷōũŕ Ĺĩńēàŕ ÀƤĨ ķēŷ ĩś ĩńvàĺĩď ōŕ ĥàś ƀēēń ŕēvōķēď. Ŕēćōńńēćţ ţō vĩēŵ ţĥĩś ĩśśũē."
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
"active": "Àćţĩvē",
|
||||
"addInstance": "Àďď ĩńśţàńćē",
|
||||
"agentProfile": "Àĝēńţ Ƥŕōƒĩĺē",
|
||||
"allProjects": "Àĺĺ ƥŕōĵēćţś",
|
||||
"any": "(àńŷ)",
|
||||
"authToken": "Àũţĥ ţōķēń",
|
||||
"availablePlaceholders": "Àvàĩĺàƀĺē ƥĺàćēĥōĺďēŕś:",
|
||||
"browseSentryIssues": "Ɓŕōŵśē Śēńţŕŷ ĩśśũēś",
|
||||
"cantDeleteInstanceInUse_one": "Ćàń'ţ ďēĺēţē \"{{name}}\": {{count}} ŵàţćĥ śţĩĺĺ ƀōũńď ţō ĩţ. Ŕēàśśĩĝń ōŕ ŕēḿōvē ţĥōśē ŵàţćĥēŕś ƒĩŕśţ.",
|
||||
"cantDeleteInstanceInUse_other": "Ćàń'ţ ďēĺēţē \"{{name}}\": {{count}} ŵàţćĥēś śţĩĺĺ ƀōũńď ţō ĩţ. Ŕēàśśĩĝń ōŕ ŕēḿōvē ţĥōśē ŵàţćĥēŕś ƒĩŕśţ.",
|
||||
"checkFailed": "Ćĥēćķ ƒàĩĺēď: {{error}}",
|
||||
@@ -28,6 +30,8 @@
|
||||
"enterNameAndTokenBeforeSaving": "Ēńţēŕ àń ĩńśţàńćē ńàḿē àńď àũţĥ ţōķēń ƀēƒōŕē śàvĩńĝ.",
|
||||
"environment": "Ēńvĩŕōńḿēńţ",
|
||||
"environmentHelp": "Ōƥţĩōńàĺ — ŕēśţŕĩćţ ţō ōńē ēńvĩŕōńḿēńţ.",
|
||||
"eventsCount_one": "ēvēńţś {{count}}",
|
||||
"eventsCount_other": "ēvēńţś {{count}}",
|
||||
"executorProfile": "Ēxēćũţōŕ Ƥŕōƒĩĺē",
|
||||
"failedToLoadInstances": "Ƒàĩĺēď ţō ĺōàď Śēńţŕŷ ĩńśţàńćēś: {{error}}",
|
||||
"fallsBackToStepDefault": "Ōƥţĩōńàĺ — ƒàĺĺś ƀàćķ ţō śţēƥ ďēƒàũĺţ.",
|
||||
@@ -47,6 +51,8 @@
|
||||
"intervalMinutes_one": "{{count}}ḿ",
|
||||
"intervalMinutes_other": "{{count}}ḿ",
|
||||
"lastPolled": "Ĺàśţ Ƥōĺĺēď",
|
||||
"lastSeen": "Ĺàśţ śēēń",
|
||||
"level": "Ĺēvēĺ",
|
||||
"levelDebug": "ďēƀũĝ",
|
||||
"levelError": "ēŕŕōŕ",
|
||||
"levelFatal": "ƒàţàĺ",
|
||||
@@ -54,6 +60,7 @@
|
||||
"levelWarning": "ŵàŕńĩńĝ",
|
||||
"levels": "Ĺēvēĺś",
|
||||
"levelsHelp": "Ćĺĩćķ ţō ţōĝĝĺē. Ḿàţćĥēś ĩśśũēś àţ ÀŃŶ ōƒ ţĥē śēĺēćţēď ĺēvēĺś.",
|
||||
"loadMore": "Ĺōàď ḿōŕē",
|
||||
"loadingEllipsis": "Ĺōàďĩńĝ…",
|
||||
"maxInflightTasks": "Ḿàx ĩń-ƒĺĩĝĥţ ţàśķś",
|
||||
"maxInflightTasksHelp": "Ćàƥ ōń ōƥēń ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ. Ĺēàvē ƀĺàńķ ƒōŕ ńō ćàƥ. Ńēŵ ḿàţćĥēś àŕē ďēƒēŕŕēď ţō ţĥē ńēxţ ƥōĺĺ ŵĥēń ţĥē ćàƥ ĩś ŕēàćĥēď.",
|
||||
@@ -64,18 +71,24 @@
|
||||
"newInstance": "Ńēŵ Ĩńśţàńćē",
|
||||
"newWatcher": "Ńēŵ ŵàţćĥēŕ",
|
||||
"noCap": "(ńō ćàƥ)",
|
||||
"noHealthySentryInstanceCheckThe": "Ńō ĥēàĺţĥŷ Śēńţŕŷ ĩńśţàńćē — ćĥēćķ ţĥē ĩńţēĝŕàţĩōń śēţţĩńĝś.",
|
||||
"noInstancesInWorkspace": "Ńō Śēńţŕŷ ĩńśţàńćēś ĩń ţĥĩś ŵōŕķśƥàćē",
|
||||
"noInstancesYet": "Ńō Śēńţŕŷ ĩńśţàńćēś ŷēţ. Àďď ōńē ţō ćōńńēćţ ţĥĩś ŵōŕķśƥàćē.",
|
||||
"noIssuesYetRunASearch": "Ńō ĩśśũēś ŷēţ. Ŕũń à śēàŕćĥ ţō ƀēĝĩń.",
|
||||
"noNewIssuesMatched": "Ńō ńēŵ ĩśśũēś ḿàţćĥēď.",
|
||||
"noOrganizationsAvailable": "Ńō ōŕĝàńĩźàţĩōńś àvàĩĺàƀĺē",
|
||||
"noProjectsAvailable": "Ńō ƥŕōĵēćţś àvàĩĺàƀĺē",
|
||||
"noProjectsAvailableSentence": "Ńō ƥŕōĵēćţś àvàĩĺàƀĺē.",
|
||||
"noSentryInstancesConfiguredForThis": "Ńō Śēńţŕŷ ĩńśţàńćēś ćōńƒĩĝũŕēď ƒōŕ ţĥĩś ŵōŕķśƥàćē.",
|
||||
"noWatchersConfigured": "Ńō Śēńţŕŷ ŵàţćĥēŕś ćōńƒĩĝũŕēď. Ćŕēàţē ōńē ţō àũţō-ćŕēàţē ţàśķś ƒŕōḿ ƒĩĺţēŕēď ĩśśũēś.",
|
||||
"orgSlugRequired": "Ōŕĝàńĩźàţĩōń śĺũĝ ĩś ŕēqũĩŕēď",
|
||||
"organization": "Ōŕĝàńĩźàţĩōń",
|
||||
"organizationSlug": "Ōŕĝàńĩźàţĩōń śĺũĝ",
|
||||
"organizationSlugHelp": "Ţĥē Śēńţŕŷ ōŕĝ ţō ƥōĺĺ.",
|
||||
"pasteAnAuthTokenToTest": "Ƥàśţē àń àũţĥ ţōķēń ţō ţēśţ ţĥē ćōńńēćţĩōń",
|
||||
"pause": "Ƥàũśē",
|
||||
"paused": "Ƥàũśēď",
|
||||
"period": "Ƥēŕĩōď",
|
||||
"placeholderAssigneeDisplayName": "Àśśĩĝńēē ďĩśƥĺàŷ ńàḿē",
|
||||
"placeholderCulprit": "Ćũĺƥŕĩţ (ƒũńćţĩōń ōŕ ƒĩĺē)",
|
||||
"placeholderEventCount": "Ţōţàĺ ēvēńţ ćōũńţ",
|
||||
@@ -90,12 +103,15 @@
|
||||
"placeholderUserCount": "Ũńĩqũē ũśēŕś àƒƒēćţēď",
|
||||
"pollIntervalHelp": "Ĥōŵ ōƒţēń ţō ŕē-ŕũń ţĥē śēàŕćĥ. Ḿĩńĩḿũḿ 60ś, ḿàxĩḿũḿ 3600ś.",
|
||||
"pollIntervalSeconds": "Ƥōĺĺ Ĩńţēŕvàĺ (śēćōńďś)",
|
||||
"production": "ƥŕōďũćţĩōń",
|
||||
"project": "Ƥŕōĵēćţ",
|
||||
"projectSlug": "Ƥŕōĵēćţ śĺũĝ",
|
||||
"promptFieldHelp": "Ţĥē ƥŕōḿƥţ śēńţ ţō ţĥē àĝēńţ ƒōŕ ēàćĥ ńēŵ ĩśśũē. Ţŷƥē {{token}} ţō ĩńśēŕţ ƥĺàćēĥōĺďēŕś.",
|
||||
"projectSlugHelp": "Ţĥē Śēńţŕŷ ƥŕōĵēćţ(ś) ţō ƥōĺĺ. Śēĺēćţ ōńē ōŕ ḿōŕē.",
|
||||
"projectsSelected_one": "{{count}} ƥŕōĵēćţ śēĺēćţēď",
|
||||
"projectsSelected_other": "{{count}} ƥŕōĵēćţś śēĺēćţēď",
|
||||
"promptFieldHelp": "Ţĥē ƥŕōḿƥţ śēńţ ţō ţĥē àĝēńţ ƒōŕ ēàćĥ ńēŵ ĩśśũē. Ţŷƥē {{token}} ţō ĩńśēŕţ ƥĺàćēĥōĺďēŕś.",
|
||||
"query": "Qũēŕŷ",
|
||||
"queryExample": "ē.ĝ. {{query}}",
|
||||
"queryHelp": "Ƒŕēē-ţēxţ Śēńţŕŷ śēàŕćĥ qũēŕŷ (ōƥţĩōńàĺ).",
|
||||
"removeInstanceConfirm": "Ŕēḿōvē Śēńţŕŷ ĩńśţàńćē \"{{name}}\"?",
|
||||
"requiredTokenScopes": "Ŕēqũĩŕēď ţōķēń śćōƥēś",
|
||||
@@ -111,7 +127,10 @@
|
||||
"scopeOrgRead": "Ōŕĝàńĩźàţĩōń — ŕēśōĺvē ţĥē ōŕĝ àńď ĺĩśţ ĩśśũēś",
|
||||
"scopeProjectRead": "Ƥŕōĵēćţ — ĺĩśţ ƥŕōĵēćţś àńď śćōƥē śēàŕćĥēś",
|
||||
"searchProjects": "Śēàŕćĥ ƥŕōĵēćţś...",
|
||||
"selectASentryInstanceToBrowse": "Śēĺēćţ à Śēńţŕŷ ĩńśţàńćē ţō ƀŕōŵśē ĩţś ĩśśũēś.",
|
||||
"selectAnInstance": "Śēĺēćţ àń ĩńśţàńćē",
|
||||
"selectAnInstanceToBrowse": "Śēĺēćţ àń ĩńśţàńćē ţō ƀŕōŵśē",
|
||||
"selectInstanceToSearch": "Śēĺēćţ à Śēńţŕŷ ĩńśţàńćē ţō śēàŕćĥ",
|
||||
"selectOrganization": "Śēĺēćţ ōŕĝàńĩźàţĩōń",
|
||||
"selectProjects": "Śēĺēćţ ƥŕōĵēćţś",
|
||||
"selectWorkflow": "Śēĺēćţ ŵōŕķƒĺōŵ",
|
||||
@@ -142,6 +161,8 @@
|
||||
"update": "Ũƥďàţē",
|
||||
"updateFailed": "Ũƥďàţē ƒàĩĺēď: {{error}}",
|
||||
"useStepDefault": "(ũśē śţēƥ ďēƒàũĺţ)",
|
||||
"usersCount_one": "ũśēŕś {{count}}",
|
||||
"usersCount_other": "ũśēŕś {{count}}",
|
||||
"watchDialogDescription": "Ƥōĺĺ Śēńţŕŷ ŵĩţĥ à śţŕũćţũŕēď ƒĩĺţēŕ àńď àũţō-ćŕēàţē à Ķàńďēv ţàśķ ƒōŕ ēàćĥ ńēŵĺŷ-ḿàţćĥĩńĝ ĩśśũē. Ōƥţĩōńàĺĺŷ ƀĩńď à ŕēƥōśĩţōŕŷ śō ēàćĥ ţàśķ ŕũńś àĝàĩńśţ ţĥàţ ćōďēƀàśē, ōŕ ĺēàvē ĩţ ũńśēţ ţō ŕũń ŵĩţĥ ńō ŕēƥōśĩţōŕŷ.",
|
||||
"watcherCreated": "Ŵàţćĥēŕ ćŕēàţēď",
|
||||
"watcherDeleted": "Ŵàţćĥēŕ ďēĺēţēď",
|
||||
@@ -150,5 +171,6 @@
|
||||
"workflowHelp": "Ţàśķś àŕē ćŕēàţēď ĩń ţĥĩś ŵōŕķƒĺōŵ.",
|
||||
"workflowStep": "Ŵōŕķƒĺōŵ Śţēƥ",
|
||||
"workflowStepHelp": "Ĩńĩţĩàĺ śţēƥ ƒōŕ ńēŵ ţàśķś.",
|
||||
"workspaceHelp": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē."
|
||||
"workspaceHelp": "Ţàśķś ćŕēàţēď ƀŷ ţĥĩś ŵàţćĥēŕ ĺàńď ĩń ţĥē śēĺēćţēď ŵōŕķśƥàćē.",
|
||||
"yourSentryAuthTokenIsInvalid": "Ŷōũŕ Śēńţŕŷ àũţĥ ţōķēń ĩś ĩńvàĺĩď ōŕ ĥàś ƀēēń ŕēvōķēď. Ŕēćōńńēćţ ţō vĩēŵ ţĥĩś ĩśśũē."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user