Skip to content

Commit ff909a3

Browse files
committed
Improve reviewer UX for submissions table
- Global search box (find applicants by any field instantly) - Pagination with page-size selector (was rendering 700+ rows at once) - CSV export now respects active filters (was always exporting everything) - Click anywhere on a row to open the applicant popover (interactive cells like selects and notes still take priority) - Color-coded status cells for at-a-glance scanning - Status count chips in the toolbar that double as filters
1 parent cd8c537 commit ff909a3

3 files changed

Lines changed: 184 additions & 21 deletions

File tree

‎src/components/forms/applications/columns.tsx‎

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ export type ReferenceMap = {
5656
[key: string]: ReferenceItem | ReferenceMap | string;
5757
};
5858

59+
export const STATUS_COLORS: Record<string, string> = {
60+
accepted: "bg-green-600/50",
61+
rejected: "bg-red-600/50",
62+
declined: "bg-neutral-600/60",
63+
pending: "bg-yellow-600/50",
64+
submitted: "bg-blue-600/50",
65+
offered: "bg-purple-600/50",
66+
waitlisted: "bg-orange-600/50",
67+
interviewed: "bg-cyan-600/50",
68+
paid: "bg-teal-600/50",
69+
};
70+
5971
export function populateReferenceMap(
6072
fields: FormFields,
6173
others: { id: string; label: string; options: any[] }[],
@@ -429,21 +441,29 @@ export function SelectField({
429441

430442
return (
431443
<>
432-
<MultiSelect
433-
className={
434-
"w-full bg-transparent border-none hover:bg-lp-500 duration-300"
435-
}
436-
onChange={(e) => updateField(e[0])}
437-
allowMultiple={false}
438-
emptyText={nullLabel?.toString() || "None"}
439-
value={Array.isArray(selected) ? selected : [selected]}
440-
options={
441-
selectOptions?.map((op) => ({
442-
label: op.label,
443-
value: op.id,
444-
})) || []
445-
}
446-
></MultiSelect>
444+
<div
445+
className={`rounded-md ${
446+
id === "status" && selected
447+
? STATUS_COLORS[selected.toString().toLowerCase()] ?? ""
448+
: ""
449+
}`}
450+
>
451+
<MultiSelect
452+
className={
453+
"w-full bg-transparent border-none hover:bg-lp-500 duration-300"
454+
}
455+
onChange={(e) => updateField(e[0])}
456+
allowMultiple={false}
457+
emptyText={nullLabel?.toString() || "None"}
458+
value={Array.isArray(selected) ? selected : [selected]}
459+
options={
460+
selectOptions?.map((op) => ({
461+
label: op.label,
462+
value: op.id,
463+
})) || []
464+
}
465+
></MultiSelect>
466+
</div>
447467
</>
448468
);
449469
}

‎src/components/forms/applications/data-table.tsx‎

Lines changed: 144 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
getSortedRowModel,
1212
getFacetedUniqueValues,
1313
getFacetedMinMaxValues,
14+
getPaginationRowModel,
1415
Column,
1516
RowData,
1617
SortingState,
@@ -20,14 +21,22 @@ import { Button } from "@/components/primitives/button";
2021
import { Input } from "@/components/primitives/input";
2122

2223
import { Table, TableCell, TableRow } from "@/components/primitives/table";
23-
import React, { CSSProperties, useMemo, useState } from "react";
24-
import { ChartArea, ListFilterIcon, TableIcon } from "lucide-react";
24+
import React, { CSSProperties, Fragment, useMemo, useState } from "react";
25+
import {
26+
ChartArea,
27+
ChevronLeftIcon,
28+
ChevronRightIcon,
29+
ListFilterIcon,
30+
SearchIcon,
31+
TableIcon,
32+
} from "lucide-react";
2533
import { DataTableProps } from "@/components/forms/applications/dataTableWrapper";
2634
import AnalyticsPage from "@/components/forms/applications/AnalyticsPage";
2735
import {
2836
FormFields,
2937
ReferenceItem,
3038
ReferenceMap,
39+
STATUS_COLORS,
3140
} from "@/components/forms/applications/columns";
3241
import MultiSelect from "@/components/general/multiSelect";
3342

@@ -54,10 +63,16 @@ export function DataTable<TData, TValue>({
5463
data,
5564
refMap,
5665
config,
66+
onRowClick,
5767
}: DataTableProps<TData, TValue>) {
5868
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
5969
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
6070
const [sorting, setSorting] = React.useState<SortingState>([]);
71+
const [globalFilter, setGlobalFilter] = useState("");
72+
const [pagination, setPagination] = React.useState({
73+
pageIndex: 0,
74+
pageSize: 50,
75+
});
6176
const [tabView, setTabView] = useState<"table" | "chart">("table");
6277

6378
const table = useReactTable({
@@ -66,6 +81,10 @@ export function DataTable<TData, TValue>({
6681
getCoreRowModel: getCoreRowModel(),
6782
onColumnFiltersChange: setColumnFilters,
6883
getFilteredRowModel: getFilteredRowModel(),
84+
onGlobalFilterChange: setGlobalFilter,
85+
globalFilterFn: "includesString",
86+
onPaginationChange: setPagination,
87+
getPaginationRowModel: getPaginationRowModel(),
6988
onColumnVisibilityChange: setColumnVisibility,
7089
onSortingChange: setSorting,
7190
getSortedRowModel: getSortedRowModel(),
@@ -81,10 +100,50 @@ export function DataTable<TData, TValue>({
81100
columnFilters,
82101
columnVisibility,
83102
sorting,
103+
globalFilter,
104+
pagination,
84105
...(config?.columnOrder ? { columnOrder: config.columnOrder } : {}),
85106
},
86107
});
87108

109+
const statusCounts = useMemo(() => {
110+
const counts: Record<string, number> = {};
111+
(config?.statusOptions || []).forEach((option: any) => {
112+
counts[option.id] = 0;
113+
});
114+
table
115+
.getCoreRowModel()
116+
.rows.forEach((row) => {
117+
const status = (row.original as any)?.status;
118+
if (status && status in counts) {
119+
counts[status] += 1;
120+
} else if (status) {
121+
counts[status] = (counts[status] || 0) + 1;
122+
}
123+
});
124+
return counts;
125+
}, [config?.statusOptions, table]);
126+
127+
const activeStatusFilter = columnFilters.find((c) => c.id === "status")
128+
?.value as string | undefined;
129+
130+
function toggleStatusFilter(statusId: string) {
131+
const isActive = activeStatusFilter === statusId;
132+
setColumnFilters([
133+
...columnFilters.filter((c) => c.id !== "status"),
134+
...(isActive ? [] : [{ id: "status", value: statusId }]),
135+
]);
136+
}
137+
138+
function handleRowClick(e: React.MouseEvent, row: any) {
139+
if (!onRowClick) return;
140+
const target = e.target as HTMLElement;
141+
if (target.closest("button, input, textarea, select, a, [role=combobox]")) {
142+
return;
143+
}
144+
onRowClick(row);
145+
}
146+
88147
return (
89148
<div className={"flex flex-col px-10 overflow-hidden pb-4"}>
90149
<div className="flex items-center w-full gap-2 py-4 flex-wrap">
@@ -94,6 +153,36 @@ export function DataTable<TData, TValue>({
94153
</span>
95154
</div>
96155

156+
<Input
157+
placeholder="Search applicants..."
158+
value={globalFilter}
159+
onChange={(e) => setGlobalFilter(e.target.value)}
160+
className="w-64 h-10 bg-background-600 border-background-500"
161+
/>
162+
163+
{config?.statusOptions?.length > 0 && (
164+
<div className="flex items-center gap-1 flex-wrap">
165+
{(config.statusOptions as any[]).map((option) => {
166+
const count = statusCounts[option.id] || 0;
167+
const active = activeStatusFilter === option.id;
168+
return (
169+
<button
170+
key={option.id}
171+
onClick={() => toggleStatusFilter(option.id)}
172+
className={`rounded-full px-3 py-1 text-xs border transition-colors ${
173+
active
174+
? "border-lp-400 bg-lp-500 text-white"
175+
: "border-background-500 bg-background-600 text-neutral-200 hover:border-background-400"
176+
}`}
177+
>
178+
{option.label}
179+
<span className="ml-1 opacity-70">{count}</span>
180+
</button>
181+
);
182+
})}
183+
</div>
184+
)}
185+
97186
<div
98187
className={
99188
"flex relative items-center bg-background-600 rounded-lg border-background-500 border-none h-10 p-1 gap-2"
@@ -115,7 +204,7 @@ export function DataTable<TData, TValue>({
115204
setTabView("chart");
116205
}}
117206
size={"fit"}
118-
className={`bg-background-600 w-24 h-full border-none border-background-500 gap-2 ${tabView === "chart" ? "bg-background-500" : ""}`}
207+
className={`bg-background-600 w-24 h-full border-none border-background-500 gap-2 ${tabView === "chart" ? "bg-background-500" : ""}`}
119208
>
120209
<ChartArea className={"h-4 w-4"} />
121210
Chart
@@ -132,7 +221,10 @@ export function DataTable<TData, TValue>({
132221
setColumnFilters={setColumnFilters}
133222
/>
134223
)}
135-
<DownloadCSV data={data} fileName="data" />
224+
<DownloadCSV
225+
data={table.getFilteredRowModel().rows.map((row) => row.original)}
226+
fileName={config?.title || "submissions"}
227+
/>
136228
</div>
137229
{table.getFilteredRowModel().rows.length > 0 && tabView === "chart" && (
138230
<AnalyticsPage
@@ -178,10 +270,11 @@ export function DataTable<TData, TValue>({
178270
table.getRowModel().rows.map((row) => (
179271
<tr
180272
className={
181-
"border-background-500 p-0 flex-shrink-0 w-full bg-background-600 odd:bg-background-700"
273+
"border-background-500 p-0 flex-shrink-0 w-full bg-background-600 odd:bg-background-700 hover:bg-background-500 cursor-pointer transition-colors"
182274
}
183275
key={row.id}
184276
data-state={row.getIsSelected() && "selected"}
277+
onClick={(e) => handleRowClick(e, row)}
185278
>
186279
{row.getVisibleCells().map((cell) => (
187280
<td
@@ -211,6 +304,52 @@ export function DataTable<TData, TValue>({
211304
</Table>
212305
</div>
213306
)}
307+
{tabView === "table" && (
308+
<Fragment>
309+
<div className="flex items-center gap-3 py-2 text-sm text-neutral-300">
310+
<Button
311+
size={"fit"}
312+
className="bg-background-600 min-h-none h-8 gap-1"
313+
onClick={() => table.previousPage()}
314+
disabled={!table.getCanPreviousPage()}
315+
>
316+
<ChevronLeftIcon className="h-4 w-4" />
317+
Prev
318+
</Button>
319+
<span>
320+
Page {table.getState().pagination.pageIndex + 1} of{" "}
321+
{table.getPageCount() || 1}
322+
</span>
323+
<Button
324+
size={"fit"}
325+
className="bg-background-600 min-h-none h-8 gap-1"
326+
onClick={() => table.nextPage()}
327+
disabled={!table.getCanNextPage()}
328+
>
329+
Next
330+
<ChevronRightIcon className="h-4 w-4" />
331+
</Button>
332+
<select
333+
className="bg-background-600 border border-background-500 rounded p-1 h-8"
334+
value={table.getState().pagination.pageSize}
335+
onChange={(e) => {
336+
const size =
337+
e.target.value === "all"
338+
? table.getFilteredRowModel().rows.length || 1
339+
: Number(e.target.value);
340+
table.setPageSize(size);
341+
}}
342+
>
343+
{[25, 50, 100].map((size) => (
344+
<option key={size} value={size}>
345+
{size} / page
346+
</option>
347+
))}
348+
<option value="all">All</option>
349+
</select>
350+
</div>
351+
</Fragment>
352+
)}
214353
</div>
215354
);
216355
}

‎src/components/forms/applications/dataTableWrapper.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export type DataTableProps<TData, TValue> = {
1717
data: TData[];
1818
refMap: ReferenceMap;
1919
config: any;
20+
onRowClick?: (row: any) => void;
2021
};
2122

2223
type DataTableWrapperProps<TData> = {
@@ -32,7 +33,7 @@ export default function DataTableWrapper<TData>({
3233
const { setAndOpen, applicantPopover } = useApplicantPopover({
3334
fields: formFields,
3435
});
35-
const { members } = useContext(formContext);
36+
const { members, rawForm } = useContext(formContext);
3637
const membersWithLabel = members.map((member) => ({
3738
...member,
3839
label: member.display_name || member.email,
@@ -42,10 +43,12 @@ export default function DataTableWrapper<TData>({
4243
]);
4344
const columns = createColumns(formFields, members, setAndOpen);
4445
const config = {
46+
title: rawForm?.title,
4547
view: {
4648
showFilter: true,
4749
showChart: true,
4850
},
51+
statusOptions: formFields["status"]?.options,
4952
analytics: {
5053
columns: [
5154
"status",
@@ -83,6 +86,7 @@ export default function DataTableWrapper<TData>({
8386
data={fieldData}
8487
refMap={refMap}
8588
config={config}
89+
onRowClick={(row) => setAndOpen({ applicant: row })}
8690
/>
8791
</Fragment>
8892
);

0 commit comments

Comments
 (0)