Skip to content

Commit d21c492

Browse files
authored
feat: Add comprehensive mobile sorting for users table with multilingual support
Merge pull request #37 from MatinDehghanian/expire-sort-mobile
2 parents d768189 + be4a201 commit d21c492

File tree

7 files changed

+171
-24
lines changed

7 files changed

+171
-24
lines changed

dashboard/public/statics/locales/en.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,8 +620,24 @@
620620
"deleteUser.title": "Remove User",
621621
"disabled": "disabled",
622622
"expire": "Expire",
623+
"expireDate": "Expire Date",
623624
"expires": "Expires in {{time}}",
624625
"expired": "Expired {{time}}",
626+
"sortOptions": "Sort Options",
627+
"sort": {
628+
"username": {
629+
"asc": "Username (A-Z)",
630+
"desc": "Username (Z-A)"
631+
},
632+
"expire": {
633+
"oldest": "Expire Date (Oldest First)",
634+
"newest": "Expire Date (Newest First)"
635+
},
636+
"usage": {
637+
"low": "Data Usage (Low to High)",
638+
"high": "Data Usage (High to Low)"
639+
}
640+
},
625641
"header.donation": "Donation",
626642
"header.hostSettings": "Host Settings",
627643
"header.logout": "Log out",

dashboard/public/statics/locales/fa.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,8 +508,24 @@
508508
"deleteUser.title": "حذف کاربر",
509509
"disabled": "غیر فعال",
510510
"expire": "انقضا",
511+
"expireDate": "تاریخ انقضا",
511512
"expired": "{{time}} منقضی شده",
512513
"expires": "پایان در {{time}}",
514+
"sortOptions": "گزینه‌های مرتب‌سازی",
515+
"sort": {
516+
"username": {
517+
"asc": "نام کاربری (الف-ی)",
518+
"desc": "نام کاربری (ی-الف)"
519+
},
520+
"expire": {
521+
"oldest": "تاریخ انقضا (قدیمی‌ترین اول)",
522+
"newest": "تاریخ انقضا (جدیدترین اول)"
523+
},
524+
"usage": {
525+
"low": "مصرف داده (کم به زیاد)",
526+
"high": "مصرف داده (زیاد به کم)"
527+
}
528+
},
513529
"header.donation": "کمک مالی",
514530
"header.hostSettings": "تنظیمات هاست",
515531
"header.logout": "خروج",

dashboard/public/statics/locales/ru.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,8 +593,24 @@
593593
"deleteUser.title": "Удалить пользователя",
594594
"disabled": "disabled",
595595
"expire": "Истекает",
596+
"expireDate": "Дата истечения",
596597
"expired": "Истёк {{time}}",
597598
"expires": "Истекает через {{time}}",
599+
"sortOptions": "Параметры сортировки",
600+
"sort": {
601+
"username": {
602+
"asc": "Имя пользователя (А-Я)",
603+
"desc": "Имя пользователя (Я-А)"
604+
},
605+
"expire": {
606+
"oldest": "Дата истечения (сначала старые)",
607+
"newest": "Дата истечения (сначала новые)"
608+
},
609+
"usage": {
610+
"low": "Использование данных (от малого к большому)",
611+
"high": "Использование данных (от большого к малому)"
612+
}
613+
},
598614
"header.donation": "Пожертвование",
599615
"header.hostSettings": "Настройки хоста",
600616
"header.logout": "Выйти",

dashboard/public/statics/locales/zh.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -619,8 +619,24 @@
619619
"deleteUser.title": "删除用户",
620620
"disabled": "禁用",
621621
"expire": "过期",
622+
"expireDate": "到期日期",
622623
"expired": "{{time}}已过期",
623624
"expires": "将在 {{time}} 后过期",
625+
"sortOptions": "排序选项",
626+
"sort": {
627+
"username": {
628+
"asc": "用户名 (A-Z)",
629+
"desc": "用户名 (Z-A)"
630+
},
631+
"expire": {
632+
"oldest": "到期日期 (最旧的优先)",
633+
"newest": "到期日期 (最新的优先)"
634+
},
635+
"usage": {
636+
"low": "数据使用量 (从低到高)",
637+
"high": "数据使用量 (从高到低)"
638+
}
639+
},
624640
"header.donation": "捐赠",
625641
"header.hostSettings": "设置",
626642
"header.logout": "退出",

