feat(plugins): add source-control extension contracts (#2117)
This commit is contained in:
@@ -9,6 +9,7 @@ import {
|
||||
toTaskPreset,
|
||||
} from "./action-presets";
|
||||
import { IconEye, IconSparkles } from "@tabler/icons-react";
|
||||
import { iconForIntegrationPreset } from "@/components/integrations/integration-preset-icons";
|
||||
|
||||
describe("interpolatePromptTemplate", () => {
|
||||
it("replaces {{url}} and {{title}} placeholders", () => {
|
||||
@@ -47,6 +48,10 @@ describe("iconForPresetKey", () => {
|
||||
expect(iconForPresetKey("eye")).toBe(IconEye);
|
||||
});
|
||||
|
||||
it.each(["eye", "message", "tool"])("shares the host semantic %s glyph", (key) => {
|
||||
expect(iconForPresetKey(key)).toBe(iconForIntegrationPreset(key));
|
||||
});
|
||||
|
||||
it("falls back to sparkle for unknown or missing keys", () => {
|
||||
expect(iconForPresetKey(undefined)).toBe(IconSparkles);
|
||||
expect(iconForPresetKey("nonsense")).toBe(IconSparkles);
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
import {
|
||||
IconEye,
|
||||
IconMessageDots,
|
||||
IconTool,
|
||||
IconCode,
|
||||
IconSearch,
|
||||
IconBug,
|
||||
IconSparkles,
|
||||
IconChecks,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import { iconForIntegrationPreset } from "@/components/integrations/integration-preset-icons";
|
||||
import type {
|
||||
GitHubActionPreset,
|
||||
GitHubActionPresetIcon,
|
||||
@@ -24,23 +15,26 @@ export const PRESET_ICON_CHOICES: {
|
||||
icon: Icon;
|
||||
labelKey: string;
|
||||
}[] = [
|
||||
{ key: "eye", icon: IconEye, labelKey: "github:presetIconEye" },
|
||||
{ key: "message", icon: IconMessageDots, labelKey: "github:presetIconMessage" },
|
||||
{ key: "tool", icon: IconTool, labelKey: "github:presetIconTool" },
|
||||
{ key: "code", icon: IconCode, labelKey: "github:presetIconCode" },
|
||||
{ key: "search", icon: IconSearch, labelKey: "github:presetIconSearch" },
|
||||
{ key: "bug", icon: IconBug, labelKey: "github:presetIconBug" },
|
||||
{ key: "sparkle", icon: IconSparkles, labelKey: "github:presetIconSparkle" },
|
||||
{ key: "check", icon: IconChecks, labelKey: "github:presetIconCheck" },
|
||||
{ key: "eye", icon: iconForIntegrationPreset("eye"), labelKey: "github:presetIconEye" },
|
||||
{
|
||||
key: "message",
|
||||
icon: iconForIntegrationPreset("message"),
|
||||
labelKey: "github:presetIconMessage",
|
||||
},
|
||||
{ key: "tool", icon: iconForIntegrationPreset("tool"), labelKey: "github:presetIconTool" },
|
||||
{ key: "code", icon: iconForIntegrationPreset("code"), labelKey: "github:presetIconCode" },
|
||||
{ key: "search", icon: iconForIntegrationPreset("search"), labelKey: "github:presetIconSearch" },
|
||||
{ key: "bug", icon: iconForIntegrationPreset("bug"), labelKey: "github:presetIconBug" },
|
||||
{
|
||||
key: "sparkle",
|
||||
icon: iconForIntegrationPreset("sparkle"),
|
||||
labelKey: "github:presetIconSparkle",
|
||||
},
|
||||
{ key: "check", icon: iconForIntegrationPreset("check"), labelKey: "github:presetIconCheck" },
|
||||
];
|
||||
|
||||
const ICON_BY_KEY: Record<string, Icon> = Object.fromEntries(
|
||||
PRESET_ICON_CHOICES.map((choice) => [choice.key, choice.icon]),
|
||||
);
|
||||
|
||||
export function iconForPresetKey(key: string | undefined): Icon {
|
||||
if (!key) return IconSparkles;
|
||||
return ICON_BY_KEY[key] ?? IconSparkles;
|
||||
return iconForIntegrationPreset(key);
|
||||
}
|
||||
|
||||
// Interpolate `{{url}}` and `{{title}}` placeholders in a prompt template.
|
||||
|
||||
@@ -15,7 +15,7 @@ import { cn } from "@/lib/utils";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
import type { GitHubIssue, TaskIssueLink } from "@/lib/types/github";
|
||||
import type { LaunchPayload, TaskPreset } from "./quick-task-launcher";
|
||||
import { TaskRowIndicator } from "./task-row-indicator";
|
||||
import { TaskRowIndicator } from "@/components/integrations/task-row-indicator";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type IssueListProps = {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { IconRefresh } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
import { RepoFilterCombobox } from "./repo-filter-combobox";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IntegrationListToolbar } from "@/components/integrations/integration-list-toolbar";
|
||||
import { RepoFilterCombobox } from "./repo-filter-combobox";
|
||||
|
||||
type ListToolbarProps = {
|
||||
title: string;
|
||||
@@ -23,40 +19,6 @@ type ListToolbarProps = {
|
||||
onRefresh: () => void;
|
||||
};
|
||||
|
||||
function RefreshControls({
|
||||
loading,
|
||||
lastFetchedAt,
|
||||
onRefresh,
|
||||
showUpdatedPrefix,
|
||||
}: {
|
||||
loading: boolean;
|
||||
lastFetchedAt: Date | null;
|
||||
onRefresh: () => void;
|
||||
showUpdatedPrefix: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<>
|
||||
{lastFetchedAt && !loading && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{showUpdatedPrefix ? t("github:updated") : ""}
|
||||
{formatRelativeTime(lastFetchedAt.toISOString())}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 cursor-pointer"
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
title={t("github:refresh")}
|
||||
>
|
||||
<IconRefresh className={cn("h-4 w-4", loading && "animate-spin")} />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ListToolbar({
|
||||
title,
|
||||
count,
|
||||
@@ -72,67 +34,31 @@ export function ListToolbar({
|
||||
onRefresh,
|
||||
}: ListToolbarProps) {
|
||||
const { t } = useTranslation();
|
||||
const dirty = customQuery !== committedQuery;
|
||||
return (
|
||||
<div className="px-4 sm:px-6 py-2.5 border-b shrink-0 flex flex-col md:flex-row md:items-center md:flex-wrap gap-2 md:gap-3">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="flex items-baseline gap-2 min-w-0 flex-1 md:flex-initial">
|
||||
<h2 className="text-sm font-semibold truncate" data-testid="github-list-toolbar-title">
|
||||
{title}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{loading ? "…" : count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<RefreshControls
|
||||
loading={loading}
|
||||
lastFetchedAt={lastFetchedAt}
|
||||
onRefresh={onRefresh}
|
||||
showUpdatedPrefix={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RepoFilterCombobox
|
||||
repoFilter={repoFilter}
|
||||
onRepoFilterChange={onRepoFilterChange}
|
||||
repoOptions={repoOptions}
|
||||
ariaLabel={t("github:filterGithubResultsByRepository")}
|
||||
triggerClassName="w-full md:w-[220px] h-8 border border-input bg-background hover:bg-secondary/50 px-2 py-1.5 text-xs/relaxed"
|
||||
className="md:min-w-[360px]"
|
||||
testId="github-repo-filter-trigger"
|
||||
dropdownTestId="github-repo-filter-dropdown"
|
||||
/>
|
||||
<div className="w-full md:flex-1 md:min-w-[240px] relative">
|
||||
<Input
|
||||
value={customQuery}
|
||||
onChange={(e) => onCustomQueryChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
onCommitCustomQuery();
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (dirty) onCommitCustomQuery();
|
||||
}}
|
||||
placeholder={t("github:customQueryPressEnterEG")}
|
||||
className="h-8 pr-20"
|
||||
<IntegrationListToolbar
|
||||
title={title}
|
||||
count={count}
|
||||
loading={loading}
|
||||
lastFetchedAt={lastFetchedAt}
|
||||
customQuery={customQuery}
|
||||
committedQuery={committedQuery}
|
||||
onCustomQueryChange={onCustomQueryChange}
|
||||
onCommitCustomQuery={onCommitCustomQuery}
|
||||
onRefresh={onRefresh}
|
||||
filter={
|
||||
<RepoFilterCombobox
|
||||
repoFilter={repoFilter}
|
||||
onRepoFilterChange={onRepoFilterChange}
|
||||
repoOptions={repoOptions}
|
||||
ariaLabel={t("github:filterGithubResultsByRepository")}
|
||||
triggerClassName="h-8 w-full border border-input bg-background px-2 py-1.5 text-xs/relaxed hover:bg-secondary/50 md:w-[220px]"
|
||||
className="md:min-w-[360px]"
|
||||
testId="github-repo-filter-trigger"
|
||||
dropdownTestId="github-repo-filter-dropdown"
|
||||
/>
|
||||
{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">
|
||||
{t("github:pressEnter")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="hidden md:flex items-center gap-2 md:ml-auto">
|
||||
<RefreshControls
|
||||
loading={loading}
|
||||
lastFetchedAt={lastFetchedAt}
|
||||
onRefresh={onRefresh}
|
||||
showUpdatedPrefix
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
queryPlaceholder={t("github:customQueryPressEnterEG")}
|
||||
titleTestId="github-list-toolbar-title"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import Link from "@/components/routing/app-link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChangeRequestList, ChangeRequestRow } from "@/components/integrations/change-request-list";
|
||||
import {
|
||||
IconGitPullRequest,
|
||||
IconGitPullRequestClosed,
|
||||
IconGitMerge,
|
||||
IconPlus,
|
||||
IconChevronDown,
|
||||
} from "@tabler/icons-react";
|
||||
import type { Icon } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { Spinner } from "@kandev/ui/spinner";
|
||||
IntegrationIcon,
|
||||
type IntegrationIconName,
|
||||
} from "@/components/integrations/integration-icon";
|
||||
import { IntegrationStartTaskMenu } from "@/components/integrations/integration-start-task-menu";
|
||||
import { cn, formatRelativeTime } from "@/lib/utils";
|
||||
import type { GitHubPR, GitHubPRStatus, TaskPR } from "@/lib/types/github";
|
||||
import type { LaunchPayload, TaskPreset } from "./quick-task-launcher";
|
||||
import { PRStatusBadges } from "./pr-status-badges";
|
||||
import { prStatusKey, usePRStatuses } from "./use-pr-statuses";
|
||||
import { PRRowTaskIndicator } from "./pr-row-task-indicator";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { prStatusKey, usePRStatuses } from "./use-pr-statuses";
|
||||
|
||||
type PRListProps = {
|
||||
workspaceId: string | null;
|
||||
@@ -35,20 +24,17 @@ type PRListProps = {
|
||||
prKeyToTasks?: Map<string, TaskPR[]>;
|
||||
};
|
||||
|
||||
// Prefer the enriched PR returned by the batched status endpoint — the search
|
||||
// API used to populate `items` does not include head/base branches, so the
|
||||
// launcher needs the enriched copy to pre-fill the task dialog correctly.
|
||||
export function pickPRForLaunch(pr: GitHubPR, status: GitHubPRStatus | null | undefined): GitHubPR {
|
||||
return status?.pr ?? pr;
|
||||
}
|
||||
|
||||
function prStateIcon(pr: GitHubPR): { Icon: Icon; className: string } {
|
||||
function prStateIcon(pr: GitHubPR): { name: IntegrationIconName; className: string } {
|
||||
if (pr.state === "merged")
|
||||
return { Icon: IconGitMerge, className: "text-purple-600 dark:text-purple-400" };
|
||||
return { name: "merged", className: "text-purple-600 dark:text-purple-400" };
|
||||
if (pr.state === "closed")
|
||||
return { Icon: IconGitPullRequestClosed, className: "text-red-600 dark:text-red-400" };
|
||||
if (pr.draft) return { Icon: IconGitPullRequest, className: "text-muted-foreground" };
|
||||
return { Icon: IconGitPullRequest, className: "text-emerald-600 dark:text-emerald-400" };
|
||||
return { name: "pull-request-closed", className: "text-red-600 dark:text-red-400" };
|
||||
if (pr.draft) return { name: "pull-request", className: "text-muted-foreground" };
|
||||
return { name: "pull-request", className: "text-emerald-600 dark:text-emerald-400" };
|
||||
}
|
||||
|
||||
function StartTaskMenu({
|
||||
@@ -61,42 +47,15 @@ function StartTaskMenu({
|
||||
onStartTask: PRListProps["onStartTask"];
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const launch = (preset: TaskPreset) => onStartTask({ kind: "pr", pr, preset });
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 gap-1 cursor-pointer"
|
||||
data-testid="pr-start-task-trigger"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5" />
|
||||
{t("github:task")}
|
||||
<IconChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{presets.map((p) => {
|
||||
const ItemIcon = p.icon;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={p.id}
|
||||
className="cursor-pointer gap-2 py-1.5"
|
||||
onSelect={() => launch(p)}
|
||||
data-testid="pr-start-task-preset"
|
||||
data-preset-id={p.id}
|
||||
>
|
||||
<ItemIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="text-xs font-medium leading-tight">{p.label}</span>
|
||||
<span className="text-[11px] text-muted-foreground leading-tight">{p.hint}</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<IntegrationStartTaskMenu
|
||||
presets={presets}
|
||||
onSelect={(preset) => onStartTask({ kind: "pr", pr, preset })}
|
||||
triggerLabel={t("github:task")}
|
||||
triggerAriaLabel={t("github:task")}
|
||||
triggerTestId="pr-start-task-trigger"
|
||||
itemTestId="pr-start-task-preset"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,25 +72,15 @@ function PRRow({
|
||||
onStartTask: PRListProps["onStartTask"];
|
||||
tasks: TaskPR[] | undefined;
|
||||
}) {
|
||||
const { Icon: StateIcon, className: stateIconClass } = prStateIcon(pr);
|
||||
const { t } = useTranslation();
|
||||
const { name: stateIconName, className: stateIconClass } = prStateIcon(pr);
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-3 px-4 py-3 hover:bg-muted/40 transition-colors"
|
||||
data-testid="pr-row"
|
||||
data-pr-number={pr.number}
|
||||
>
|
||||
<StateIcon className={cn("h-4 w-4 mt-1 shrink-0", stateIconClass)} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={pr.html_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-semibold hover:underline block truncate cursor-pointer"
|
||||
>
|
||||
{pr.title}
|
||||
</Link>
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 mt-0.5 text-xs text-muted-foreground">
|
||||
<ChangeRequestRow
|
||||
stateIcon={<IntegrationIcon name={stateIconName} className={cn("h-4 w-4", stateIconClass)} />}
|
||||
title={pr.title}
|
||||
href={pr.html_url}
|
||||
metadata={
|
||||
<>
|
||||
<span className="whitespace-nowrap">
|
||||
{pr.repo_owner}/{pr.repo_name}#{pr.number}
|
||||
</span>
|
||||
@@ -143,50 +92,26 @@ function PRRow({
|
||||
})}
|
||||
</span>
|
||||
<PRStatusBadges pr={pr} status={status} />
|
||||
<PRRowTaskIndicator tasks={tasks} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
</>
|
||||
}
|
||||
taskIndicator={<PRRowTaskIndicator tasks={tasks} />}
|
||||
action={
|
||||
<StartTaskMenu
|
||||
pr={pickPRForLaunch(pr, status)}
|
||||
presets={presets}
|
||||
onStartTask={onStartTask}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
testId="pr-row"
|
||||
dataAttributes={{ "data-pr-number": pr.number }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PRListBody({
|
||||
workspaceId,
|
||||
loading,
|
||||
error,
|
||||
items,
|
||||
presets,
|
||||
onStartTask,
|
||||
prKeyToTasks,
|
||||
}: PRListProps) {
|
||||
const { t } = useTranslation();
|
||||
function PRListBody({ workspaceId, items, presets, onStartTask, prKeyToTasks }: PRListProps) {
|
||||
const statuses = usePRStatuses(workspaceId, items);
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center py-10">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return <div className="text-center py-10 text-destructive text-sm">{error}</div>;
|
||||
}
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-10 text-muted-foreground text-sm">
|
||||
{t("github:noPullRequestsMatchThisFilter")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="divide-y">
|
||||
<>
|
||||
{items.map((pr) => {
|
||||
const key = prStatusKey(pr.repo_owner, pr.repo_name, pr.number);
|
||||
return (
|
||||
@@ -200,14 +125,20 @@ function PRListBody({
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PRList(props: PRListProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<ChangeRequestList
|
||||
loading={props.loading}
|
||||
error={props.error}
|
||||
emptyMessage={t("github:noPullRequestsMatchThisFilter")}
|
||||
isEmpty={props.items.length === 0}
|
||||
>
|
||||
<PRListBody {...props} />
|
||||
</div>
|
||||
</ChangeRequestList>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { TaskPR } from "@/lib/types/github";
|
||||
import { TaskRowIndicator } from "./task-row-indicator";
|
||||
import { TaskRowIndicator } from "@/components/integrations/task-row-indicator";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type PRRowTaskIndicatorProps = {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { Combobox, type ComboboxOption } from "@/components/combobox";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
const ALL_REPOS = "__all__";
|
||||
import { IntegrationRepositoryFilter } from "@/components/integrations/integration-repository-filter";
|
||||
|
||||
type RepoFilterComboboxProps = {
|
||||
repoFilter: string;
|
||||
@@ -18,13 +14,6 @@ type RepoFilterComboboxProps = {
|
||||
className?: string;
|
||||
};
|
||||
|
||||
function buildRepoFilterOptions(repoOptions: string[]): ComboboxOption[] {
|
||||
return [
|
||||
{ value: ALL_REPOS, label: t("github:allRepos"), keywords: ["all", "repositories", "repos"] },
|
||||
...repoOptions.map((repo) => ({ value: repo, label: repo, keywords: [repo] })),
|
||||
];
|
||||
}
|
||||
|
||||
export function RepoFilterCombobox({
|
||||
repoFilter,
|
||||
onRepoFilterChange,
|
||||
@@ -36,19 +25,13 @@ export function RepoFilterCombobox({
|
||||
className,
|
||||
}: RepoFilterComboboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const options = useMemo(() => buildRepoFilterOptions(repoOptions), [repoOptions]);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
value={repoFilter || ALL_REPOS}
|
||||
onValueChange={(value) => {
|
||||
// Combobox signals reselecting the active option with an empty value.
|
||||
if (!value) return;
|
||||
onRepoFilterChange(value === ALL_REPOS ? "" : value);
|
||||
}}
|
||||
options={options}
|
||||
<IntegrationRepositoryFilter
|
||||
value={repoFilter}
|
||||
onValueChange={onRepoFilterChange}
|
||||
options={repoOptions.map((repo) => ({ value: repo, label: repo, keywords: [repo] }))}
|
||||
ariaLabel={ariaLabel}
|
||||
placeholder={t("github:allRepos")}
|
||||
allLabel={t("github:allRepos")}
|
||||
searchPlaceholder={t("github:filterRepositories")}
|
||||
emptyMessage={t("github:noRepositoriesFound")}
|
||||
triggerClassName={triggerClassName}
|
||||
|
||||
@@ -1,151 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { IconChecklist } from "@tabler/icons-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@kandev/ui/dropdown-menu";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { linkToTask } from "@/lib/links";
|
||||
import { useTaskById } from "@/hooks/domains/kanban/use-task-by-id";
|
||||
import type { KanbanState } from "@/lib/state/slices";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export type TaskRowLink = {
|
||||
id: string;
|
||||
taskId: string;
|
||||
fallbackTitle: string;
|
||||
};
|
||||
|
||||
type TaskRowIndicatorProps = {
|
||||
tasks: TaskRowLink[] | undefined;
|
||||
testIdPrefix: string;
|
||||
emptyLabel?: string;
|
||||
};
|
||||
|
||||
function useTaskStepTitle(workflowStepId: string | undefined): string | null {
|
||||
return useAppStore((state) => {
|
||||
if (!workflowStepId) return null;
|
||||
const findIn = (steps: KanbanState["steps"]) =>
|
||||
steps.find((step) => step.id === workflowStepId)?.title ?? null;
|
||||
const activeTitle = findIn(state.kanban.steps);
|
||||
if (activeTitle) return activeTitle;
|
||||
for (const snapshot of Object.values(state.kanbanMulti.snapshots)) {
|
||||
const title = findIn(snapshot.steps);
|
||||
if (title) return title;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function TaskTitle({ task }: { task: TaskRowLink }) {
|
||||
const taskData = useTaskById(task.taskId);
|
||||
const title = taskData?.title ?? task.fallbackTitle;
|
||||
return (
|
||||
<span className="truncate text-foreground/80" title={title}>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const buttonClass = cn(
|
||||
"inline-flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs",
|
||||
"hover:bg-muted/70 transition-colors cursor-pointer w-fit max-w-full",
|
||||
);
|
||||
const iconClass = "h-3 w-3 shrink-0 text-muted-foreground";
|
||||
|
||||
function SingleTaskButton({
|
||||
task,
|
||||
testIdPrefix,
|
||||
onNavigate,
|
||||
}: {
|
||||
task: TaskRowLink;
|
||||
testIdPrefix: string;
|
||||
onNavigate: (taskId: string) => void;
|
||||
}) {
|
||||
const taskData = useTaskById(task.taskId);
|
||||
const stepTitle = useTaskStepTitle(taskData?.workflowStepId);
|
||||
const title = taskData?.title ?? task.fallbackTitle;
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`${testIdPrefix}-single`}
|
||||
onClick={() => onNavigate(task.taskId)}
|
||||
className={buttonClass}
|
||||
>
|
||||
<IconChecklist className={iconClass} />
|
||||
<span className="truncate text-foreground/80">{title}</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
<span>{t("github:taskLabelled", { title })}</span>
|
||||
{stepTitle ? (
|
||||
<span className="text-muted-foreground">{t("github:stepLabelled", { stepTitle })}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function TaskRowIndicator({ tasks, testIdPrefix, emptyLabel }: TaskRowIndicatorProps) {
|
||||
const { t } = useTranslation();
|
||||
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
||||
const router = useRouter();
|
||||
const navigate = useCallback(
|
||||
(taskId: string) => {
|
||||
setActiveTask(taskId);
|
||||
router.push(linkToTask(taskId));
|
||||
},
|
||||
[router, setActiveTask],
|
||||
);
|
||||
|
||||
if (!tasks || tasks.length === 0) {
|
||||
return emptyLabel ? (
|
||||
<span
|
||||
data-testid={`${testIdPrefix}-empty`}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{emptyLabel}
|
||||
</span>
|
||||
) : null;
|
||||
}
|
||||
if (tasks.length === 1) {
|
||||
return <SingleTaskButton task={tasks[0]} testIdPrefix={testIdPrefix} onNavigate={navigate} />;
|
||||
}
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" data-testid={`${testIdPrefix}-multi`} className={buttonClass}>
|
||||
<IconChecklist className={iconClass} />
|
||||
<span className="text-foreground/80">{t("github:tasks")}</span>
|
||||
<Badge variant="outline" className="h-4 px-1 py-0 text-[10px] shrink-0">
|
||||
{tasks.length}
|
||||
</Badge>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
{tasks.map((task) => (
|
||||
<DropdownMenuItem
|
||||
key={task.id}
|
||||
className="cursor-pointer"
|
||||
onSelect={() => navigate(task.taskId)}
|
||||
>
|
||||
<TaskTitle task={task} />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
export {
|
||||
TaskRowIndicator,
|
||||
type TaskRowIndicatorProps,
|
||||
type TaskRowLink,
|
||||
} from "@/components/integrations/task-row-indicator";
|
||||
|
||||
Reference in New Issue
Block a user