feat(timesheet): add searchable tag selectors
This commit is contained in:
@@ -56,6 +56,7 @@ function FilterTagMultiSelect({
|
|||||||
title: string;
|
title: string;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({});
|
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({});
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
@@ -105,7 +106,17 @@ function FilterTagMultiSelect({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setSearchQuery("");
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id));
|
const selectedTags = tags.filter((tag) => selectedTagIds.includes(tag.id));
|
||||||
|
const normalizedSearch = searchQuery.trim().toLowerCase();
|
||||||
|
const filteredTags = normalizedSearch
|
||||||
|
? tags.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch))
|
||||||
|
: tags;
|
||||||
const label = selectedTags.length > 0 ? selectedTags.map((tag) => tag.name).join(" | ") : title;
|
const label = selectedTags.length > 0 ? selectedTags.map((tag) => tag.name).join(" | ") : title;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -127,8 +138,20 @@ function FilterTagMultiSelect({
|
|||||||
style={dropdownStyle}
|
style={dropdownStyle}
|
||||||
className="rounded-xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-900"
|
className="rounded-xl border border-slate-200 bg-white p-2 shadow-xl dark:border-slate-700 dark:bg-slate-900"
|
||||||
>
|
>
|
||||||
<div className="max-h-64 space-y-1 overflow-y-auto">
|
<div className="border-b border-slate-200 p-2 dark:border-slate-700">
|
||||||
{tags.map((tag) => {
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="Search tags..."
|
||||||
|
className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-800 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 space-y-1 overflow-y-auto p-2">
|
||||||
|
{filteredTags.map((tag) => {
|
||||||
const selected = selectedTagIds.includes(tag.id);
|
const selected = selectedTagIds.includes(tag.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -156,6 +179,11 @@ function FilterTagMultiSelect({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{filteredTags.length === 0 && (
|
||||||
|
<div className="px-2 py-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
No tags found.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -533,6 +533,7 @@ function TagMultiSelect({
|
|||||||
portalOwnerId?: string;
|
portalOwnerId?: string;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -587,8 +588,18 @@ function TagMultiSelect({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setSearchQuery("");
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name);
|
const selectedLabels = tags.filter((tag) => selectedTags.includes(tag.id)).map((tag) => tag.name);
|
||||||
const joinedSelectedLabels = selectedLabels.join(" | ");
|
const joinedSelectedLabels = selectedLabels.join(" | ");
|
||||||
|
const normalizedSearch = searchQuery.trim().toLowerCase();
|
||||||
|
const filteredTags = normalizedSearch
|
||||||
|
? tags.filter((tag) => tag.name.toLowerCase().includes(normalizedSearch))
|
||||||
|
: tags;
|
||||||
const buttonLabel = compact
|
const buttonLabel = compact
|
||||||
? selectedTags.length > 0
|
? selectedTags.length > 0
|
||||||
? joinedSelectedLabels
|
? joinedSelectedLabels
|
||||||
@@ -638,8 +649,21 @@ function TagMultiSelect({
|
|||||||
{tags.length === 0 ? (
|
{tags.length === 0 ? (
|
||||||
<p className="px-2 py-2 text-sm text-slate-500 dark:text-slate-400">{emptyHint}</p>
|
<p className="px-2 py-2 text-sm text-slate-500 dark:text-slate-400">{emptyHint}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="max-h-56 space-y-1 overflow-y-auto">
|
<>
|
||||||
{tags.map((tag) => {
|
<div className="border-b border-slate-200 p-2 dark:border-slate-700">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(event) => setSearchQuery(event.target.value)}
|
||||||
|
placeholder="Search tags..."
|
||||||
|
className="h-8 w-full rounded-md border border-slate-200 bg-slate-50 pl-8 pr-2 text-xs text-slate-900 outline-none transition focus:border-sky-400 focus:bg-white focus:ring-2 focus:ring-sky-500/20 dark:border-slate-700 dark:bg-slate-900 dark:text-white dark:focus:border-sky-500 dark:focus:bg-slate-800"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-72 space-y-1 overflow-y-auto p-2">
|
||||||
|
{filteredTags.map((tag) => {
|
||||||
const selected = selectedTags.includes(tag.id);
|
const selected = selectedTags.includes(tag.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -2306,8 +2330,8 @@ export default function Timesheet() {
|
|||||||
client: t.projects?.clientLabel || "Client",
|
client: t.projects?.clientLabel || "Client",
|
||||||
tags: t.tags?.title || "Tags",
|
tags: t.tags?.title || "Tags",
|
||||||
clear: extendedTimesheet.clearFilters || "Clear filters",
|
clear: extendedTimesheet.clearFilters || "Clear filters",
|
||||||
customFrom: extendedTimesheet.customFromLabel || "From",
|
customFrom: extendedTimesheet.customFromLabel || "From date",
|
||||||
customTo: extendedTimesheet.customToLabel || "To",
|
customTo: extendedTimesheet.customToLabel || "To date",
|
||||||
allClients: extendedTimesheet.allClientsLabel || "All clients",
|
allClients: extendedTimesheet.allClientsLabel || "All clients",
|
||||||
allProjects: extendedTimesheet.allProjectsLabel || "All projects",
|
allProjects: extendedTimesheet.allProjectsLabel || "All projects",
|
||||||
allTags: extendedTimesheet.allTagsLabel || "All tags",
|
allTags: extendedTimesheet.allTagsLabel || "All tags",
|
||||||
|
|||||||
Reference in New Issue
Block a user