235 lines
7.2 KiB
TypeScript
235 lines
7.2 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectGroup,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Calendar } from "@/components/ui/calendar";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import type { ResultFilters } from "@/lib/form/admin-form-results";
|
||
import { Download, X, CalendarDays } from "lucide-react";
|
||
import { format, parseISO } from "date-fns";
|
||
|
||
function DateFilterPicker({
|
||
label,
|
||
value,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
value: string;
|
||
onChange: (value: string) => void;
|
||
}) {
|
||
const selected = value ? parseISO(value) : undefined;
|
||
|
||
return (
|
||
<Popover>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
data-empty={!selected}
|
||
className="w-full justify-start text-left font-normal data-[empty=true]:text-muted-foreground lg:w-44"
|
||
>
|
||
<CalendarDays data-icon="inline-start" />
|
||
{selected ? format(selected, "PP") : label}
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="w-auto p-0" align="start">
|
||
<Calendar
|
||
mode="single"
|
||
selected={selected}
|
||
onSelect={(date) => onChange(date ? format(date, "yyyy-MM-dd") : "")}
|
||
/>
|
||
{selected && (
|
||
<div className="border-t p-2">
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="sm"
|
||
className="w-full"
|
||
onClick={() => onChange("")}
|
||
>
|
||
Clear date
|
||
</Button>
|
||
</div>
|
||
)}
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
export function ResultsFilters({
|
||
formId,
|
||
filters,
|
||
onFiltersChange,
|
||
}: {
|
||
formId: string;
|
||
filters: ResultFilters;
|
||
onFiltersChange?: (filters: ResultFilters) => void;
|
||
}) {
|
||
const router = useRouter();
|
||
const [values, setValues] = useState(filters);
|
||
const base = `/admin/form/${formId}/result`;
|
||
|
||
const updateValues = (
|
||
update: (current: ResultFilters) => ResultFilters
|
||
) => {
|
||
setValues((current) => {
|
||
const next = update(current);
|
||
onFiltersChange?.(next);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const filterQuery = useMemo(() => {
|
||
const params = new URLSearchParams();
|
||
if (values.q) params.set("q", values.q);
|
||
if (values.sort !== "submittedAt") params.set("sort", values.sort);
|
||
if (values.direction !== "desc") params.set("direction", values.direction);
|
||
if (values.edited !== "all") params.set("edited", values.edited);
|
||
if (values.dateFrom) params.set("dateFrom", values.dateFrom);
|
||
if (values.dateTo) params.set("dateTo", values.dateTo);
|
||
return params.toString();
|
||
}, [values]);
|
||
|
||
const appliedQuery = useMemo(() => {
|
||
const params = new URLSearchParams();
|
||
if (filters.q) params.set("q", filters.q);
|
||
if (filters.sort !== "submittedAt") params.set("sort", filters.sort);
|
||
if (filters.direction !== "desc") params.set("direction", filters.direction);
|
||
if (filters.edited !== "all") params.set("edited", filters.edited);
|
||
if (filters.dateFrom) params.set("dateFrom", filters.dateFrom);
|
||
if (filters.dateTo) params.set("dateTo", filters.dateTo);
|
||
return params.toString();
|
||
}, [filters]);
|
||
|
||
useEffect(() => {
|
||
if (onFiltersChange) return;
|
||
if (filterQuery === appliedQuery) return;
|
||
|
||
const timeout = window.setTimeout(() => {
|
||
router.replace(`${base}${filterQuery ? `?${filterQuery}` : ""}`, {
|
||
scroll: false,
|
||
});
|
||
}, 350);
|
||
|
||
return () => window.clearTimeout(timeout);
|
||
}, [appliedQuery, base, filterQuery, onFiltersChange, router]);
|
||
|
||
return (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex flex-col gap-2 lg:flex-row">
|
||
<Input
|
||
value={values.q}
|
||
onChange={(event) =>
|
||
updateValues((current) => ({ ...current, q: event.target.value }))
|
||
}
|
||
placeholder="Search name or Discord ID"
|
||
aria-label="Search responses"
|
||
/>
|
||
<Select
|
||
value={values.edited}
|
||
onValueChange={(edited: ResultFilters["edited"]) =>
|
||
updateValues((current) => ({ ...current, edited }))
|
||
}
|
||
>
|
||
<SelectTrigger className="w-full lg:w-40" aria-label="Edit status">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectGroup>
|
||
<SelectItem value="all">All responses</SelectItem>
|
||
<SelectItem value="edited">Edited</SelectItem>
|
||
<SelectItem value="unedited">Not edited</SelectItem>
|
||
</SelectGroup>
|
||
</SelectContent>
|
||
</Select>
|
||
<Select
|
||
value={`${values.sort}-${values.direction}`}
|
||
onValueChange={(value) => {
|
||
const [sort, direction] = value.split("-") as [
|
||
ResultFilters["sort"],
|
||
ResultFilters["direction"],
|
||
];
|
||
updateValues((current) => ({ ...current, sort, direction }));
|
||
}}
|
||
>
|
||
<SelectTrigger className="w-full lg:w-44" aria-label="Sort responses">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectGroup>
|
||
<SelectItem value="submittedAt-desc">Newest first</SelectItem>
|
||
<SelectItem value="submittedAt-asc">Oldest first</SelectItem>
|
||
<SelectItem value="respondent-asc">Respondent A–Z</SelectItem>
|
||
<SelectItem value="respondent-desc">Respondent Z–A</SelectItem>
|
||
</SelectGroup>
|
||
</SelectContent>
|
||
</Select>
|
||
<DateFilterPicker
|
||
label="From date"
|
||
value={values.dateFrom}
|
||
onChange={(dateFrom) =>
|
||
updateValues((current) => ({ ...current, dateFrom }))
|
||
}
|
||
/>
|
||
<DateFilterPicker
|
||
label="Through date"
|
||
value={values.dateTo}
|
||
onChange={(dateTo) =>
|
||
updateValues((current) => ({ ...current, dateTo }))
|
||
}
|
||
/>
|
||
<Button variant="outline" asChild>
|
||
<a href={`${base}/export${filterQuery ? `?${filterQuery}` : ""}`}>
|
||
<Download data-icon="inline-start" />
|
||
CSV
|
||
</a>
|
||
</Button>
|
||
{(values.q ||
|
||
values.edited !== "all" ||
|
||
values.sort !== "submittedAt" ||
|
||
values.direction !== "desc" ||
|
||
values.dateFrom ||
|
||
values.dateTo) && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="self-start lg:self-auto"
|
||
aria-label="Clear filters"
|
||
asChild={!onFiltersChange}
|
||
onClick={onFiltersChange ? () => updateValues(() => ({
|
||
q: "",
|
||
page: 1,
|
||
sort: "submittedAt",
|
||
direction: "desc",
|
||
edited: "all",
|
||
dateFrom: "",
|
||
dateTo: "",
|
||
})) : undefined}
|
||
>
|
||
{onFiltersChange ? (
|
||
<X />
|
||
) : (
|
||
<a href={base}>
|
||
<X />
|
||
</a>
|
||
)}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|