Skip to content

Commit bb9ab89

Browse files
feat: add search command to toolbar (#93)
1 parent dad87c7 commit bb9ab89

File tree

6 files changed

+200
-52
lines changed

6 files changed

+200
-52
lines changed

src/components/GlobalSearch.tsx

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import React, {useCallback, useContext, useEffect, useState} from 'react'
2+
import {useNavigate} from 'react-router-dom'
3+
import {CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from '@/components/ui/command'
4+
import {Search, TreePalm} from 'lucide-react'
5+
import {Button} from './ui/button'
6+
import {getUsers} from '@/core/services/userService'
7+
import {getLeavesPolicies} from '@/core/services/leaveService'
8+
import {UserResponse} from '@/core/types/user'
9+
import {LeavePolicyResponse} from '@/core/types/leave'
10+
import UserAvatar from "@/modules/user/components/UserAvatar.tsx";
11+
import {UserContext} from "@/contexts/UserContext.tsx";
12+
import {getAccessibleNavigationItems} from "@/core/utils/navigation.ts";
13+
import {UserRole} from "@/core/types/enum.ts";
14+
15+
export function GlobalSearch() {
16+
const [open, setOpen] = useState(false)
17+
const [loading, setLoading] = useState(false)
18+
const [users, setUsers] = useState<UserResponse[]>([])
19+
const [policies, setPolicies] = useState<LeavePolicyResponse[]>([])
20+
const [searchTerm, setSearchTerm] = useState('')
21+
const navigate = useNavigate();
22+
const {user} = useContext(UserContext);
23+
const accessibleItems = user ? getAccessibleNavigationItems(user.role) : [];
24+
25+
const fetchData = useCallback(async () => {
26+
try {
27+
setLoading(true)
28+
const [usersData, policiesData] = await Promise.all([
29+
getUsers(0, 100),
30+
getLeavesPolicies()
31+
])
32+
setUsers(usersData.contents)
33+
setPolicies(policiesData)
34+
} catch (error) {
35+
console.error('Error fetching data:', error)
36+
} finally {
37+
setLoading(false)
38+
}
39+
}, [])
40+
41+
useEffect(() => {
42+
if (open) {
43+
fetchData()
44+
}
45+
}, [open, fetchData])
46+
47+
const onSelect = (path: string) => {
48+
setOpen(false)
49+
navigate(path)
50+
}
51+
52+
const filteredUsers = users.filter(user => {
53+
if (!searchTerm) return true;
54+
const searchLower = searchTerm.toLowerCase();
55+
return (
56+
`${user.firstName} ${user.lastName}`.toLowerCase().includes(searchLower) ||
57+
user.email.toLowerCase().includes(searchLower)
58+
);
59+
});
60+
61+
const filteredPolicies = policies.filter(policy => {
62+
if (!searchTerm) return true;
63+
const searchLower = searchTerm.toLowerCase();
64+
return policy.name.toLowerCase().includes(searchLower);
65+
});
66+
67+
const visibleUsers = searchTerm ? filteredUsers : filteredUsers.slice(0, 10);
68+
const visiblePolicies = searchTerm ? filteredPolicies : filteredPolicies.slice(0, 10);
69+
const visiblePages = searchTerm ? accessibleItems : accessibleItems.slice(0, 5);
70+
71+
return (
72+
<>
73+
<Button variant="outline" className='px-2 h-9' onClick={() => setOpen(true)}>
74+
<Search className="h-4 w-4 mr-1"/>
75+
Search
76+
</Button>
77+
<CommandDialog open={open} onOpenChange={setOpen}>
78+
<CommandInput placeholder="Type to search..." value={searchTerm} onValueChange={setSearchTerm}/>
79+
<CommandList className="max-h-[80vh] overflow-auto">
80+
<CommandEmpty>No results found.</CommandEmpty>
81+
{loading ? (
82+
<div className="space-y-1 overflow-hidden px-1 py-2">
83+
<div className="animate-pulse space-y-2">
84+
{[...Array(5)].map((_, i) => (
85+
<div key={i} className="h-4 w-full rounded bg-muted"/>
86+
))}
87+
</div>
88+
</div>
89+
) : (
90+
<>
91+
<CommandGroup heading="Pages" className="p-2">
92+
{visiblePages.map((page) => (
93+
<CommandItem
94+
key={page.path}
95+
onSelect={() => onSelect(page.path)}
96+
className="flex items-center gap-2 p-2 hove:bg-indigo-50 rounded-lg"
97+
>
98+
<page.icon className='h-4 w-4 text-gray-500'/>
99+
<div className="flex flex-col">
100+
<span className="font-medium">{page.title}</span>
101+
<span className="text-gray-500 text-sm">{page.description}</span>
102+
</div>
103+
</CommandItem>
104+
))}
105+
</CommandGroup>
106+
107+
{(user.role === UserRole.ORGANIZATION_ADMIN || user.role === UserRole.TEAM_ADMIN) && (
108+
<>
109+
<CommandGroup heading="Users" className="p-2">
110+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[300px] overflow-auto">
111+
{visibleUsers.map((user) => (
112+
<CommandItem
113+
key={user.id}
114+
onSelect={() => onSelect(`/users/${user.id}`)}
115+
className="p-3 cursor-pointer hover:bg-indigo-50 rounded-lg"
116+
>
117+
<div className="flex items-center gap-2">
118+
<UserAvatar size={32} user={user}/>
119+
<div className="flex flex-col">
120+
<span className="font-medium">{user.firstName} {user.lastName}</span>
121+
<span className="text-xs text-muted-foreground">{user.email}</span>
122+
</div>
123+
</div>
124+
</CommandItem>
125+
))}
126+
</div>
127+
</CommandGroup>
128+
129+
<CommandGroup heading="Leave Policies" className="p-2">
130+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 max-h-[300px] overflow-auto">
131+
{visiblePolicies.map((policy) => (
132+
<CommandItem
133+
key={policy.id}
134+
onSelect={() => onSelect(`/leaves/policies/${policy.id}`)}
135+
className="flex items-center gap-2 p-2 hover:bg-indigo-50 rounded-lg"
136+
>
137+
<TreePalm className='h-4 w-4 text-gray-500' />
138+
<span className="font-medium">{policy.name}</span>
139+
</CommandItem>
140+
))}
141+
</div>
142+
</CommandGroup>
143+
</>
144+
)}
145+
</>
146+
)}
147+
</CommandList>
148+
</CommandDialog>
149+
</>
150+
)
151+
}

src/components/layout/PageHeader.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React, {ReactNode} from "react";
22
import {Button} from "@/components/ui/button";
33
import {ChevronLeft} from "lucide-react";
44
import {useNavigate} from "react-router-dom";
5+
import {GlobalSearch} from "@/components/GlobalSearch.tsx";
56

67
type PageHeaderProps = {
78
title: string;
@@ -35,11 +36,10 @@ export default function PageHeader({title, children, backButton}: PageHeaderProp
3536
</h1>
3637
</div>
3738

38-
{children && (
39-
<div className="flex items-center gap-3">
40-
{children}
41-
</div>
42-
)}
39+
<div className="flex items-center gap-3">
40+
<GlobalSearch />
41+
{children}
42+
</div>
4343
</div>
4444
</div>
4545
</header>

src/components/sidebar/Sidebar.tsx

Lines changed: 30 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,17 @@
1-
import React, {useContext, useState} from 'react';
1+
import React, {useContext} from 'react';
22
import {Link, useLocation} from 'react-router-dom';
33
import {UserContext} from "@/contexts/UserContext.tsx";
44
import SidebarAccountDropdown from "@/components/sidebar/SidebarAccountDropdown.tsx";
55
import Logo from "@/components/icon/Logo.tsx";
66
import {NotificationBell} from "@/modules/notification/components/NotificationBell.tsx";
7-
import {UserRole} from "@/core/types/enum.ts";
8-
import {NavigationItem, navigationItems} from "@/core/types/NavigationItem.ts";
9-
10-
const getAccessibleNavigationItems = (role: UserRole): NavigationItem[] => {
11-
return navigationItems.filter(item => item.accessLevel.includes(role))
12-
}
7+
import {NavigationItem} from "@/core/types/navigationItem.ts";
8+
import {getAccessibleNavigationItems} from "@/core/utils/navigation.ts";
139

1410
export default function Sidebar() {
15-
const [selectedOption, setSelectedOption] = useState<string>('');
1611
const location = useLocation();
1712
const {user} = useContext(UserContext);
1813
const accessibleItems = user ? getAccessibleNavigationItems(user.role) : [];
1914

20-
const handleNavigation = (path: string) => {
21-
setSelectedOption(path);
22-
};
23-
24-
const isNavigationActive = (path: string) => {
25-
return location.pathname === path || selectedOption === path;
26-
};
27-
2815
return (
2916
<div className="left-0 hidden md:block md:w-[240px] lg:w-[280px]">
3017
<div className="bg-white shadow-sm h-full">
@@ -37,9 +24,9 @@ export default function Sidebar() {
3724
<NotificationBell/>
3825
</div>
3926

40-
<NavigationSection items={accessibleItems} isNavigationActive={isNavigationActive} onClick={handleNavigation}/>
27+
<NavigationSection items={accessibleItems} currentPath={location.pathname}/>
4128

42-
<SidebarAccountDropdown isActive={isNavigationActive('/profile')} onClick={handleNavigation}/>
29+
<SidebarAccountDropdown isActive={location.pathname === '/profile'} />
4330
</div>
4431
</div>
4532
</div>
@@ -49,26 +36,35 @@ export default function Sidebar() {
4936
type NavigationSectionProps = {
5037
title?: string;
5138
items: NavigationItem[];
52-
isNavigationActive: (path: string) => boolean;
53-
onClick: (page: string) => void;
54-
}
39+
currentPath: string;
40+
};
5541

56-
function NavigationSection({title, items, isNavigationActive, onClick}: NavigationSectionProps) {
42+
function NavigationSection({ title, items, currentPath }: NavigationSectionProps) {
5743
return (
5844
<div className="flex-1 overflow-y-auto px-4">
5945
<h2 className="px-3 text-xs font-semibold text-gray-500 uppercase tracking-wider my-3">{title}</h2>
60-
{items.map((item) => (
61-
<Link
62-
key={item.title}
63-
to={item.path}
64-
onClick={() => onClick(item.path)}
65-
className={`group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
66-
isNavigationActive(item.path) ? "bg-indigo-50 text-indigo-600" : "text-gray-600 hover:bg-gray-50 hover:text-gray-900"}`}
67-
>
68-
<item.icon className={`h-5 w-5 ${isNavigationActive(item.path) ? "text-indigo-600" : "text-gray-400 group-hover:text-gray-500"}`}/>
69-
{item.title}
70-
</Link>
71-
))}
46+
{items.map((item) => {
47+
const isActive = currentPath === item.path;
48+
49+
return (
50+
<Link
51+
key={item.title}
52+
to={item.path}
53+
className={`group flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
54+
isActive
55+
? "bg-indigo-50 text-indigo-600"
56+
: "text-gray-600 hover:bg-gray-50 hover:text-gray-900"
57+
}`}
58+
>
59+
<item.icon
60+
className={`h-5 w-5 ${
61+
isActive ? "text-indigo-600" : "text-gray-400 group-hover:text-gray-500"
62+
}`}
63+
/>
64+
{item.title}
65+
</Link>
66+
);
67+
})}
7268
</div>
7369
);
7470
}

src/components/sidebar/SidebarAccountDropdown.tsx

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
import {Button} from "@/components/ui/button.tsx";
99
import {Check, Ellipsis, X} from "lucide-react";
1010
import React, {useContext, useState} from "react";
11-
import {useNavigate} from "react-router-dom";
11+
import {Link, useNavigate} from "react-router-dom";
1212
import {UserContext} from "@/contexts/UserContext.tsx";
1313
import {
1414
Dialog,
@@ -23,10 +23,9 @@ import UserAvatar from "@/modules/user/components/UserAvatar.tsx";
2323

2424
type AccountDropdownProps = {
2525
isActive: boolean;
26-
onClick: (name: string) => void;
2726
};
2827

29-
export default function SidebarAccountDropdown({isActive, onClick}: AccountDropdownProps) {
28+
export default function SidebarAccountDropdown({isActive}: AccountDropdownProps) {
3029
const [signOut, setSignOut] = useState<boolean>(false);
3130
const navigate = useNavigate();
3231
const {user} = useContext(UserContext);
@@ -35,12 +34,8 @@ export default function SidebarAccountDropdown({isActive, onClick}: AccountDropd
3534
navigate("/profile");
3635
};
3736

38-
const handleDropdownClick = () => {
39-
onClick("profile");
40-
};
41-
4237
return (
43-
<div className='p-4' onClick={handleDropdownClick}>
38+
<Link className='p-4' to={"profile"}>
4439
<DropdownMenu>
4540
<DropdownMenuTrigger className="w-full">
4641
<div
@@ -65,7 +60,7 @@ export default function SidebarAccountDropdown({isActive, onClick}: AccountDropd
6560
</DropdownMenu>
6661

6762
{signOut && <SignOut signOut={signOut} setSignOut={setSignOut}/>}
68-
</div>
63+
</Link>
6964
)
7065
}
7166

src/core/types/NavigationItem.ts renamed to src/core/types/navigationItem.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export const navigationItems: NavigationItem[] = [
3535
title: "Leaves",
3636
path: "/leaves",
3737
icon: CalendarCheck,
38-
description: "Review and manage leave requests, and access user profiles.",
38+
description: "Review and manage leave requests, and access users profile.",
3939
accessLevel: [UserRole.ORGANIZATION_ADMIN, UserRole.TEAM_ADMIN]
4040
},
4141
{
@@ -49,21 +49,21 @@ export const navigationItems: NavigationItem[] = [
4949
title: "Leave Policy",
5050
path: "/leaves/policies",
5151
icon: TreePalm,
52-
description: "Create and manage custom leave types and policies for your organization.",
52+
description: "Create and manage custom leave types and policies.",
5353
accessLevel: [UserRole.ORGANIZATION_ADMIN]
5454
},
5555
{
5656
title: "Users",
5757
path: "/users",
5858
icon: User,
59-
description: "Add, edit, or remove users from the organization.",
59+
description: "Create and manage users.",
6060
accessLevel: [UserRole.ORGANIZATION_ADMIN, UserRole.TEAM_ADMIN]
6161
},
6262
{
6363
title: "Teams",
6464
path: "/teams",
6565
icon: Users,
66-
description: "Add, edit, or remove teams from the organization.",
66+
description: "Create and manage teams.",
6767
accessLevel: [UserRole.ORGANIZATION_ADMIN]
6868
}
6969
]

src/core/utils/navigation.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import {UserRole} from "@/core/types/enum.ts";
2+
import {NavigationItem, navigationItems} from "@/core/types/navigationItem.ts";
3+
4+
export const getAccessibleNavigationItems = (role: UserRole): NavigationItem[] => {
5+
return navigationItems.filter(item => item.accessLevel.includes(role))
6+
}

0 commit comments

Comments
 (0)