Files
erika/app/admin/form/[id]/result/client.tsx
T

235 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}