feat(projects): improve list filters
Some checks failed
Frontend CI/CD / build (push) Has been cancelled
Frontend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-24 15:18:48 +03:30
parent 22390592eb
commit 215425dede
3 changed files with 411 additions and 214 deletions

View File

@@ -0,0 +1,185 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Check, ChevronDown, Search } from "lucide-react";
import { Input } from "./input";
export interface MultiSearchableSelectOption {
value: string;
label: string;
searchText?: string;
}
interface MultiSearchableSelectProps {
values: string[];
onChange: (values: string[]) => void;
options: MultiSearchableSelectOption[];
placeholder?: string;
searchPlaceholder?: string;
emptyLabel?: string;
disabled?: boolean;
className?: string;
buttonClassName?: string;
renderValue?: (selectedOptions: MultiSearchableSelectOption[]) => string;
}
export function MultiSearchableSelect({
values,
onChange,
options,
placeholder = "",
searchPlaceholder,
emptyLabel,
disabled = false,
className = "",
buttonClassName = "",
renderValue,
}: MultiSearchableSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const [dropdownStyle, setDropdownStyle] = useState<React.CSSProperties>({});
const buttonRef = useRef<HTMLButtonElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const selectedOptions = useMemo(
() => options.filter((option) => values.includes(option.value)),
[options, values],
);
const filteredOptions = useMemo(() => {
const needle = query.trim().toLowerCase();
if (!needle) return options;
return options.filter((option) =>
`${option.label} ${option.searchText || ""}`.toLowerCase().includes(needle),
);
}, [options, query]);
const displayValue = useMemo(() => {
if (!selectedOptions.length) return placeholder;
if (renderValue) return renderValue(selectedOptions);
return selectedOptions.map((option) => option.label).join(", ");
}, [placeholder, renderValue, selectedOptions]);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
buttonRef.current &&
!buttonRef.current.contains(event.target as Node) &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setQuery("");
}
};
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]);
useEffect(() => {
if (!isOpen || !buttonRef.current) return;
const rect = buttonRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const dropdownHeight = 320;
const shouldOpenUp = spaceBelow < dropdownHeight && rect.top > spaceBelow;
setDropdownStyle({
position: "fixed",
top: shouldOpenUp ? `${rect.top - 4}px` : `${rect.bottom + 4}px`,
left: `${rect.left}px`,
width: `${rect.width}px`,
transform: shouldOpenUp ? "translateY(-100%)" : "none",
zIndex: 99999,
});
}, [isOpen]);
useEffect(() => {
const handleScrollOrResize = () => setIsOpen(false);
if (isOpen) {
window.addEventListener("resize", handleScrollOrResize);
window.addEventListener("scroll", handleScrollOrResize, true);
}
return () => {
window.removeEventListener("resize", handleScrollOrResize);
window.removeEventListener("scroll", handleScrollOrResize, true);
};
}, [isOpen]);
const toggleValue = (value: string) => {
if (values.includes(value)) {
onChange(values.filter((item) => item !== value));
return;
}
onChange([...values, value]);
};
return (
<div className={`relative ${className}`}>
<button
ref={buttonRef}
type="button"
disabled={disabled}
onClick={() => !disabled && setIsOpen((current) => !current)}
className={`flex w-full items-center justify-between rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-700 outline-none transition focus:ring-2 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300 ${buttonClassName}`}
>
<span className="truncate text-start">{displayValue}</span>
<ChevronDown className={`h-4 w-4 shrink-0 transition-transform ${isOpen ? "rotate-180" : ""}`} />
</button>
{isOpen &&
createPortal(
<div
ref={dropdownRef}
style={dropdownStyle}
className="overflow-hidden rounded-md border border-slate-200 bg-white shadow-lg dark:border-slate-700 dark:bg-slate-800"
>
<div className="border-b border-slate-100 p-2 dark:border-slate-700">
<div className="relative">
<Search className="pointer-events-none absolute inset-y-0 left-3 my-auto h-4 w-4 text-slate-400 rtl:left-auto rtl:right-3" />
<Input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={searchPlaceholder || "Search..."}
className="h-9 pl-9 rtl:pl-3 rtl:pr-9"
autoFocus
/>
</div>
</div>
<div className="max-h-64 overflow-y-auto py-1">
{filteredOptions.map((option) => {
const isSelected = values.includes(option.value);
return (
<button
key={option.value}
type="button"
onClick={() => toggleValue(option.value)}
className={`flex w-full items-center justify-between px-3 py-2 text-left text-sm transition hover:bg-slate-100 dark:hover:bg-slate-700 ${
isSelected
? "bg-blue-50 font-medium text-blue-600 dark:bg-blue-900/30 dark:text-blue-300"
: "text-slate-700 dark:text-slate-300"
}`}
>
<span className="truncate">{option.label}</span>
{isSelected ? <Check className="h-4 w-4 shrink-0" /> : <span className="h-4 w-4 shrink-0" />}
</button>
);
})}
{filteredOptions.length === 0 && (
<div className="px-3 py-3 text-sm text-slate-500 dark:text-slate-400">
{emptyLabel || "No results"}
</div>
)}
</div>
</div>,
document.body,
)}
</div>
);
}

