Files
kandev/apps/web/components/jira/my-jira/list-toolbar.tsx
T
2026-09-12 14:58:49 +02:00

431 lines
13 KiB
TypeScript

"use client";
import { useRef, useState, type RefObject } from "react";
import {
IconArrowsSort,
IconBookmark,
IconCheck,
IconCode,
IconPlus,
IconRefresh,
IconSearch,
IconTrash,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { savedViewLabel, type SavedView } from "./use-saved-views";
import type { SortKey } from "./filter-model";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { isActionConfirmationTarget } from "@/components/confirmation/action-confirm-popover";
import { SavedTaskViewDeleteConfirmation } from "@/components/confirmation/saved-task-view-delete-confirmation";
import {
useSavedTaskViewDeleteMenu,
type SavedTaskViewDeleteTarget,
} from "@/components/confirmation/use-saved-task-view-delete-confirmation";
/** `value` is the persisted SortKey; only the catalog key is copy. */
const SORT_OPTIONS: { value: SortKey; labelKey: string }[] = [
{ value: "updated", labelKey: "jira:sortUpdated" },
{ value: "created", labelKey: "jira:sortCreated" },
{ value: "priority", labelKey: "jira:sortPriority" },
];
type ListToolbarProps = {
searchText: string;
onSearchChange: (text: string) => void;
views: SavedView[];
activeViewId: string | null;
onSelectView: (id: string) => void;
onDeleteView: (id: string) => void;
onSaveView: (name: string) => void;
count: number;
loading: boolean;
sort: SortKey;
onSortChange: (sort: SortKey) => void;
onRefresh: () => void;
showJqlEditor: boolean;
onToggleJqlEditor: () => void;
};
export function ListToolbar({
searchText,
onSearchChange,
views,
activeViewId,
onSelectView,
onDeleteView,
onSaveView,
count,
loading,
sort,
onSortChange,
onRefresh,
showJqlEditor,
onToggleJqlEditor,
}: ListToolbarProps) {
const { t } = useTranslation();
const activeView = views.find((v) => v.id === activeViewId);
const sortOption = SORT_OPTIONS.find((o) => o.value === sort);
const sortLabel = t(sortOption?.labelKey ?? "jira:sortUpdated");
return (
<div className="flex items-center gap-2 px-6 py-2.5 border-b shrink-0 flex-wrap">
<SearchInput value={searchText} onChange={onSearchChange} />
<ViewsDropdown
views={views}
activeViewId={activeViewId}
onSelect={onSelectView}
onDelete={onDeleteView}
activeName={activeView ? savedViewLabel(t, activeView) : undefined}
/>
<SaveViewButton onSave={onSaveView} />
<div className="ml-auto flex items-center gap-1">
<span className="text-xs text-muted-foreground tabular-nums mr-2">
{loading ? t("jira:loading2") : t("jira:ticketOnThisPage", { count })}
</span>
<SortDropdown sort={sort} sortLabel={sortLabel} onSortChange={onSortChange} />
<Button
variant="ghost"
size="icon"
onClick={onRefresh}
disabled={loading}
className="cursor-pointer"
title={t("jira:refresh")}
>
<IconRefresh className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`} />
</Button>
<Button
variant={showJqlEditor ? "default" : "ghost"}
onClick={onToggleJqlEditor}
className="cursor-pointer text-xs gap-1.5"
title={t("jira:toggleRawJqlEditor")}
>
<IconCode className="h-3.5 w-3.5" />
JQL
</Button>
</div>
</div>
);
}
function SortDropdown({
sort,
sortLabel,
onSortChange,
}: {
sort: SortKey;
sortLabel: string;
onSortChange: (sort: SortKey) => void;
}) {
const { t } = useTranslation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="cursor-pointer text-xs gap-1.5">
<IconArrowsSort className="h-3.5 w-3.5" />
{t("jira:sortLabelled", { label: sortLabel })}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
{SORT_OPTIONS.map((o) => (
<DropdownMenuCheckboxItem
key={o.value}
checked={sort === o.value}
onCheckedChange={() => onSortChange(o.value)}
className="cursor-pointer"
>
{t(o.labelKey)}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function SearchInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
const { t } = useTranslation();
return (
<div className="relative flex-1 max-w-md min-w-[200px]">
<IconSearch className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
<Input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={t("jira:searchTicketKeyOrText")}
className="text-xs pl-8"
/>
</div>
);
}
function ViewsDropdown({
views,
activeViewId,
onSelect,
onDelete,
activeName,
}: {
views: SavedView[];
activeViewId: string | null;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
activeName: string | undefined;
}) {
const { t } = useTranslation();
const { isFinePointer, isMobile } = useResponsiveBreakpoint();
const contentRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const builtin = views.filter((v) => v.builtin);
const custom = views.filter((v) => !v.builtin);
const deletion = useSavedTaskViewDeleteMenu<HTMLButtonElement>(custom, isMobile);
const deleteProps: ViewDeletionProps = {
isFinePointer: !isMobile && isFinePointer,
deleteTarget: deletion.target,
deleteAnchorRef: deletion.anchorRef,
onDeleteOpenChange: (nextOpen) => {
if (!nextOpen) deletion.close();
},
onRequestDelete: (view) => deletion.request({ id: view.id, label: savedViewLabel(t, view) }),
onConfirmDelete: onDelete,
onRegisterDeleteAnchor: deletion.registerAnchor,
};
return (
<Popover open={deletion.open} onOpenChange={deletion.onOpenChange}>
<PopoverTrigger asChild>
<Button ref={triggerRef} variant="outline" className="cursor-pointer text-xs gap-1.5">
<IconBookmark className="h-3.5 w-3.5" />
{activeName ?? t("jira:noView")}
</Button>
</PopoverTrigger>
<PopoverContent
ref={contentRef}
align="start"
className="w-60 max-w-[calc(100vw-1rem)] overflow-x-hidden p-0"
onCloseAutoFocus={deletion.onCloseAutoFocus}
onFocusOutside={(event) => {
if (isActionConfirmationTarget(event.target)) event.preventDefault();
}}
onInteractOutside={(event) => {
if (isActionConfirmationTarget(event.target)) event.preventDefault();
}}
>
<ViewsGroup
label={t("jira:builtIn")}
views={builtin}
activeViewId={activeViewId}
onSelect={(id) => {
onSelect(id);
deletion.onOpenChange(false);
}}
{...deleteProps}
/>
{custom.length > 0 && (
<>
<div className="border-t" />
<ViewsGroup
label={t("jira:saved")}
views={custom}
activeViewId={activeViewId}
onSelect={(id) => {
onSelect(id);
deletion.onOpenChange(false);
}}
{...deleteProps}
/>
</>
)}
</PopoverContent>
{(isMobile || isFinePointer) && deletion.target ? (
<SavedTaskViewDeleteConfirmation
target={deletion.target}
presentation="popover"
open
anchorRef={isMobile ? triggerRef : deletion.anchorRef}
focusBoundaryRef={contentRef}
onOpenChange={(nextOpen) => {
if (!nextOpen) deletion.close();
}}
onConfirm={onDelete}
/>
) : null}
</Popover>
);
}
type ViewDeletionProps = {
isFinePointer: boolean;
deleteTarget: SavedTaskViewDeleteTarget | null;
deleteAnchorRef: RefObject<HTMLElement | null>;
onDeleteOpenChange: (open: boolean) => void;
onRequestDelete: (view: SavedView) => void;
onConfirmDelete: (id: string) => void;
onRegisterDeleteAnchor: (id: string, element: HTMLButtonElement | null) => void;
};
function ViewsGroup({
label,
views,
activeViewId,
onSelect,
isFinePointer,
deleteTarget,
deleteAnchorRef,
onDeleteOpenChange,
onRequestDelete,
onConfirmDelete,
onRegisterDeleteAnchor,
}: {
label: string;
views: SavedView[];
activeViewId: string | null;
onSelect: (id: string) => void;
} & ViewDeletionProps) {
return (
<div className="py-1">
<div className="px-3 py-1 text-[10px] uppercase tracking-wider text-muted-foreground font-semibold">
{label}
</div>
{views.map((v) => (
<ViewRow
key={v.id}
view={v}
active={v.id === activeViewId}
onSelect={onSelect}
isFinePointer={isFinePointer}
deleteTarget={deleteTarget}
deleteAnchorRef={deleteAnchorRef}
onDeleteOpenChange={onDeleteOpenChange}
onRequestDelete={onRequestDelete}
onConfirmDelete={onConfirmDelete}
onRegisterDeleteAnchor={onRegisterDeleteAnchor}
/>
))}
</div>
);
}
function ViewRow({
view,
active,
onSelect,
isFinePointer,
deleteTarget,
deleteAnchorRef,
onDeleteOpenChange,
onRequestDelete,
onConfirmDelete,
onRegisterDeleteAnchor,
}: {
view: SavedView;
active: boolean;
onSelect: (id: string) => void;
} & ViewDeletionProps) {
const { t } = useTranslation();
const label = savedViewLabel(t, view);
if (!isFinePointer && deleteTarget?.id === view.id) {
return (
<div className="min-w-0 px-2 py-1">
<SavedTaskViewDeleteConfirmation
target={deleteTarget}
presentation="inline"
open
anchorRef={deleteAnchorRef}
onOpenChange={onDeleteOpenChange}
onConfirm={onConfirmDelete}
/>
</div>
);
}
return (
<div className="group flex min-w-0 items-center px-2">
<button
type="button"
onClick={() => onSelect(view.id)}
className="flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50"
>
<IconCheck className={`h-3.5 w-3.5 ${active ? "opacity-100" : "opacity-0"}`} />
<span className="min-w-0 flex-1 truncate">{label}</span>
</button>
{!view.builtin && (
<button
ref={(element) => onRegisterDeleteAnchor(view.id, element)}
type="button"
onClick={(e) => {
e.stopPropagation();
onRequestDelete(view);
}}
className={cn(
"flex shrink-0 cursor-pointer items-center justify-center rounded hover:bg-muted",
isFinePointer
? "h-7 w-7 opacity-0 group-hover:opacity-100 focus:opacity-100"
: "h-12 w-12 opacity-100",
)}
title={t("jira:deleteView")}
aria-label={t("common:deleteSavedTaskViewAction", { name: label })}
>
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
</button>
)}
</div>
);
}
function SaveViewButton({ onSave }: { onSave: (name: string) => void }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [name, setName] = useState("");
const submit = () => {
const trimmed = name.trim();
if (!trimmed) return;
onSave(trimmed);
setName("");
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
className="cursor-pointer text-xs gap-1.5"
title={t("jira:saveCurrentFiltersAsAView")}
>
<IconPlus className="h-3.5 w-3.5" />
{t("jira:saveView")}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-3 space-y-2">
<div className="text-xs font-semibold">{t("jira:saveCurrentFiltersAs")}</div>
<Input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
}}
placeholder={t("jira:myOpenBugs")}
className="text-xs"
/>
<div className="flex justify-end gap-1">
<Button variant="ghost" onClick={() => setOpen(false)} className="cursor-pointer text-xs">
{t("common:cancel")}
</Button>
<Button onClick={submit} disabled={!name.trim()} className="cursor-pointer text-xs">
{t("common:save")}
</Button>
</div>
</PopoverContent>
</Popover>
);
}