Files
kandev/apps/web/components/task/remote-contribution-action-items.tsx
T
2026-09-14 09:28:23 +01:00

319 lines
9.2 KiB
TypeScript

"use client";
import type { ComponentType } from "react";
import { useTranslation } from "react-i18next";
import {
IconCloudDownload,
IconCloudUpload,
IconEye,
IconGitCompare,
IconInfoCircle,
} from "@tabler/icons-react";
import { DropdownMenuItem } from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@kandev/ui/lib/utils";
type ActionKind = "compare" | "replace" | "use" | "view";
type DescriptionMode = "tooltip" | "inline";
type ActionSurface = "menu" | "drawer";
type ActionDefinition = {
kind: ActionKind;
labelKey: string;
descriptionKey: string;
icon: ComponentType<{ className?: string }>;
iconClassName: string;
};
const ACTION_DEFINITIONS: ActionDefinition[] = [
{
kind: "compare",
labelKey: "task:compareContributionVersions",
descriptionKey: "task:remoteContributionCompareDescription",
icon: IconGitCompare,
iconClassName: "text-primary",
},
{
kind: "replace",
labelKey: "task:replacePRBranch",
descriptionKey: "task:remoteContributionReplaceDescription",
icon: IconCloudUpload,
iconClassName: "text-destructive",
},
{
kind: "use",
labelKey: "task:usePRVersion",
descriptionKey: "task:remoteContributionUseDescription",
icon: IconCloudDownload,
iconClassName: "text-blue-500",
},
{
kind: "view",
labelKey: "task:prNumberVersion",
descriptionKey: "task:remoteContributionViewDescription",
icon: IconEye,
iconClassName: "text-muted-foreground",
},
];
export type RemoteContributionActionItemsProps = {
disabled: boolean;
replaceDisabled?: boolean;
useDisabled?: boolean;
onReplaceContribution?: () => void;
onUseContribution?: () => void;
onViewPRVersion?: () => void;
onCompareVersions?: () => void;
compareDisabled?: boolean;
descriptionMode?: DescriptionMode;
surface?: ActionSurface;
replaceLabelKey?: string;
useLabelKey?: string;
replaceDescriptionKey?: string;
useDescriptionKey?: string;
compareDescriptionKey?: string;
itemClassName?: string;
testIdPrefix?: string;
prNumber?: number;
viewLabelKey?: string;
};
function actionHandler(
kind: ActionKind,
props: RemoteContributionActionItemsProps,
): (() => void) | undefined {
if (kind === "compare") return props.onCompareVersions;
if (kind === "replace") return props.onReplaceContribution;
if (kind === "use") return props.onUseContribution;
return props.onViewPRVersion;
}
function actionDisabled(kind: ActionKind, props: RemoteContributionActionItemsProps): boolean {
if (kind === "compare") return Boolean(props.compareDisabled) || !props.onCompareVersions;
if (kind === "replace") return Boolean(props.replaceDisabled) || !props.onReplaceContribution;
if (kind === "use") return Boolean(props.useDisabled) || !props.onUseContribution;
return !props.onViewPRVersion;
}
function actionTestId(prefix: string | undefined, kind: ActionKind): string | undefined {
if (!prefix) return undefined;
if (kind === "compare") return `${prefix}-compare-versions`;
if (kind === "replace") return `${prefix}-replace-pr-branch`;
if (kind === "use") return `${prefix}-use-pr-version`;
return `${prefix}-view-pr-version`;
}
function ActionInfo({ description }: { description: string }) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span
aria-label={description}
className="ml-1 inline-flex h-5 w-5 shrink-0 cursor-help items-center justify-center rounded text-muted-foreground/70 hover:text-foreground"
onClick={(event) => event.stopPropagation()}
onKeyDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
role="img"
tabIndex={0}
>
<IconInfoCircle className="h-3.5 w-3.5" aria-hidden="true" />
</span>
</TooltipTrigger>
<TooltipContent side="left" sideOffset={4}>
{description}
</TooltipContent>
</Tooltip>
);
}
type ActionItemProps = {
definition: ActionDefinition;
disabled: boolean;
descriptionMode: DescriptionMode;
itemClassName?: string;
testIdPrefix?: string;
onSelect?: () => void;
prNumber?: number;
surface: ActionSurface;
viewLabelKey?: string;
replaceLabelKey?: string;
useLabelKey?: string;
replaceDescriptionKey?: string;
useDescriptionKey?: string;
compareDescriptionKey?: string;
};
function actionLabelKey(
definition: ActionDefinition,
props: Pick<ActionItemProps, "viewLabelKey" | "replaceLabelKey" | "useLabelKey">,
): string {
switch (definition.kind) {
case "view":
return props.viewLabelKey ?? definition.labelKey;
case "replace":
return props.replaceLabelKey ?? definition.labelKey;
case "use":
return props.useLabelKey ?? definition.labelKey;
default:
return definition.labelKey;
}
}
function actionDescriptionKey(
definition: ActionDefinition,
props: Pick<
ActionItemProps,
"replaceDescriptionKey" | "useDescriptionKey" | "compareDescriptionKey"
>,
): string {
switch (definition.kind) {
case "replace":
return props.replaceDescriptionKey ?? definition.descriptionKey;
case "use":
return props.useDescriptionKey ?? definition.descriptionKey;
case "compare":
return props.compareDescriptionKey ?? definition.descriptionKey;
default:
return definition.descriptionKey;
}
}
function ActionItemContent({
definition,
descriptionMode,
label,
description,
}: Pick<ActionItemProps, "definition" | "descriptionMode"> & {
label: string;
description: string;
}) {
const Icon = definition.icon;
return (
<>
<Icon className={cn("h-4 w-4 shrink-0", definition.iconClassName)} />
<span
className={cn(
"min-w-0 flex-1",
descriptionMode === "inline" && "flex flex-col gap-0.5 leading-tight",
)}
>
<span className="truncate">{label}</span>
{descriptionMode === "inline" && (
<span className="text-[10px] font-normal leading-tight text-muted-foreground">
{description}
</span>
)}
</span>
{descriptionMode === "tooltip" && <ActionInfo description={description} />}
</>
);
}
function actionItemClassName(
definition: ActionDefinition,
descriptionMode: DescriptionMode,
surface: ActionSurface,
itemClassName?: string,
): string {
return cn(
"cursor-pointer gap-3",
descriptionMode === "inline" && "min-h-11",
surface === "drawer" && "flex w-full items-start rounded-md px-3 py-2.5 text-left",
definition.kind === "replace" && "text-destructive",
itemClassName,
);
}
function ActionItem({
definition,
disabled,
descriptionMode,
itemClassName,
testIdPrefix,
onSelect,
prNumber,
surface,
viewLabelKey,
replaceLabelKey,
useLabelKey,
replaceDescriptionKey,
useDescriptionKey,
compareDescriptionKey,
}: ActionItemProps) {
const { t } = useTranslation();
const translationOptions = { number: prNumber ?? "" };
const label = t(
actionLabelKey(definition, { viewLabelKey, replaceLabelKey, useLabelKey }),
translationOptions,
);
const descriptionKey = actionDescriptionKey(definition, {
replaceDescriptionKey,
useDescriptionKey,
compareDescriptionKey,
});
const description = t(descriptionKey, { number: prNumber ?? "" });
const className = actionItemClassName(definition, descriptionMode, surface, itemClassName);
const content = (
<ActionItemContent
definition={definition}
descriptionMode={descriptionMode}
label={label}
description={description}
/>
);
if (surface === "drawer") {
return (
<button
type="button"
className={className}
data-testid={actionTestId(testIdPrefix, definition.kind)}
disabled={disabled}
onClick={onSelect}
>
{content}
</button>
);
}
return (
<DropdownMenuItem
className={className}
data-testid={actionTestId(testIdPrefix, definition.kind)}
disabled={disabled}
onClick={onSelect}
variant={definition.kind === "replace" ? "destructive" : "default"}
>
{content}
</DropdownMenuItem>
);
}
export function RemoteContributionActionItems(props: RemoteContributionActionItemsProps) {
const descriptionMode = props.descriptionMode ?? "tooltip";
const surface = props.surface ?? "menu";
return (
<>
{ACTION_DEFINITIONS.filter(
(definition) => definition.kind !== "compare" || props.onCompareVersions,
).map((definition) => (
<ActionItem
key={definition.kind}
definition={definition}
disabled={props.disabled || actionDisabled(definition.kind, props)}
descriptionMode={descriptionMode}
itemClassName={props.itemClassName}
testIdPrefix={props.testIdPrefix}
onSelect={actionHandler(definition.kind, props)}
prNumber={props.prNumber}
surface={surface}
viewLabelKey={props.viewLabelKey}
replaceLabelKey={props.replaceLabelKey}
useLabelKey={props.useLabelKey}
replaceDescriptionKey={props.replaceDescriptionKey}
useDescriptionKey={props.useDescriptionKey}
compareDescriptionKey={props.compareDescriptionKey}
/>
))}
</>
);
}