dashboard/src/components/users/columns.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ export const setupColumns = ({
1717
t: (key: string) => string
1818
handleSort: (column: string) => void
1919
filters: { sort: string; status?: string }
20-
handleStatusFilter: (value: any) => void
21-
dir: any
20+
handleStatusFilter: (value: string) => void
21+
dir: string
2222
}): ColumnDef<UserResponse>[] => [
2323
{
2424
accessorKey: 'username',
@@ -59,7 +59,7 @@ export const setupColumns = ({
5959
accessorKey: 'status',
6060
header: () => (
6161
<div className="flex items-center">
62-
<Select dir={dir || ''} onValueChange={handleStatusFilter} value={filters.status || '0'}>
62+
<Select dir={dir as 'ltr' | 'rtl'} onValueChange={handleStatusFilter} value={filters.status || '0'}>
6363
<SelectTrigger icon={false} className="ring-none w-fit max-w-28 border-none p-0 sm:px-1">
6464
<span className="px-0 text-xs capitalize">{t('usersTable.status')}</span>
6565
</SelectTrigger>
@@ -74,6 +74,7 @@ export const setupColumns = ({
7474
<SelectItem value="expired">{t('hostsDialog.status.expired')}</SelectItem>
7575
</SelectContent>
7676
</Select>
77+
{/* Desktop expire sorting */}
7778
<div className="hidden items-center sm:flex">
7879
<span>/</span>
7980
<button className="flex w-full items-center gap-1 px-2 py-3" onClick={handleSort.bind(null, 'expire')}>

dashboard/src/components/users/filters.tsx

Lines changed: 102 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,49 @@ import { Pagination, PaginationContent, PaginationEllipsis, PaginationItem, Pagi
44
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
55
import { Badge } from '@/components/ui/badge'
66
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
7+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuSeparator, DropdownMenuLabel } from '@/components/ui/dropdown-menu'
78
import useDirDetection from '@/hooks/use-dir-detection'
89
import { cn } from '@/lib/utils'
910
import { debounce } from 'es-toolkit'
10-
import { RefreshCw, SearchIcon, Filter, X } from 'lucide-react'
11-
import { useCallback, useState } from 'react'
11+
import { RefreshCw, SearchIcon, Filter, X, ArrowUpDown, User, Calendar, ChartPie, ChevronDown } from 'lucide-react'
12+
import { useState, useMemo } from 'react'
1213
import { useTranslation } from 'react-i18next'
1314
import { useGetUsers, UserStatus } from '@/service/api'
1415
import { RefetchOptions } from '@tanstack/react-query'
1516
import { LoaderCircle } from 'lucide-react'
1617
import { UseFormReturn } from 'react-hook-form'
1718

19+
// Sort configuration to eliminate duplication
20+
const sortSections = [
21+
{
22+
key: 'username',
23+
icon: User,
24+
label: 'username',
25+
items: [
26+
{ value: 'username', label: 'sort.username.asc' },
27+
{ value: '-username', label: 'sort.username.desc' },
28+
],
29+
},
30+
{
31+
key: 'expire',
32+
icon: Calendar,
33+
label: 'expireDate',
34+
items: [
35+
{ value: 'expire', label: 'sort.expire.oldest' },
36+
{ value: '-expire', label: 'sort.expire.newest' },
37+
],
38+
},
39+
{
40+
key: 'usage',
41+
icon: ChartPie,
42+
label: 'dataUsage',
43+
items: [
44+
{ value: 'used_traffic', label: 'sort.usage.low' },
45+
{ value: '-used_traffic', label: 'sort.usage.high' },
46+
],
47+
},
48+
] as const
49+
1850
interface FiltersProps {
1951
filters: {
2052
search?: string
@@ -25,13 +57,14 @@ interface FiltersProps {
2557
load_sub: boolean
2658
}
2759
onFilterChange: (filters: Partial<FiltersProps['filters']>) => void
28-
refetch?: (options?: RefetchOptions) => Promise<any>
60+
refetch?: (options?: RefetchOptions) => Promise<unknown>
2961
advanceSearchOnOpen: (status: boolean) => void
30-
advanceSearchForm?: UseFormReturn<any>
62+
advanceSearchForm?: UseFormReturn<Record<string, unknown>>
3163
onClearAdvanceSearch?: () => void
64+
handleSort?: (column: string) => void
3265
}
3366

34-
export const Filters = ({ filters, onFilterChange, refetch, advanceSearchOnOpen, advanceSearchForm, onClearAdvanceSearch }: FiltersProps) => {
67+
export const Filters = ({ filters, onFilterChange, refetch, advanceSearchOnOpen, advanceSearchForm, onClearAdvanceSearch, handleSort }: FiltersProps) => {
3568
const { t } = useTranslation()
3669
const dir = useDirDetection()
3770
const [search, setSearch] = useState(filters.search || '')
@@ -40,20 +73,22 @@ export const Filters = ({ filters, onFilterChange, refetch, advanceSearchOnOpen,
4073
const handleRefetch = refetch || userQuery.refetch
4174

4275
// Ultra-fast debounced search function
43-
const setSearchField = useCallback(
44-
debounce((value: string) => {
45-
onFilterChange({
46-
search: value,
47-
offset: 0, // Reset to first page when search is updated
48-
})
49-
}, 25), // Ultra-fast debounce
50-
[onFilterChange], // Recreate the debounced function when onFilterChange changes
76+
const debouncedFilterChange = useMemo(
77+
() =>
78+
debounce((value: string) => {
79+
onFilterChange({
80+
search: value,
81+
offset: 0, // Reset to first page when search is updated
82+
})
83+
}, 25), // Ultra-fast debounce
84+
[onFilterChange],
5185
)
5286

5387
// Handle input change
5488
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
55-
setSearch(e.target.value)
56-
setSearchField(e.target.value)
89+
const value = e.target.value
90+
setSearch(value)
91+
debouncedFilterChange(value)
5792
}
5893

5994
// Clear search field
@@ -83,18 +118,24 @@ export const Filters = ({ filters, onFilterChange, refetch, advanceSearchOnOpen,
83118
// Check if any advance search filters are active
84119
const hasActiveAdvanceFilters = () => {
85120
if (!advanceSearchForm) return false
86-
const values = advanceSearchForm.getValues()
87-
return (values.admin && values.admin.length > 0) || (values.group && values.group.length > 0) || values.status !== '0'
121+
const values = advanceSearchForm.getValues() as Record<string, unknown>
122+
const admin = values.admin as string[] | undefined
123+
const group = values.group as string[] | undefined
124+
const status = values.status as string | undefined
125+
return (admin && admin.length > 0) || (group && group.length > 0) || status !== '0'
88126
}
89127

90128
// Get the count of active advance filters
91129
const getActiveFiltersCount = () => {
92130
if (!advanceSearchForm) return 0
93-
const values = advanceSearchForm.getValues()
131+
const values = advanceSearchForm.getValues() as Record<string, unknown>
132+
const admin = values.admin as string[] | undefined
133+
const group = values.group as string[] | undefined
134+
const status = values.status as string | undefined
94135
let count = 0
95-
if (values.admin && values.admin.length > 0) count++
96-
if (values.group && values.group.length > 0) count++
97-
if (values.status !== '0') count++
136+
if (admin && admin.length > 0) count++
137+
if (group && group.length > 0) count++
138+
if (status !== '0') count++
98139
return count
99140
}
100141

@@ -132,6 +173,46 @@ export const Filters = ({ filters, onFilterChange, refetch, advanceSearchOnOpen,
132173
</Popover>
133174
)}
134175
</div>
176+
{/* Sort Button */}
177+
{handleSort && (
178+
<div className="flex h-full items-center gap-1">
179+
<DropdownMenu>
180+
<DropdownMenuTrigger asChild>
181+
<Button size="icon-md" variant="ghost" className="relative flex items-center gap-2 border" aria-label={t('sortOptions', { defaultValue: 'Sort Options' })}>
182+
<ArrowUpDown className="h-4 w-4" />
183+
{filters.sort && filters.sort !== '-created_at' && <div className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-primary" />}
184+
</Button>
185+
</DropdownMenuTrigger>
186+
<DropdownMenuContent align="end" className="w-52 md:w-56">
187+
{sortSections.map((section, sectionIndex) => (
188+
<div key={section.key}>
189+
{/* Section Label */}
190+
<DropdownMenuLabel className="flex items-center gap-1.5 px-2 py-1.5 text-xs text-muted-foreground md:gap-2 md:px-3 md:py-2">
191+
<section.icon className="h-3 w-3" />
192+
<span className="text-xs md:text-sm">{t(section.label)}</span>
193+
</DropdownMenuLabel>
194+
195+
{/* Section Items */}
196+
{section.items.map(item => (
197+
<DropdownMenuItem
198+
key={item.value}
199+
onClick={() => handleSort && handleSort(item.value)}
200+
className={`whitespace-nowrap px-2 py-1.5 text-xs md:px-3 md:py-2 ${filters.sort === item.value ? 'bg-accent' : ''}`}
201+
>
202+
<section.icon className="mr-1.5 h-3 w-3 flex-shrink-0 md:mr-2 md:h-4 md:w-4" />
203+
<span className="truncate">{t(item.label)}</span>
204+
{filters.sort === item.value && <ChevronDown className={`ml-auto h-3 w-3 flex-shrink-0 md:h-4 md:w-4 ${item.value.startsWith('-') ? '' : 'rotate-180'}`} />}
205+
</DropdownMenuItem>
206+
))}
207+
208+
{/* Add separator except for last section */}
209+
{sectionIndex < sortSections.length - 1 && <DropdownMenuSeparator />}
210+
</div>
211+
))}
212+
</DropdownMenuContent>
213+
</DropdownMenu>
214+
</div>
215+
)}
135216
{/* Refresh Button */}
136217
<div className="flex h-full items-center gap-2">
137218
<Button size="icon-md" onClick={handleRefreshClick} variant="ghost" className="flex items-center gap-2 border" disabled={isRefreshing}>

dashboard/src/components/users/users-table.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ const UsersTable = memo(() => {
253253
advanceSearchOnOpen={setIsAdvanceSearchOpen}
254254
refetch={handleManualRefresh}
255255
advanceSearchForm={advanceSearchForm}
256+
handleSort={handleSort}
256257
onClearAdvanceSearch={() => {
257258
advanceSearchForm.reset({
258259
is_username: true,

0 commit comments

Comments
 (0)