Skip to content

Commit 4444ebc

Browse files
committed
feat: Allow to search in project
- Allow to search in project - Added clsxm utility function - Fix fragment keys - Add result limit to extended search
1 parent de32b11 commit 4444ebc

File tree

8 files changed

+105
-50
lines changed

8 files changed

+105
-50
lines changed

src/components/general/CheckBox.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ interface PropTypes extends Omit<React.ComponentProps<"input">, "id" | "type"> {
66
description?: string;
77
}
88

9-
const CheckBox = ({ title, description, ...props }: PropTypes) => {
9+
const CheckBox = ({ title, description, className, ...props }: PropTypes) => {
1010
const id = useId();
1111

1212
return (
@@ -19,7 +19,7 @@ const CheckBox = ({ title, description, ...props }: PropTypes) => {
1919
className={clsx(
2020
"w-4 h-4 accent-primary-600 bg-gray-100 border-gray-300 rounded dark:ring-offset-gray-800 dark:bg-gray-700 dark:border-gray-600",
2121
"focus:ring-2 focus:ring-primary-300 focus:outline-none dark:focus:ring-primary-800",
22-
props.className
22+
className
2323
)}
2424
/>
2525
</div>

src/components/general/Switch.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import clsx from "clsx";
2+
import { clsxm } from "../../utils/clsxm";
23

34
interface PropTypes {
45
size?: "md" | "lg";
@@ -8,14 +9,16 @@ interface PropTypes {
89
options: {
910
value: string;
1011
name: string;
12+
disabled?: boolean;
1113
}[];
1214
className?: string;
15+
tabIndex?: number;
1316
}
1417

15-
const Switch = ({ size = "md", name, value, onChange, options, className }: PropTypes) => {
18+
const Switch = ({ size = "md", name, value, onChange, options, className, tabIndex }: PropTypes) => {
1619
return (
1720
<div
18-
className={clsx(
21+
className={clsxm(
1922
"rounded-full flex items-center gap-x-1 w-fit bg-gray-50 text-gray-900 dark:bg-gray-700 dark:text-white",
2023
{
2124
"p-1": size === "md",
@@ -29,11 +32,13 @@ const Switch = ({ size = "md", name, value, onChange, options, className }: Prop
2932
<button
3033
type="button"
3134
className={clsx("w-full rounded-full", "focus:ring-2 focus:ring-primary-300 focus:outline-none dark:focus:ring-primary-800", {
32-
"bg-primary-600": value === option.value,
35+
"text-white bg-primary-700 dark:bg-primary-600": value === option.value,
3336
"p-0.5 px-2": size === "md",
3437
"p-1 px-3": size === "lg",
3538
})}
39+
disabled={option.disabled}
3640
onClick={() => onChange?.({ target: { name: name, value: option.value } })}
41+
tabIndex={tabIndex}
3742
>
3843
{option.name}
3944
</button>

src/components/issues/IssuesList.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
2+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3+
import { Fragment } from "react";
14
import useSettings from "../../hooks/useSettings";
25
import useStorage from "../../hooks/useStorage";
36
import { TAccount, TIssue, TReference } from "../../types/redmine";
@@ -21,9 +24,10 @@ type PropTypes = {
2124
account?: TAccount;
2225
issues: TIssue[];
2326
issuesData: ReturnType<typeof useStorage<IssuesData>>;
27+
onSearchInProject: (project: TReference) => void;
2428
};
2529

26-
const IssuesList = ({ account, issues: rawIssues, issuesData: { data: issuesData, setData: setIssuesData } }: PropTypes) => {
30+
const IssuesList = ({ account, issues: rawIssues, issuesData: { data: issuesData, setData: setIssuesData }, onSearchInProject }: PropTypes) => {
2731
const { settings } = useSettings();
2832

2933
const sortedIssues = rawIssues.sort((a, b) => {
@@ -62,10 +66,15 @@ const IssuesList = ({ account, issues: rawIssues, issuesData: { data: issuesData
6266
return (
6367
<>
6468
{groupedIssues.map(({ project, issues: groupIssues }) => (
65-
<>
66-
<a href={`${settings.redmineURL}/projects/${project.id}`} target="_blank" tabIndex={-1} className="text-xs text-slate-500 dark:text-slate-300 hover:underline truncate max-w-fit">
67-
{project.name}
68-
</a>
69+
<Fragment key={project.id}>
70+
<div className="flex justify-between gap-x-2">
71+
<a href={`${settings.redmineURL}/projects/${project.id}`} target="_blank" tabIndex={-1} className="text-xs text-slate-500 dark:text-slate-300 hover:underline truncate max-w-fit">
72+
{project.name}
73+
</a>
74+
<button type="button" className=" text-gray-900 dark:text-white" onClick={() => onSearchInProject(project)} tabIndex={-1}>
75+
<FontAwesomeIcon icon={faMagnifyingGlass} />
76+
</button>
77+
</div>
6978
{groupIssues.map((issue) => {
7079
const data: IssueData = issuesData?.[issue.id] ?? {
7180
active: false,
@@ -198,7 +207,7 @@ const IssuesList = ({ account, issues: rawIssues, issuesData: { data: issuesData
198207
/>
199208
);
200209
})}
201-
</>
210+
</Fragment>
202211
))}
203212
{rawIssues.length === 0 && <p className="text-center">No issues</p>}
204213
</>

src/components/issues/Search.tsx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
import { faSearch } from "@fortawesome/free-solid-svg-icons";
1+
import { faChevronRight, faSearch } from "@fortawesome/free-solid-svg-icons";
22
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3-
import { useEffect, useRef, useState } from "react";
3+
import { ForwardedRef, forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
44
import useHotKey from "../../hooks/useHotkey";
5+
import { TReference } from "../../types/redmine";
56
import InputField from "../general/InputField";
67
import Switch from "../general/Switch";
78

89
export type SearchQuery = {
910
searching: boolean;
1011
mode: "issue" | "project";
1112
query: string;
13+
inProject?: TReference;
1214
};
1315

1416
export const defaultSearchQuery: SearchQuery = { searching: false, mode: "issue", query: "" };
@@ -17,19 +19,35 @@ type PropTypes = {
1719
onSearch: (search: SearchQuery) => void;
1820
};
1921

20-
const Search = ({ onSearch }: PropTypes) => {
22+
export type SearchRef = {
23+
searchInProject: (project: TReference) => void;
24+
};
25+
26+
const Search = forwardRef(({ onSearch }: PropTypes, ref: ForwardedRef<SearchRef>) => {
2127
const searchRef = useRef<HTMLInputElement>(null);
2228
const [searching, setSearching] = useState(false);
2329
const [mode, setMode] = useState<SearchQuery["mode"]>(defaultSearchQuery.mode);
2430
const [query, setQuery] = useState(defaultSearchQuery.query);
31+
const [inProject, setInProject] = useState<TReference | undefined>(undefined);
32+
33+
useImperativeHandle(ref, () => ({
34+
searchInProject(project: TReference) {
35+
setMode("issue");
36+
setInProject(project);
37+
setSearching(true);
38+
searchRef.current?.focus();
39+
searchRef.current?.select();
40+
},
41+
}));
2542

2643
useEffect(() => {
2744
onSearch({
2845
searching,
2946
mode,
3047
query,
48+
inProject,
3149
});
32-
}, [searching, mode, query]);
50+
}, [searching, mode, query, inProject]);
3351

3452
// hotkeys
3553
useHotKey(
@@ -53,6 +71,7 @@ const Search = ({ onSearch }: PropTypes) => {
5371
setSearching(false);
5472
setQuery("");
5573
setMode(defaultSearchQuery.mode);
74+
setInProject(undefined);
5675
},
5776
{ key: "Escape" },
5877
searching
@@ -61,22 +80,32 @@ const Search = ({ onSearch }: PropTypes) => {
6180
return (
6281
<>
6382
{searching && (
64-
<div className="relative">
65-
<InputField ref={searchRef} icon={<FontAwesomeIcon icon={faSearch} />} name="query" placeholder="Search..." className="select-none mb-3 pr-[7.25rem]" value={query} onChange={(e) => setQuery(e.target.value)} autoFocus autoComplete="off" />
83+
<div className="relative mb-3">
84+
<InputField ref={searchRef} icon={<FontAwesomeIcon icon={faSearch} />} name="query" placeholder="Search..." className="select-none pr-[7.25rem]" value={query} onChange={(e) => setQuery(e.target.value)} autoFocus autoComplete="off" />
6685
<Switch
6786
name="mode"
6887
value={mode}
6988
options={[
7089
{ value: "issue", name: "Issue" },
71-
{ value: "project", name: "Project" },
90+
{ value: "project", name: "Project", disabled: !!inProject },
7291
]}
7392
onChange={(e) => setMode(e.target.value as SearchQuery["mode"])}
7493
className="absolute top-1.5 end-1 bg-gray-300 dark:bg-gray-800"
94+
tabIndex={-1}
7595
/>
96+
{inProject && (
97+
<div className="flex items-center gap-x-1.5 ps-2 mt-1.5 whitespace-nowrap">
98+
<FontAwesomeIcon icon={faChevronRight} />
99+
Search in
100+
<span className="rounded-full bg-primary-300 dark:bg-primary-800 px-1.5 text-xs truncate">{inProject.name}</span>
101+
</div>
102+
)}
76103
</div>
77104
)}
78105
</>
79106
);
80-
};
107+
});
108+
109+
Search.displayName = "Search";
81110

82111
export default Search;

src/components/time/TimeEntry.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Fragment } from "react";
12
import { Tooltip } from "react-tooltip";
23
import { TTimeEntry } from "../../types/redmine";
34

@@ -12,7 +13,7 @@ const TimeEntry = ({ entries, previewHours, maxHours = 24 }: PropTypes) => {
1213
return (
1314
<div className="flex gap-x-0.5 items-center">
1415
{entries.map((entry) => (
15-
<>
16+
<Fragment key={entry.id}>
1617
<Tooltip id={`tooltip-time-entry-${entry.id}`} place="bottom" className="z-10 opacity-100">
1718
<h4 className="text-base">
1819
{entry.issue ? (
@@ -32,7 +33,7 @@ const TimeEntry = ({ entries, previewHours, maxHours = 24 }: PropTypes) => {
3233
}}
3334
data-tooltip-id={`tooltip-time-entry-${entry.id}`}
3435
/>
35-
</>
36+
</Fragment>
3637
))}
3738
{(previewHours && (
3839
<>

src/hooks/useMyIssues.ts

Lines changed: 30 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { SearchQuery } from "../components/issues/Search";
55
import useDebounce from "./useDebounce";
66
import useSettings from "./useSettings";
77

8+
const MAX_EXTENDED_SEARCH_LIMIT = 75;
9+
810
const useMyIssues = (additionalIssuesIds: number[], search: SearchQuery) => {
911
const { settings } = useSettings();
1012

@@ -32,54 +34,58 @@ const useMyIssues = (additionalIssuesIds: number[], search: SearchQuery) => {
3234
let issues = issuesQuery.data?.pages?.flat() ?? [];
3335
issues.push(...(additionalIssuesQuery.data?.pages?.flat().filter((issue) => !issues.find((iss) => iss.id === issue.id)) ?? []));
3436

37+
// filter by project
38+
if (search.inProject) {
39+
issues = issues.filter((issue) => issue.project.id === search.inProject?.id);
40+
}
3541
// filter by search
3642
if (search.searching && search.query) {
3743
issues = issues.filter((issue) => new RegExp(search.query, "i").test(search.mode === "project" ? issue.project.name : `#${issue.id} ${issue.subject}`));
3844
}
3945

46+
// ---
47+
4048
// extended search
4149
const debouncedSearch = useDebounce(search.query, 300);
4250
const extendedSearching = search.searching && debouncedSearch.length >= 3 && settings.options.extendedSearch;
4351

44-
// extended search - issues
45-
const extendedSearchResultIssuesQuery = useQuery({
46-
queryKey: ["extendedSearchResultIssues", debouncedSearch],
52+
// extended search - mode: issue
53+
const extendedSearchIssuesResultQuery = useQuery({
54+
queryKey: ["extendedSearchIssuesResult", debouncedSearch],
4755
queryFn: () => searchOpenIssues(debouncedSearch),
4856
enabled: extendedSearching && search.mode === "issue",
4957
keepPreviousData: true,
5058
});
51-
const extendedSearchResultIssuesIds = (extendedSearchResultIssuesQuery.data?.map((result) => result.id) ?? []).filter((id) => !issues.find((issue) => issue.id === id));
59+
const extendedSearchIssuesResultIds = (extendedSearchIssuesResultQuery.data?.map((result) => result.id) ?? []).filter((id) => !issues.find((issue) => issue.id === id));
5260
const extendedSearchIssuesQuery = useQuery({
53-
queryKey: ["extendedSearchIssues", extendedSearchResultIssuesIds],
54-
queryFn: () => getOpenIssuesByIds(extendedSearchResultIssuesIds),
55-
enabled: extendedSearchResultIssuesIds.length > 0,
56-
keepPreviousData: extendedSearchResultIssuesIds.length > 0,
61+
queryKey: ["extendedSearchIssues", extendedSearchIssuesResultIds],
62+
queryFn: () => getOpenIssuesByIds(extendedSearchIssuesResultIds, 0, search.inProject ? 100 : MAX_EXTENDED_SEARCH_LIMIT),
63+
enabled: extendedSearchIssuesResultIds.length > 0,
64+
keepPreviousData: extendedSearchIssuesResultIds.length > 0,
5765
});
66+
const extendedSearchIssuesList = (search.inProject ? extendedSearchIssuesQuery.data?.filter((issue) => issue.project.id === search.inProject?.id) : extendedSearchIssuesQuery.data)?.slice(0, MAX_EXTENDED_SEARCH_LIMIT) ?? [];
5867

59-
// extended search - projects
60-
const extendedSearchResultProjectsQuery = useQuery({
61-
queryKey: ["extendedSearchResultProjects", debouncedSearch],
68+
// extended search - mode: project
69+
const extendedSearchProjectsResultQuery = useQuery({
70+
queryKey: ["extendedSearchProjectsResult", debouncedSearch],
6271
queryFn: () => searchProjects(debouncedSearch),
6372
enabled: extendedSearching && search.mode === "project",
6473
keepPreviousData: true,
6574
});
66-
const extendedSearchResultProjectsIds = extendedSearchResultProjectsQuery.data?.map((result) => result.id) ?? [];
67-
const extendedSearchIssuesProjectsQueries = useQueries({
68-
queries: extendedSearchResultProjectsIds.map((projectId) => ({
69-
queryKey: ["extendedSearchIssuesByProject", projectId],
70-
queryFn: () => getOpenIssuesByProject(projectId),
75+
const extendedSearchProjectsResultIds = extendedSearchProjectsResultQuery.data?.map((result) => result.id) ?? [];
76+
const extendedSearchIssuesByProjectsQueries = useQueries({
77+
queries: extendedSearchProjectsResultIds.map((projectId) => ({
78+
queryKey: ["extendedSearchIssuesByProjects", projectId],
79+
queryFn: () => getOpenIssuesByProject(projectId, 0, Math.ceil(MAX_EXTENDED_SEARCH_LIMIT / extendedSearchProjectsResultIds.length)),
7180
keepPreviousData: true,
7281
})),
7382
});
83+
const extendedSearchIssuesByProjectsList = extendedSearchIssuesByProjectsQueries
84+
.map((q) => q.data ?? [])
85+
.flat()
86+
.filter(({ id }) => !issues.find((issue) => issue.id === id));
7487

75-
const extendedSearchIssues = extendedSearching
76-
? search.mode === "issue"
77-
? extendedSearchIssuesQuery.data ?? []
78-
: extendedSearchIssuesProjectsQueries
79-
.map((q) => q.data ?? [])
80-
.flat()
81-
.filter(({ id }) => !issues.find((issue) => issue.id === id))
82-
: [];
88+
const extendedSearchIssues = extendedSearching ? (search.mode === "issue" ? extendedSearchIssuesList : extendedSearchIssuesByProjectsList) : [];
8389

8490
return {
8591
data: issues,

src/pages/IssuesPage.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { useEffect, useState } from "react";
1+
import { useEffect, useRef, useState } from "react";
22
import Toast from "../components/general/Toast";
33
import IssuesList, { IssuesData } from "../components/issues/IssuesList";
44
import IssuesListSkeleton from "../components/issues/IssuesListSkeleton";
5-
import Search, { SearchQuery, defaultSearchQuery } from "../components/issues/Search";
5+
import Search, { SearchQuery, SearchRef, defaultSearchQuery } from "../components/issues/Search";
66
import useMyAccount from "../hooks/useMyAccount";
77
import useMyIssues from "../hooks/useMyIssues";
88
import useSettings from "../hooks/useSettings";
@@ -13,6 +13,7 @@ const IssuesPage = () => {
1313

1414
const issuesData = useStorage<IssuesData>("issues", {});
1515

16+
const searchRef = useRef<SearchRef>(null);
1617
const [search, setSearch] = useState<SearchQuery>(defaultSearchQuery);
1718

1819
const myIssuesQuery = useMyIssues(
@@ -34,11 +35,11 @@ const IssuesPage = () => {
3435

3536
return (
3637
<>
37-
<Search onSearch={setSearch} />
38+
<Search ref={searchRef} onSearch={setSearch} />
3839
<div className="flex flex-col gap-y-2">
3940
{myIssuesQuery.isLoading && <IssuesListSkeleton />}
4041

41-
<IssuesList account={myAccount.data} issues={myIssuesQuery.data} issuesData={issuesData} />
42+
<IssuesList account={myAccount.data} issues={myIssuesQuery.data} issuesData={issuesData} onSearchInProject={(project) => searchRef.current?.searchInProject(project)} />
4243

4344
{search.searching && settings.options.extendedSearch && (
4445
<>
@@ -51,7 +52,7 @@ const IssuesPage = () => {
5152
</div>
5253
</div>
5354

54-
<IssuesList account={myAccount.data} issues={myIssuesQuery.extendedSearch} issuesData={issuesData} />
55+
<IssuesList account={myAccount.data} issues={myIssuesQuery.extendedSearch} issuesData={issuesData} onSearchInProject={(project) => searchRef.current?.searchInProject(project)} />
5556
</>
5657
)}
5758

src/utils/clsxm.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import clsx, { ClassValue } from "clsx";
2+
import { twMerge } from "tailwind-merge";
3+
4+
export const clsxm = (...classes: ClassValue[]) => twMerge(clsx(...classes));

0 commit comments

Comments
 (0)