d3058276d7
Co-authored-by: Kandev Agent <agent@kandev.dev>
159 lines
5.2 KiB
TypeScript
159 lines
5.2 KiB
TypeScript
"use client";
|
|
|
|
import { IconBug, IconClipboardList, IconGitPullRequest, IconLink } from "@tabler/icons-react";
|
|
import type { EntityReferenceSearchError } from "@/hooks/use-entity-reference-search";
|
|
import type {
|
|
EntityReference,
|
|
EntityReferenceGroupStatus,
|
|
EntityReferenceSearchGroup,
|
|
} from "@/lib/types/entity-reference";
|
|
import {
|
|
selectableEntityReferences,
|
|
visibleEntityReferenceGroups,
|
|
} from "./entity-reference-groups";
|
|
import { PopupMenu, PopupMenuItem, useMenuItemRefs } from "./popup-menu";
|
|
import { useTranslation } from "react-i18next";
|
|
import { t } from "@/lib/i18n";
|
|
|
|
export {
|
|
selectableEntityReferences,
|
|
visibleEntityReferenceGroups,
|
|
} from "./entity-reference-groups";
|
|
|
|
type EntityReferenceMenuProps = {
|
|
isOpen: boolean;
|
|
clientRect?: (() => DOMRect | null) | null;
|
|
groups: EntityReferenceSearchGroup[];
|
|
query: string;
|
|
selectedIndex: number;
|
|
isSearching: boolean;
|
|
error: EntityReferenceSearchError | null;
|
|
onRetry: () => void;
|
|
onSelect: (reference: EntityReference) => void;
|
|
onClose: () => void;
|
|
setSelectedIndex: (index: number) => void;
|
|
};
|
|
|
|
// Catalog keys, not copy: this table is built at module load, so a `t()` here
|
|
// would freeze at the boot locale. Keys resolve at render.
|
|
const GROUP_STATUS_LABEL_KEYS: Partial<Record<EntityReferenceGroupStatus, string>> = {
|
|
unauthorized: "task:groupStatusUnauthorized",
|
|
rate_limited: "task:groupStatusRateLimited",
|
|
timeout: "task:groupStatusTimeout",
|
|
upstream_error: "task:groupStatusUpstreamError",
|
|
unsupported_scope: "task:groupStatusUnsupportedScope",
|
|
};
|
|
|
|
function groupStatusLabel(
|
|
translate: (key: string) => string,
|
|
status: EntityReferenceGroupStatus,
|
|
): string {
|
|
const key = GROUP_STATUS_LABEL_KEYS[status];
|
|
return key ? translate(key) : translate("task:providerUnavailable");
|
|
}
|
|
|
|
function entityReferenceIcon(kind: string) {
|
|
if (kind === "task") return <IconClipboardList className="h-4 w-4" />;
|
|
if (kind === "issue" || kind === "work_item") return <IconBug className="h-4 w-4" />;
|
|
if (kind === "pull_request" || kind === "merge_request") {
|
|
return <IconGitPullRequest className="h-4 w-4" />;
|
|
}
|
|
return <IconLink className="h-4 w-4" data-testid="entity-reference-generic-icon" />;
|
|
}
|
|
|
|
function entityReferenceEmptyState(
|
|
query: string,
|
|
isSearching: boolean,
|
|
error: EntityReferenceSearchError | null,
|
|
onRetry: () => void,
|
|
) {
|
|
if (!query.trim()) return t("task:typeToSearchWorkItems");
|
|
if (isSearching) return t("task:searchingWorkItems");
|
|
if (error) {
|
|
return (
|
|
<div className="flex min-h-11 items-center justify-between gap-3 px-3 text-xs">
|
|
<span className="text-muted-foreground">{error.message}</span>
|
|
<button
|
|
type="button"
|
|
className="min-h-11 shrink-0 cursor-pointer px-2 font-medium text-primary"
|
|
onClick={onRetry}
|
|
>
|
|
{t("task:retry")}
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
return t("task:noWorkItemsFound");
|
|
}
|
|
|
|
export function EntityReferenceMenu({
|
|
isOpen,
|
|
clientRect,
|
|
groups,
|
|
query,
|
|
selectedIndex,
|
|
isSearching,
|
|
error,
|
|
onRetry,
|
|
onSelect,
|
|
onClose,
|
|
setSelectedIndex,
|
|
}: EntityReferenceMenuProps) {
|
|
const { t } = useTranslation();
|
|
const { setItemRef } = useMenuItemRefs(selectedIndex);
|
|
const selectable = selectableEntityReferences(groups);
|
|
let itemIndex = 0;
|
|
const visibleGroups = visibleEntityReferenceGroups(groups);
|
|
const hasContent = visibleGroups.length > 0;
|
|
return (
|
|
<PopupMenu
|
|
isOpen={isOpen}
|
|
testId="entity-reference-menu"
|
|
position={null}
|
|
clientRect={clientRect}
|
|
title={t("task:referenceWorkItems")}
|
|
selectedIndex={selectedIndex}
|
|
onClose={onClose}
|
|
hasItems={hasContent}
|
|
emptyState={
|
|
<div className="px-3 py-2 text-xs text-muted-foreground">
|
|
{entityReferenceEmptyState(query, isSearching, error, onRetry)}
|
|
</div>
|
|
}
|
|
>
|
|
{visibleGroups.map((group) => (
|
|
<div key={group.source} data-reference-group={group.source}>
|
|
<div className="flex items-center justify-between gap-2 px-3 py-1 text-[11px] text-muted-foreground">
|
|
<span className="truncate font-medium">{group.display_name}</span>
|
|
<span className="shrink-0">{group.kind_label}</span>
|
|
</div>
|
|
{group.status === "ok" ? (
|
|
group.results.map((reference) => {
|
|
const index = itemIndex++;
|
|
return (
|
|
<PopupMenuItem
|
|
key={reference.ref}
|
|
icon={entityReferenceIcon(group.kind)}
|
|
label={`#${reference.key || reference.title}`}
|
|
description={reference.title}
|
|
isSelected={selectedIndex === index}
|
|
onClick={() => onSelect(reference)}
|
|
onMouseEnter={() => setSelectedIndex(index)}
|
|
itemRef={setItemRef(index)}
|
|
/>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="min-h-11 px-3 py-2 text-xs text-muted-foreground">
|
|
{groupStatusLabel(t, group.status)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
{isSearching && selectable.length > 0 && (
|
|
<div className="px-3 py-1 text-[11px] text-muted-foreground">{t("task:updating")}</div>
|
|
)}
|
|
</PopupMenu>
|
|
);
|
|
}
|