feat: add debounced search input and clear filter functionality to PersonalDocumentsCard

This commit is contained in:
estifanos
2026-08-28 11:28:41 +00:00
parent bf083bff82
commit 664558146d
3 changed files with 66 additions and 25 deletions

View File

@@ -6,13 +6,16 @@ import {
Button,
Group,
Modal,
Paper,
Select,
Stack,
Text,
TextInput,
Title,
Tooltip,
} from '@mantine/core';
import { IconEdit, IconPlus, IconTrash } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash, IconX } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import {
AdvancedTable,
@@ -41,6 +44,8 @@ type DraftRequirement = Omit<DocumentRequirement, 'id' | 'licenseTypeId' | 'isAc
/** Filter value for "the documents every licence asks for". */
const GLOBAL_ONLY = 'GLOBAL';
const SEARCH_DEBOUNCE_MS = 300;
/** `application/vnd.openxmlformats-…-document` is nobody's idea of a column. */
const MIME_LABELS: Record<string, string> = {
'application/pdf': 'PDF',
@@ -93,14 +98,17 @@ export function PersonalDocumentsCard() {
/** null = any licence, GLOBAL_ONLY = the all-licence ones, else a type id. */
const [licenseTypeFilter, setLicenseTypeFilter] = useState<string | null>(null);
const { q, setQ, pageIndex, setPageIndex, pageSize, setPageSize } = useServerTable({
const { pageIndex, setPageIndex, pageSize, setPageSize } = useServerTable({
pageSize: 10,
});
const [searchInput, setSearchInput] = useState('');
// Typing must not fire a request per keystroke; same 300ms as the queue.
const [search] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
// Every facet goes to the server: it filters and searches in SQL, groups the
// rows into documents, then pages the documents.
const { data, isFetching, refetch } = useGetPersonalDocumentsQuery({
search: q.trim() || undefined,
search: search.trim() || undefined,
licenseTypeId:
licenseTypeFilter && licenseTypeFilter !== GLOBAL_ONLY
? licenseTypeFilter
@@ -126,7 +134,13 @@ export function PersonalDocumentsCard() {
);
/** Filters are the server's business now; an empty page is its answer. */
const isFiltered = q.trim() !== '' || licenseTypeFilter !== null;
const isFiltered = search.trim() !== '' || licenseTypeFilter !== null;
function clearFilters() {
setSearchInput('');
setLicenseTypeFilter(null);
setPageIndex(0);
}
const typeName = (id: string) => {
const found = (licenseTypes?.items ?? []).find((lt) => lt.id === id);
@@ -298,25 +312,24 @@ export function PersonalDocumentsCard() {
</Button>
</Group>
<AdvancedTable
tableName={t('certReq.personal.title', 'Personal documents')}
columns={columns}
data={groups}
itemCount={data?.total ?? 0}
pageIndex={pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
onSearchChange={setQ}
refresh={refetch}
isLoading={isFetching}
emptyText={
isFiltered
? t('certReq.personal.noMatch', 'No personal document matches those filters.')
: t('certReq.personal.empty', 'No personal documents configured yet.')
}
toolbar={
{/* Facets, in the shape the licence-review queue uses. No date range:
a configuration row has no submission date to filter on. */}
<Paper withBorder p="sm">
<Group gap="sm" align="flex-end" wrap="wrap">
<TextInput
label={t('certReq.personal.search', 'Search')}
placeholder={t('certReq.personal.searchPlaceholder', 'Search by name or key')}
leftSection={<IconSearch size={14} />}
value={searchInput}
onChange={(e) => {
const { value } = e.currentTarget;
setSearchInput(value);
setPageIndex(0);
}}
w={240}
/>
<Select
label={t('certReq.doc.scope', 'Applies to')}
placeholder={t('certReq.personal.filterAny', 'Any licence type')}
data={[
{
@@ -336,9 +349,35 @@ export function PersonalDocumentsCard() {
}}
searchable
clearable
size="sm"
w={240}
/>
{isFiltered && (
<Button
variant="subtle"
leftSection={<IconX size={14} />}
onClick={clearFilters}
>
{t('certReq.personal.clearFilters', 'Clear')}
</Button>
)}
</Group>
</Paper>
<AdvancedTable
tableName={t('certReq.personal.title', 'Personal documents')}
columns={columns}
data={groups}
itemCount={data?.total ?? 0}
pageIndex={pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={
isFiltered
? t('certReq.personal.noMatch', 'No personal document matches those filters.')
: t('certReq.personal.empty', 'No personal documents configured yet.')
}
/>

View File

@@ -1374,6 +1374,7 @@ export const am: Translations = {
"አመልካቹ በእያንዳንዱ ማመልከቻ ላይ ከመስቀል ይልቅ በ\u2018ሰነዶቼ\u2019 ውስጥ አንድ ጊዜ የሚያስቀምጣቸው የማንነትና የትምህርት ሰነዶች። ለተወሰኑ የፈቃድ አይነቶች ከወሰኑት፣ እነዚያን የሥራ ዘርፍ ያሳወቁ አመልካቾች ብቻ ይጠየቃሉ።",
add: "የግል ሰነድ ጨምር",
empty: "እስካሁን የተዋቀረ የግል ሰነድ የለም።",
search: "ፍለጋ",
searchPlaceholder: "በስም ወይም በቁልፍ ይፈልጉ",
moreTypes: " +{{count}} ተጨማሪ",
columns: {
@@ -1383,7 +1384,7 @@ export const am: Translations = {
filterAny: "ማንኛውም የፈቃድ አይነት",
filterGlobal: "ለሁሉም ፈቃዶች የሚሆኑ ብቻ",
noMatch: "በእነዚህ ማጣሪያዎች የሚመጣጠን የግል ሰነድ የለም።",
clearFilters: "ማጣሪያዎችን አጽዳ",
clearFilters: "አጽዳ",
fileCount_one: "{{count}} ፋይል",
fileCount_other: "{{count}} ፋይሎች",
deleteWarning:

View File

@@ -1380,6 +1380,7 @@ export const en = {
'Identity and education documents an applicant keeps once, in My Documents, instead of uploading with every application. Scope one to licence types and only applicants who declared those modes of operation are asked for it.',
add: 'Add personal document',
empty: 'No personal documents configured yet.',
search: 'Search',
searchPlaceholder: 'Search by name or key',
moreTypes: ' +{{count}} more',
columns: {
@@ -1389,7 +1390,7 @@ export const en = {
filterAny: 'Any licence type',
filterGlobal: 'All-licence documents only',
noMatch: 'No personal document matches those filters.',
clearFilters: 'Clear filters',
clearFilters: 'Clear',
fileCount_one: '{{count}} file',
fileCount_other: '{{count}} files',
deleteWarning: