167 lines
4.9 KiB
TypeScript
167 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
import type { ReactNode } from "react";
|
|
import { Trans, useTranslation } from "react-i18next";
|
|
import { IconRefresh } from "@tabler/icons-react";
|
|
import { Button } from "@kandev/ui/button";
|
|
import { Input } from "@kandev/ui/input";
|
|
import { cn, formatRelativeTime } from "@/lib/utils";
|
|
import { controlSizingClassName } from "@kandev/ui/control-sizing";
|
|
|
|
export type IntegrationListToolbarProps = {
|
|
title: string;
|
|
titleControl?: ReactNode;
|
|
count: number;
|
|
loading: boolean;
|
|
lastFetchedAt: Date | null;
|
|
customQuery: string;
|
|
committedQuery: string;
|
|
onCustomQueryChange: (value: string) => void;
|
|
onCommitCustomQuery: () => void;
|
|
onRefresh: () => void;
|
|
filter: ReactNode;
|
|
queryPlaceholder: string;
|
|
titleTestId?: string;
|
|
queryTestId?: string;
|
|
refreshTestId?: string;
|
|
};
|
|
|
|
type RefreshControlsProps = Pick<
|
|
IntegrationListToolbarProps,
|
|
"loading" | "lastFetchedAt" | "onRefresh" | "refreshTestId"
|
|
> & { showUpdatedPrefix: boolean };
|
|
|
|
function RefreshControls({
|
|
loading,
|
|
lastFetchedAt,
|
|
onRefresh,
|
|
refreshTestId,
|
|
showUpdatedPrefix,
|
|
}: RefreshControlsProps) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<>
|
|
{lastFetchedAt && !loading ? (
|
|
<span className="min-w-0 truncate text-xs leading-5 text-muted-foreground">
|
|
{showUpdatedPrefix ? t("github:updated") : ""}
|
|
{formatRelativeTime(lastFetchedAt.toISOString())}
|
|
</span>
|
|
) : null}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="cursor-pointer"
|
|
onClick={onRefresh}
|
|
disabled={loading}
|
|
title={t("github:refresh")}
|
|
data-testid={refreshTestId}
|
|
>
|
|
<IconRefresh className={cn("h-4 w-4", loading && "animate-spin")} />
|
|
</Button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MobileToolbarStatus({ count, ...props }: RefreshControlsProps & { count: number }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<div className="flex min-w-0 items-center justify-between gap-3 md:hidden">
|
|
<span
|
|
className="shrink-0 whitespace-nowrap text-xs leading-5 text-muted-foreground"
|
|
data-testid="integration-mobile-result-count"
|
|
>
|
|
{props.loading ? (
|
|
t("integrations:loadingResults")
|
|
) : (
|
|
<Trans
|
|
i18nKey="integrations:resultCount"
|
|
count={count}
|
|
components={{ count: <span className="font-medium tabular-nums text-foreground" /> }}
|
|
/>
|
|
)}
|
|
</span>
|
|
<div className="flex min-w-0 items-center justify-end gap-2">
|
|
<RefreshControls {...props} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function IntegrationListToolbar({
|
|
title,
|
|
titleControl,
|
|
count,
|
|
loading,
|
|
lastFetchedAt,
|
|
customQuery,
|
|
committedQuery,
|
|
onCustomQueryChange,
|
|
onCommitCustomQuery,
|
|
onRefresh,
|
|
filter,
|
|
queryPlaceholder,
|
|
titleTestId,
|
|
queryTestId,
|
|
refreshTestId,
|
|
}: IntegrationListToolbarProps) {
|
|
const { t } = useTranslation();
|
|
const dirty = customQuery !== committedQuery;
|
|
return (
|
|
<div className="flex shrink-0 flex-col gap-2 border-b px-4 py-2.5 sm:px-6 md:flex-row md:flex-wrap md:items-center md:gap-3">
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<div className="flex min-w-0 flex-1 items-baseline gap-2 md:flex-initial">
|
|
{titleControl ?? (
|
|
<h2 className="truncate text-sm font-semibold" data-testid={titleTestId}>
|
|
{title}
|
|
</h2>
|
|
)}
|
|
<span className="hidden text-xs tabular-nums text-muted-foreground md:inline">
|
|
{loading ? "…" : count}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
{filter}
|
|
<div className="relative w-full md:min-w-[240px] md:flex-1">
|
|
<Input
|
|
value={customQuery}
|
|
onChange={(event) => onCustomQueryChange(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
onCommitCustomQuery();
|
|
}
|
|
}}
|
|
onBlur={() => {
|
|
if (dirty) onCommitCustomQuery();
|
|
}}
|
|
placeholder={queryPlaceholder}
|
|
className={controlSizingClassName("standard", "pr-20")}
|
|
data-testid={queryTestId}
|
|
/>
|
|
{dirty ? (
|
|
<span className="pointer-events-none absolute right-2 top-1/2 hidden -translate-y-1/2 text-[10px] uppercase tracking-wider text-muted-foreground sm:inline">
|
|
{t("github:pressEnter")}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<MobileToolbarStatus
|
|
count={count}
|
|
loading={loading}
|
|
lastFetchedAt={lastFetchedAt}
|
|
onRefresh={onRefresh}
|
|
refreshTestId={refreshTestId}
|
|
showUpdatedPrefix
|
|
/>
|
|
<div className="ml-auto hidden items-center gap-2 md:flex">
|
|
<RefreshControls
|
|
loading={loading}
|
|
lastFetchedAt={lastFetchedAt}
|
|
onRefresh={onRefresh}
|
|
refreshTestId={refreshTestId}
|
|
showUpdatedPrefix
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|