feat(i18n): localize the GitHub and GitLab task surfaces (#2283)
Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
@@ -9,6 +9,7 @@ import type { GitLabLaunchPayload, GitLabTaskPreset } from "./quick-task-launche
|
||||
import { StartTaskMenu } from "./start-task-menu";
|
||||
import { SubscriptionToggle } from "../subscription-toggle";
|
||||
import { RowTitleLink } from "./row-title-link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type IssueListProps = {
|
||||
items: Issue[];
|
||||
@@ -49,6 +50,7 @@ function IssueRow({
|
||||
workspaceId?: string;
|
||||
host?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isOpen = issue.state !== "closed";
|
||||
const StateIcon = isOpen ? IconCircle : IconCircleCheck;
|
||||
const stateClass = isOpen
|
||||
@@ -69,7 +71,10 @@ function IssueRow({
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="whitespace-nowrap">
|
||||
by {issue.author_username} · opened {formatRelativeTime(issue.created_at)}
|
||||
{t("gitlab:byAuthorOpenedAgo", {
|
||||
author: issue.author_username,
|
||||
time: formatRelativeTime(issue.created_at),
|
||||
})}
|
||||
</span>
|
||||
<IssueLabels labels={issue.labels} />
|
||||
</div>
|
||||
@@ -106,6 +111,7 @@ function IssueListBody({
|
||||
workspaceId,
|
||||
host,
|
||||
}: IssueListProps) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-10">
|
||||
@@ -119,7 +125,7 @@ function IssueListBody({
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 text-muted-foreground text-sm">
|
||||
No issues match this filter.
|
||||
{t("gitlab:noIssuesMatchThisFilter")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
|
||||
import { cn, formatRelativeTime } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const ALL_PROJECTS = "__all__";
|
||||
|
||||
@@ -34,11 +35,12 @@ function RefreshControls({
|
||||
onRefresh: () => void;
|
||||
showUpdatedPrefix: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{lastFetchedAt && !loading && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{showUpdatedPrefix ? "Updated " : ""}
|
||||
{showUpdatedPrefix ? t("gitlab:updated") : ""}
|
||||
{formatRelativeTime(lastFetchedAt.toISOString())}
|
||||
</span>
|
||||
)}
|
||||
@@ -48,7 +50,7 @@ function RefreshControls({
|
||||
className="h-8 w-8 cursor-pointer"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title="Refresh"
|
||||
title={t("gitlab:refresh")}
|
||||
data-testid="gitlab-list-toolbar-refresh"
|
||||
>
|
||||
<IconRefresh className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
@@ -71,6 +73,7 @@ export function ListToolbar({
|
||||
projectOptions,
|
||||
onRefresh,
|
||||
}: ListToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const selectValue = projectFilter || ALL_PROJECTS;
|
||||
const dirty = customQuery !== committedQuery;
|
||||
return (
|
||||
@@ -101,11 +104,11 @@ export function ListToolbar({
|
||||
className="w-full md:w-[220px] h-8 cursor-pointer"
|
||||
data-testid="gitlab-project-filter-trigger"
|
||||
>
|
||||
<SelectValue placeholder="All projects" />
|
||||
<SelectValue placeholder={t("gitlab:allProjects")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL_PROJECTS} className="cursor-pointer">
|
||||
All projects
|
||||
{t("gitlab:allProjects")}
|
||||
</SelectItem>
|
||||
{projectOptions.map((key) => (
|
||||
<SelectItem key={key} value={key} className="cursor-pointer">
|
||||
@@ -127,13 +130,13 @@ export function ListToolbar({
|
||||
onBlur={() => {
|
||||
if (dirty) onCommitCustomQuery();
|
||||
}}
|
||||
placeholder='Custom query — press Enter. e.g. "labels=bug&state=opened"'
|
||||
placeholder={t("gitlab:customQueryPressEnterEG")}
|
||||
className="h-8 pr-20"
|
||||
data-testid="gitlab-list-toolbar-custom-query"
|
||||
/>
|
||||
{dirty && (
|
||||
<span className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-[10px] uppercase tracking-wider text-muted-foreground hidden sm:inline">
|
||||
Press Enter
|
||||
{t("gitlab:pressEnter")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { gitLabMRKey } from "@/lib/gitlab-identity";
|
||||
import { MRRowTaskIndicator } from "./mr-row-task-indicator";
|
||||
import { StartTaskMenu } from "./start-task-menu";
|
||||
import { RowTitleLink } from "./row-title-link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type MRListProps = {
|
||||
items: MR[];
|
||||
@@ -41,6 +42,7 @@ function MRRow({
|
||||
presets: GitLabTaskPreset[];
|
||||
onStartTask?: MRListProps["onStartTask"];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { Icon: StateIcon, className: stateIconClass } = mrStateIcon(mr);
|
||||
return (
|
||||
<div
|
||||
@@ -61,7 +63,10 @@ function MRRow({
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="whitespace-nowrap">
|
||||
by {mr.author_username} · opened {formatRelativeTime(mr.created_at)}
|
||||
{t("gitlab:byAuthorOpenedAgo", {
|
||||
author: mr.author_username,
|
||||
time: formatRelativeTime(mr.created_at),
|
||||
})}
|
||||
</span>
|
||||
<MRRowTaskIndicator tasks={tasks} />
|
||||
</div>
|
||||
@@ -86,6 +91,7 @@ function MRListBody({
|
||||
onStartTask,
|
||||
mrKeyToTasks,
|
||||
}: MRListProps) {
|
||||
const { t } = useTranslation();
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-10">
|
||||
@@ -99,7 +105,7 @@ function MRListBody({
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 text-muted-foreground text-sm">
|
||||
No merge requests match this filter.
|
||||
{t("gitlab:noMergeRequestsMatchThisFilter")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import type { TaskMR } from "@/lib/types/gitlab";
|
||||
import { TaskRowIndicator } from "@/components/github/my-github/task-row-indicator";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function MRRowTaskIndicator({ tasks }: { tasks: TaskMR[] | undefined }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<TaskRowIndicator
|
||||
tasks={tasks?.map((association) => ({
|
||||
@@ -12,7 +14,7 @@ export function MRRowTaskIndicator({ tasks }: { tasks: TaskMR[] | undefined }) {
|
||||
fallbackTitle: association.mr_title,
|
||||
}))}
|
||||
testIdPrefix="gitlab-mr-row-task-indicator"
|
||||
emptyLabel="No task created yet"
|
||||
emptyLabel={t("gitlab:noTaskCreatedYet")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IntegrationScopeBar } from "@/components/integrations/presets-scope-bar
|
||||
import { MR_PRESETS, ISSUE_PRESETS, type PresetOption } from "./presets";
|
||||
import type { SavedPreset } from "./use-saved-presets";
|
||||
import type { SidebarSelection } from "./presets-sidebar";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type PresetsScopeBarProps = {
|
||||
className?: string;
|
||||
@@ -17,9 +18,10 @@ type PresetsScopeBarProps = {
|
||||
issuePresets?: PresetOption[];
|
||||
};
|
||||
|
||||
/** `value` is the persisted kind; only the catalog key is copy. */
|
||||
const KINDS = [
|
||||
{ value: "mr", label: "Merge requests" },
|
||||
{ value: "issue", label: "Issues" },
|
||||
{ value: "mr", labelKey: "gitlab:titleMergeRequests" },
|
||||
{ value: "issue", labelKey: "gitlab:titleIssues" },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -32,12 +34,13 @@ export function PresetsScopeBar({
|
||||
issuePresets = ISSUE_PRESETS,
|
||||
...props
|
||||
}: PresetsScopeBarProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<IntegrationScopeBar
|
||||
{...props}
|
||||
testId="gitlab-presets-scope-bar"
|
||||
savedMenuTestId="gitlab-saved-queries-menu"
|
||||
kinds={KINDS}
|
||||
kinds={KINDS.map(({ value, labelKey }) => ({ value, label: t(labelKey) }))}
|
||||
presetsByKind={(kind) => (kind === "mr" ? mrPresets : issuePresets)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Icon } from "@tabler/icons-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MR_PRESETS, ISSUE_PRESETS, type PresetOption, type PresetGroup } from "./presets";
|
||||
import type { SavedPreset } from "./use-saved-presets";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type SidebarSelection = {
|
||||
kind: "mr" | "issue";
|
||||
@@ -30,6 +31,7 @@ function KindToggle({
|
||||
kind: "mr" | "issue";
|
||||
onChange: (k: "mr" | "issue") => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="mx-2 mb-3 grid grid-cols-2 rounded-md border p-0.5 text-xs"
|
||||
@@ -48,18 +50,26 @@ function KindToggle({
|
||||
)}
|
||||
data-testid={`gitlab-kind-${value}`}
|
||||
>
|
||||
{value === "mr" ? "Merge requests" : "Issues"}
|
||||
{value === "mr" ? t("gitlab:mergeRequests") : t("gitlab:issues")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionHeader({ title }: { title: string }) {
|
||||
/**
|
||||
* `id` is a stable section identifier and `title` is the visible label. They
|
||||
* were one value: the testid was built as `gitlab-section-${title.toLowerCase()}`,
|
||||
* so translating the title moved the selector with it — under pseudo the inbox
|
||||
* section became `gitlab-section-ĩńƀōx`. apps/web/AGENTS.md is explicit that a
|
||||
* `data-testid` is never translated; deriving one from copy translates it by
|
||||
* the back door.
|
||||
*/
|
||||
function SectionHeader({ id, title }: { id: string; title: string }) {
|
||||
return (
|
||||
<div
|
||||
className="px-2 mt-3 mb-1 text-[11px] uppercase tracking-wider text-muted-foreground font-medium"
|
||||
data-testid={`gitlab-section-${title.toLowerCase()}`}
|
||||
data-testid={`gitlab-section-${id}`}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
@@ -121,11 +131,15 @@ function PresetGroupList({
|
||||
onSelect: (s: SidebarSelection) => void;
|
||||
kind: "mr" | "issue";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const items = presets.filter((p) => p.group === group);
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title={group === "inbox" ? "Inbox" : "Created"} />
|
||||
<SectionHeader
|
||||
id={group}
|
||||
title={group === "inbox" ? t("gitlab:inbox") : t("gitlab:created")}
|
||||
/>
|
||||
{items.map((p) => (
|
||||
<PresetItem
|
||||
key={`${kind}-${p.value}`}
|
||||
@@ -156,12 +170,13 @@ function SavedSection({
|
||||
canSaveCurrent: boolean;
|
||||
onSaveCurrent: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
<SectionHeader title="Saved" />
|
||||
<SectionHeader id="saved" title={t("gitlab:saved")} />
|
||||
{saved.length === 0 && (
|
||||
<div className="mx-2 px-2 py-1 text-xs text-muted-foreground/80 italic">
|
||||
No saved queries yet.
|
||||
{t("gitlab:noSavedQueriesYet")}
|
||||
</div>
|
||||
)}
|
||||
{saved.map((s) => (
|
||||
@@ -179,8 +194,8 @@ function SavedSection({
|
||||
onDelete(s.id);
|
||||
}}
|
||||
className="opacity-0 group-hover/item:opacity-100 transition-opacity text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
title="Delete saved query"
|
||||
aria-label={`Delete saved query ${s.label}`}
|
||||
title={t("gitlab:deleteSavedQuery")}
|
||||
aria-label={t("gitlab:deleteSavedQuery2", { label: s.label })}
|
||||
data-testid={`gitlab-saved-delete-${s.id}`}
|
||||
>
|
||||
<IconX className="h-3.5 w-3.5" />
|
||||
@@ -198,11 +213,11 @@ function SavedSection({
|
||||
? "text-muted-foreground hover:bg-muted/50 hover:text-foreground cursor-pointer"
|
||||
: "text-muted-foreground/50 cursor-not-allowed",
|
||||
)}
|
||||
title={canSaveCurrent ? "Save current query" : "Type a custom query first"}
|
||||
title={canSaveCurrent ? t("gitlab:saveCurrentQuery") : t("gitlab:typeACustomQueryFirst")}
|
||||
data-testid="gitlab-save-current-query"
|
||||
>
|
||||
<IconDeviceFloppy className="h-4 w-4 shrink-0" />
|
||||
<span>Save current query</span>
|
||||
<span>{t("gitlab:saveCurrentQuery")}</span>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type SavePresetDialogProps = {
|
||||
open: boolean;
|
||||
@@ -38,6 +39,7 @@ function SavePresetForm({
|
||||
onSave: (label: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [value, setValue] = useState(suggestedLabel);
|
||||
const trimmed = value.trim();
|
||||
const canSubmit = trimmed.length > 0;
|
||||
@@ -51,16 +53,17 @@ function SavePresetForm({
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Save GitLab query</DialogTitle>
|
||||
<DialogTitle>{t("gitlab:saveGitlabQuery")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Save this {kind === "mr" ? "merge request" : "issue"} query to the sidebar for quick
|
||||
access later.
|
||||
{t("gitlab:savePresetDescription", {
|
||||
kind: kind === "mr" ? t("gitlab:kindMergeRequest") : t("gitlab:kindIssue"),
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="gitlab-preset-label" className="text-xs">
|
||||
Name
|
||||
{t("gitlab:name")}
|
||||
</Label>
|
||||
<Input
|
||||
id="gitlab-preset-label"
|
||||
@@ -68,13 +71,13 @@ function SavePresetForm({
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onFocus={(e) => e.target.select()}
|
||||
placeholder="e.g. Needs my review"
|
||||
placeholder={t("gitlab:eGNeedsMyReview")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 text-xs">
|
||||
{customQuery && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0 w-16">Query</span>
|
||||
<span className="text-muted-foreground shrink-0 w-16">{t("gitlab:query")}</span>
|
||||
<code className="font-mono text-[11px] bg-muted rounded px-1.5 py-0.5 break-all">
|
||||
{customQuery}
|
||||
</code>
|
||||
@@ -82,7 +85,7 @@ function SavePresetForm({
|
||||
)}
|
||||
{projectFilter && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground shrink-0 w-16">Project</span>
|
||||
<span className="text-muted-foreground shrink-0 w-16">{t("gitlab:project")}</span>
|
||||
<code className="font-mono text-[11px] bg-muted rounded px-1.5 py-0.5 break-all">
|
||||
{projectFilter}
|
||||
</code>
|
||||
@@ -92,10 +95,10 @@ function SavePresetForm({
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="cursor-pointer" onClick={onClose}>
|
||||
Cancel
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button className="cursor-pointer" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
Save
|
||||
{t("common:save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import type { GitLabTaskPreset } from "./quick-task-launcher";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function StartTaskMenu({
|
||||
presets,
|
||||
@@ -17,6 +18,7 @@ export function StartTaskMenu({
|
||||
presets: GitLabTaskPreset[];
|
||||
onSelect: (preset: GitLabTaskPreset) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (presets.length === 0) return null;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
@@ -25,10 +27,10 @@ export function StartTaskMenu({
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-11 sm:h-7 gap-1 cursor-pointer"
|
||||
aria-label="Create task"
|
||||
aria-label={t("gitlab:createTask")}
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
Task
|
||||
{t("gitlab:task")}
|
||||
<IconChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
Reference in New Issue
Block a user