Files
kandev/apps/web/components/linear/linear-issue-watch-fields.tsx
João Salavisa d0ee6d989b feat(i18n): localize Settings → Integrations → Linear (#2179)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 23:08:18 +02:00

342 lines
11 KiB
TypeScript

"use client";
import { type Dispatch, type SetStateAction, useEffect, useRef, useState } from "react";
import { Badge } from "@kandev/ui/badge";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Switch } from "@kandev/ui/switch";
import {
listLinearLabels,
listLinearStates,
listLinearTeams,
listLinearUsers,
} from "@/lib/api/domains/linear-api";
import {
PRIORITY_OPTIONS,
parseMaxInflightTasks,
SORT_BY_OPTIONS,
type FormState,
type LinearPriority,
} from "./linear-issue-watch-form";
import type { LinearLabel, LinearTeam, LinearUser, LinearWorkflowState } from "@/lib/types/linear";
import { useTranslation } from "react-i18next";
import { resolveOptionLabel } from "@/lib/i18n/option-label";
// useTeamsAndStates loads the team list once Linear is configured, plus the
// states, labels, and users for the currently-selected team. Each per-team
// dataset is cached so switching teams renders an empty list (or the cached
// list) without us having to setState in an effect — only the lookup
// expression changes.
export function useTeamsAndStates(workspaceId: string, teamKey: string) {
const [teams, setTeams] = useState<LinearTeam[]>([]);
const [statesByTeam, setStatesByTeam] = useState<Record<string, LinearWorkflowState[]>>({});
const [labelsByTeam, setLabelsByTeam] = useState<Record<string, LinearLabel[]>>({});
const [usersByTeam, setUsersByTeam] = useState<Record<string, LinearUser[]>>({});
const fetchedTeams = useRef<Set<string>>(new Set());
useEffect(() => {
let cancelled = false;
listLinearTeams({ workspaceId })
.then((res) => {
if (!cancelled) setTeams(res.teams ?? []);
})
.catch(() => {
if (!cancelled) setTeams([]);
});
return () => {
cancelled = true;
};
}, [workspaceId]);
useEffect(() => {
// Capture the Set once so cleanup uses the exact instance the effect
// body added to (ref.current is stable across renders, but eslint can't
// prove that — so we assign to a local).
const fetched = fetchedTeams.current;
if (!teamKey || fetched.has(teamKey)) return;
// Mark in-flight so concurrent renders don't double-fetch; clear on
// failure so a subsequent remount can retry instead of being stuck with
// an empty cached entry.
fetched.add(teamKey);
let cancelled = false;
let anyFailed = false;
let loaded = false;
const markFailed = () => {
anyFailed = true;
};
Promise.allSettled([
listLinearStates(teamKey, { workspaceId })
.then((res) => {
if (!cancelled) setStatesByTeam((prev) => ({ ...prev, [teamKey]: res.states ?? [] }));
})
.catch(() => {
markFailed();
if (!cancelled) setStatesByTeam((prev) => ({ ...prev, [teamKey]: [] }));
}),
listLinearLabels(teamKey, { workspaceId })
.then((res) => {
if (!cancelled) setLabelsByTeam((prev) => ({ ...prev, [teamKey]: res.labels ?? [] }));
})
.catch(() => {
markFailed();
if (!cancelled) setLabelsByTeam((prev) => ({ ...prev, [teamKey]: [] }));
}),
listLinearUsers(teamKey, { workspaceId })
.then((res) => {
if (!cancelled) setUsersByTeam((prev) => ({ ...prev, [teamKey]: res.users ?? [] }));
})
.catch(() => {
markFailed();
if (!cancelled) setUsersByTeam((prev) => ({ ...prev, [teamKey]: [] }));
}),
]).finally(() => {
// Track full success so cleanup can keep the cache marker — otherwise
// every cleanup wipes it and the next visit refetches.
loaded = !cancelled && !anyFailed;
// If a fetch failed (and we didn't cancel), drop the marker so the
// next visit to this team can retry. Success-path marker stays so
// cached data is reused.
if (anyFailed) fetched.delete(teamKey);
});
return () => {
cancelled = true;
// Drop the marker on cleanup ONLY when the fetch hadn't completed
// successfully — that handles the rapid-switch (A→B→A) case without
// also evicting a healthy cache after a normal team change.
if (!loaded) fetched.delete(teamKey);
};
}, [workspaceId, teamKey]);
const states = teamKey ? (statesByTeam[teamKey] ?? []) : [];
const labels = teamKey ? (labelsByTeam[teamKey] ?? []) : [];
const users = teamKey ? (usersByTeam[teamKey] ?? []) : [];
const loadingStates = !!teamKey && statesByTeam[teamKey] === undefined;
const loadingLabels = !!teamKey && labelsByTeam[teamKey] === undefined;
const loadingUsers = !!teamKey && usersByTeam[teamKey] === undefined;
return { teams, states, labels, users, loadingStates, loadingLabels, loadingUsers };
}
export function StateMultiSelect({
states,
loading,
selected,
onToggle,
disabled,
}: {
states: LinearWorkflowState[];
loading: boolean;
selected: string[];
onToggle: (id: string) => void;
disabled: boolean;
}) {
const { t } = useTranslation();
if (disabled) {
// Caller's row description already explains the disabled state — render
// nothing here to avoid duplicate prose.
return null;
}
if (loading) {
return <p className="text-xs text-muted-foreground">{t("linear:loadingStates")}</p>;
}
if (states.length === 0) {
return <p className="text-xs text-muted-foreground">{t("linear:noWorkflowStatesAvailable")}</p>;
}
return (
<div className="flex flex-wrap gap-1.5">
{states.map((s) => {
const active = selected.includes(s.id);
return (
<button
key={s.id}
type="button"
onClick={() => onToggle(s.id)}
aria-pressed={active}
className="cursor-pointer"
>
{/* Workflow state names come from the Linear API — user data. */}
<Badge variant={active ? "default" : "outline"}>{s.name}</Badge>
</button>
);
})}
</div>
);
}
export function PriorityMultiSelect({
selected,
onToggle,
}: {
selected: LinearPriority[];
onToggle: (p: LinearPriority) => void;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-1.5">
{PRIORITY_OPTIONS.map((opt) => {
const active = selected.includes(opt.value);
return (
<button
key={opt.value}
type="button"
onClick={() => onToggle(opt.value)}
aria-pressed={active}
className="cursor-pointer"
>
<Badge variant={active ? "default" : "outline"}>{resolveOptionLabel(t, opt)}</Badge>
</button>
);
})}
</div>
);
}
export function LabelMultiSelect({
labels,
loading,
selected,
onToggle,
disabled,
}: {
labels: LinearLabel[];
loading: boolean;
selected: string[];
onToggle: (id: string) => void;
disabled: boolean;
}) {
const { t } = useTranslation();
if (disabled) {
// Caller's row description already explains the disabled state — render
// nothing here to avoid duplicate prose.
return null;
}
if (loading) {
return <p className="text-xs text-muted-foreground">{t("linear:loadingLabels")}</p>;
}
if (labels.length === 0) {
return (
<p className="text-xs text-muted-foreground">{t("linear:noLabelsAvailableForThisTeam")}</p>
);
}
return (
<div className="flex flex-wrap gap-1.5">
{labels.map((l) => {
const active = selected.includes(l.id);
return (
<button
key={l.id}
type="button"
onClick={() => onToggle(l.id)}
aria-pressed={active}
className="cursor-pointer"
>
{/* Label names come from the Linear API — user data. */}
<Badge variant={active ? "default" : "outline"}>{l.name}</Badge>
</button>
);
})}
</div>
);
}
type FormSetter = Dispatch<SetStateAction<FormState>>;
// Radix SelectItem rejects an empty-string value, so the "Default (Linear
// order)" option uses a sentinel in the dropdown that we translate back to ""
// (FormState's empty default) at the Select boundary.
const SORT_BY_DEFAULT_SENTINEL = "__default__";
export function SortByField({ form, setForm }: { form: FormState; setForm: FormSetter }) {
const { t } = useTranslation();
return (
<div className="space-y-1.5">
<Label>{t("linear:dispatchOrder")}</Label>
<p className="text-xs text-muted-foreground">{t("linear:dispatchOrderHelp")}</p>
<Select
value={form.sortBy || SORT_BY_DEFAULT_SENTINEL}
onValueChange={(v) =>
setForm((p) => ({
...p,
sortBy: (v === SORT_BY_DEFAULT_SENTINEL ? "" : v) as FormState["sortBy"],
}))
}
>
<SelectTrigger className="cursor-pointer">
<SelectValue />
</SelectTrigger>
<SelectContent>
{SORT_BY_OPTIONS.map((o) => (
<SelectItem
key={o.value || SORT_BY_DEFAULT_SENTINEL}
value={o.value || SORT_BY_DEFAULT_SENTINEL}
>
{resolveOptionLabel(t, o)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// MaxInflightTasksField renders the per-watcher throttle-cap input with inline
// validation. Lives here (rather than the dialog) to keep the dialog file under
// its line ceiling.
export function MaxInflightTasksField({ form, setForm }: { form: FormState; setForm: FormSetter }) {
const { t } = useTranslation();
const parsed = parseMaxInflightTasks(form.maxInflightTasks);
const invalid = parsed === "invalid";
return (
<div className="space-y-1.5">
<Label>{t("linear:maxInflightTasks")}</Label>
<p className="text-xs text-muted-foreground">{t("linear:maxInflightTasksHelp")}</p>
<Input
type="number"
value={form.maxInflightTasks}
onChange={(e) => setForm((p) => ({ ...p, maxInflightTasks: e.target.value }))}
min={1}
step={1}
placeholder={t("linear:noCap")}
aria-invalid={invalid}
/>
{invalid && (
<p className="text-xs text-destructive">{t("linear:enterAPositiveIntegerOrBlank")}</p>
)}
</div>
);
}
// SettingsFields renders the poll-interval, throttle-cap, and enabled toggle —
// the trailing "Settings" block of the watcher dialog.
export function SettingsFields({ form, setForm }: { form: FormState; setForm: FormSetter }) {
const { t } = useTranslation();
return (
<>
<div className="space-y-1.5">
<Label>{t("linear:pollIntervalSeconds")}</Label>
<p className="text-xs text-muted-foreground">{t("linear:pollIntervalHelp")}</p>
<Input
type="number"
value={form.pollInterval}
onChange={(e) => setForm((p) => ({ ...p, pollInterval: Number(e.target.value) }))}
min={60}
max={3600}
/>
</div>
<MaxInflightTasksField form={form} setForm={setForm} />
<SortByField form={form} setForm={setForm} />
<div className="flex items-center justify-between">
<div>
<Label>{t("linear:enabled")}</Label>
<p className="text-xs text-muted-foreground">{t("linear:enabledHelp")}</p>
</div>
<Switch
checked={form.enabled}
onCheckedChange={(v) => setForm((p) => ({ ...p, enabled: v }))}
className="cursor-pointer"
/>
</div>
</>
);
}