View File

@@ -491,7 +491,7 @@ export const fa = {
title: "پروژه‌ها",
description: (workspaceName: string) => `مدیریت پروژه‌ها برای ${workspaceName}`,
active: "پروژه‌های فعال",
archived: "پروژه‌های بایگانی شده",
archived: "بایگانی شده",
createNew: "ایجاد پروژه جدید",
searchPlaceholder: "جستجوی پروژه‌ها...",
selectWorkspace: "لطفاً ابتدا یک ورک‌اسپیس انتخاب کنید.",

View File

@@ -9,7 +9,7 @@ import { ProjectCreateModal } from "../components/projects/ProjectCreateModal";
import { ProjectEditModal } from "../components/projects/ProjectEditModal";
import { ProjectAccessModal } from "../components/projects/ProjectAccessModal";
import { Pagination } from "../components/Pagination";
import { Plus, Archive, Building2, Pencil, ShieldCheck, Trash2, X } from "lucide-react";
import { Plus, Building2, Pencil, ShieldCheck, Trash2, X } from "lucide-react";
import EmptyStateCard from "../components/EmptyStateCard";
import FilterBar from "../components/FilterBar";
@@ -19,6 +19,7 @@ import { Card, CardContent, CardTitle } from "../components/ui/card";
import { Modal } from "../components/Modal";
import { toast } from "sonner";
import { Input } from "../components/ui/input";
import { MultiSearchableSelect } from "../components/ui/MultiSearchableSelect";
import {
PROJECTS_ARCHIVE,
PROJECTS_CREATE,
@@ -188,13 +189,16 @@ export const Projects: React.FC = () => {
return [...selected, ...unselected];
}, [clients, selectedClientIdsKey]);
const toggleClientFilter = (clientId: string) => {
const nextClientIds = selectedClientIds.includes(clientId)
? selectedClientIds.filter((id) => id !== clientId)
: [...selectedClientIds, clientId];
const clientOptions = useMemo(
() =>
sortedClients.map((client) => ({
value: client.id,
label: client.name,
})),
[sortedClients],
);
updateListParams({ clients: nextClientIds, page: 1 });
};
const hasActiveProjectFilters = selectedClientIds.length > 0 || isArchived;
const updateListParams = (
updates: Record<string, string | number | boolean | null | undefined | string[]>,
@@ -243,16 +247,6 @@ export const Projects: React.FC = () => {
{t.projects?.manageAccess || "Manage access"}
</Button>
)}
{canArchiveProject && (
<Button
variant={isArchived ? "default" : "secondary"}
onClick={() => updateListParams({ archived: !isArchived, page: 1 })}
className="flex-1 gap-2 shadow-sm sm:flex-none"
>
<Archive className="h-4 w-4" />
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
</Button>
)}
{canCreateProject && (
<Button
onClick={() => setIsCreateModalOpen(true)}
@@ -277,63 +271,81 @@ export const Projects: React.FC = () => {
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
/>
<div className="mt-4 flex items-center justify-between gap-3">
<div className="mt-4 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] lg:flex-1">
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
{t.projects?.filterClients || "Filter by client"}
</div>
{selectedClientIds.length > 0 ? (
<MultiSearchableSelect
values={selectedClientIds}
onChange={(values) => updateListParams({ clients: values, page: 1 })}
options={clientOptions}
placeholder={t.reports?.allClients || "All clients"}
searchPlaceholder={t.reports?.searchClients || "Search clients..."}
emptyLabel={t.clients?.noClients || "No clients found"}
renderValue={(selectedOptions) => {
if (selectedOptions.length === 0) {
return t.reports?.allClients || "All clients";
}
if (selectedOptions.length <= 2) {
return selectedOptions.map((option) => option.label).join(", ");
}
return `${selectedOptions[0]?.label} +${selectedOptions.length - 1}`;
}}
buttonClassName="min-h-11 w-full rounded-xl border-slate-200 bg-slate-50/80 dark:border-slate-700"
/>
</div>
{canArchiveProject ? (
<div className="space-y-2">
<div className="text-xs font-semibold uppercase tracking-[0.14em] text-slate-400 dark:text-slate-500">
{t.projects?.archived || "Archived Projects"}
</div>
<button
type="button"
onClick={() => {
updateListParams({ clients: [], page: 1 });
}}
className="text-xs font-medium text-slate-500 transition hover:text-slate-900 dark:text-slate-400 dark:hover:text-white"
role="switch"
aria-checked={isArchived}
aria-label={t.projects?.archived || "Archived Projects"}
onClick={() => updateListParams({ archived: !isArchived, page: 1 })}
className={`inline-flex min-h-11 w-full items-center justify-between gap-3 rounded-xl border px-3 py-2 text-sm font-medium transition md:w-auto ${
isArchived
? "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300"
: "border-slate-200 bg-slate-50/80 text-slate-600 hover:border-slate-300 hover:text-slate-900 dark:border-slate-700 dark:bg-slate-950/60 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:text-white"
}`}
>
{t.projects?.clearClientFilters || "Clear filters"}
<span>{t.projects?.archived || "Archived Projects"}</span>
<span
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition ${
isArchived ? "bg-amber-500 dark:bg-amber-400" : "bg-slate-300 dark:bg-slate-700"
}`}
>
<span
className={`inline-block h-5 w-5 rounded-full bg-white shadow-sm transition-transform ${
isArchived ? "translate-x-5 rtl:-translate-x-5" : "translate-x-0.5 rtl:-translate-x-0.5"
}`}
/>
</span>
</button>
</div>
) : null}
</div>
<div className="mt-3 overflow-x-auto pb-2">
<div className="flex min-w-max items-center gap-2">
{sortedClients.map((client) => {
const isSelected = selectedClientIds.includes(client.id);
return (
<button
key={client.id}
type="button"
onClick={() => toggleClientFilter(client.id)}
className={`inline-flex items-center gap-2 rounded-full border px-3 py-2 text-sm font-medium transition ${
isSelected
? "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-500/30 dark:bg-sky-500/10 dark:text-sky-300"
: "border-slate-200 bg-slate-50 text-slate-600 hover:border-slate-300 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-950/60 dark:text-slate-300 dark:hover:border-slate-600 dark:hover:bg-slate-800"
}`}
>
<span className="whitespace-nowrap">{client.name}</span>
{isSelected ? (
<span
role="button"
tabIndex={0}
onClick={(event) => {
event.stopPropagation();
toggleClientFilter(client.id);
onClick={() => {
updateListParams({ clients: [], archived: false, page: 1 });
}}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
toggleClientFilter(client.id);
}
}}
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-sky-100 text-sky-700 dark:bg-sky-500/20 dark:text-sky-200"
disabled={!hasActiveProjectFilters}
aria-label={t.projects?.clearClientFilters || "Clear filters"}
className={`inline-flex h-10 w-10 items-center justify-center self-start rounded-xl border text-sm transition lg:self-end ${
hasActiveProjectFilters
? "border-red-200 bg-red-50 text-red-700 hover:border-red-300 hover:bg-red-100 hover:text-red-800 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300 dark:hover:border-red-400 dark:hover:bg-red-500/20 dark:hover:text-red-200"
: "border-slate-200 bg-white text-slate-400 opacity-60 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-500"
} disabled:cursor-not-allowed`}
>
<X className="h-3 w-3" />
</span>
) : null}
<X className="h-4 w-4" />
</button>
);
})}
</div>
</div>
</div>