Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 46 additions & 15 deletions src/components/features/query/popovers/filter-rows.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Combobox } from "@/components/ui/combobox";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Expand All @@ -11,34 +12,38 @@ import {
} from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { FilterRowsSelection } from "@/lib/firebase/types";
import { CATEGORICAL_COLUMNS } from "@/services/query";
import { Check, Pencil, Plus, X } from "lucide-react";
import { useState } from "react";

const BOOLEAN_OPTIONS = ["true", "false"];

/**
* Set of possible operators for filtering rows based on data type.
*/
const CONDITION_OPTIONS: Record<string, { value: string; label: string }[]> = {
string: [
{ value: "matches", label: "matches" },
{ value: "does_not_match", label: "does not match" },
{ value: "equals", label: "equals" },
{ value: "not_equals", label: "is not equal to" },
{ value: "matches", label: "contains" },
{ value: "does_not_match", label: "does not contain" },
{ value: "equals", label: "exactly equals" },
{ value: "not_equals", label: "is not exactly equal to" },
],
number: [
{ value: "equals", label: "equals" },
{ value: "not_equals", label: "is not equal to" },
{ value: "equals", label: "exactly equals" },
{ value: "not_equals", label: "is not exactly equal to" },
{ value: "greater_than", label: "greater than" },
{ value: "less_than", label: "less than" },
],
boolean: [
{ value: "equals", label: "equals" },
{ value: "not_equals", label: "is not equal to" },
{ value: "equals", label: "exactly equals" },
{ value: "not_equals", label: "is not exactly equal to" },
],
};

interface FilterRowsProps {
columns: string[];
columnTypes: Record<string, string>;
columnValueOptions: Record<string, string[]>;
filterSelections: FilterRowsSelection[];
onAddFilter: (filter: FilterRowsSelection) => void;
onRemoveFilter: (filterId: string) => void;
Expand All @@ -53,6 +58,7 @@ interface FilterRowsProps {
export function FilterRows({
columns,
columnTypes,
columnValueOptions,
filterSelections,
onAddFilter,
onRemoveFilter,
Expand Down Expand Up @@ -283,13 +289,38 @@ export function FilterRows({
))}
</SelectContent>
</Select>
<Input
className="max-w-[160px] text-sm"
placeholder="Enter value…"
value={newFilterValue}
onChange={(e) => setNewFilterValue(e.target.value)}
disabled={!newFilterCondition}
/>
{(() => {
const isBoolean = type === "boolean";
const isCategorical = CATEGORICAL_COLUMNS.has(newFilterColumn);
const options = isBoolean
? BOOLEAN_OPTIONS
: isCategorical
? (columnValueOptions[newFilterColumn] ?? [])
: [];

if (isBoolean || isCategorical) {
return (
<Combobox
className="max-w-[160px] text-sm"
placeholder="Enter value…"
value={newFilterValue}
onChange={setNewFilterValue}
options={options}
disabled={!newFilterCondition}
/>
);
}

return (
<Input
className="max-w-[160px] text-sm"
placeholder="Enter value…"
value={newFilterValue}
onChange={(e) => setNewFilterValue(e.target.value)}
disabled={!newFilterCondition}
/>
);
})()}
<Button
size="icon"
variant="ghost"
Expand Down
2 changes: 2 additions & 0 deletions src/components/features/query/query-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function QueryFilters({ availableColumns }: QueryFiltersProps) {
sorting,
onSortingChange,
applicants,
columnValueOptions,
} = useQuery();

const handleColumnsChange = (columns: string[]) => {
Expand Down Expand Up @@ -113,6 +114,7 @@ export function QueryFilters({ availableColumns }: QueryFiltersProps) {
<FilterRows
columns={columns}
columnTypes={columnTypes}
columnValueOptions={columnValueOptions}
filterSelections={filterSelections}
onAddFilter={onFilterAdd}
onRemoveFilter={onFilterRemove}
Expand Down
120 changes: 120 additions & 0 deletions src/components/ui/combobox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { Input } from "@/components/ui/input";
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { useEffect, useRef, useState } from "react";

interface ComboboxProps {
value: string;
onChange: (value: string) => void;
options: string[];
placeholder?: string;
disabled?: boolean;
className?: string;
}

export function Combobox({
value,
onChange,
options,
placeholder,
disabled,
className,
}: ComboboxProps) {
const [open, setOpen] = useState(false);
const [highlight, setHighlight] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(null);

const search = value.trim().toLowerCase();
const filtered = search ? options.filter((o) => o.toLowerCase().startsWith(search)) : options;
const showDropdown = open && filtered.length > 0;

// biome-ignore lint/correctness/useExhaustiveDependencies: reset highlight when the search term changes
useEffect(() => {
setHighlight(0);
}, [search]);

const pick = (item: string) => {
onChange(item);
setOpen(false);
inputRef.current?.focus();
};

const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!showDropdown) {
if (e.key === "ArrowDown" && filtered.length > 0) setOpen(true);
return;
}
if (e.key === "ArrowDown") {
e.preventDefault();
setHighlight((h) => (h + 1) % filtered.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setHighlight((h) => (h - 1 + filtered.length) % filtered.length);
} else if (e.key === "Enter") {
e.preventDefault();
if (filtered[highlight]) pick(filtered[highlight]);
} else if (e.key === "Escape") {
e.preventDefault();
setOpen(false);
}
};

return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverAnchor>
<Input
ref={inputRef}
disabled={disabled}
placeholder={placeholder}
value={value}
className={className}
onChange={(e) => {
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onClick={() => setOpen(true)}
onBlur={() => {
setTimeout(() => {
if (!inputRef.current?.contains(document.activeElement)) setOpen(false);
}, 0);
}}
onKeyDown={onKeyDown}
/>
</PopoverAnchor>
{filtered.length > 0 && (
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] p-1"
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
onPointerDownOutside={(e) => {
if (inputRef.current?.contains(e.target as Node)) e.preventDefault();
}}
onFocusOutside={(e) => {
if (inputRef.current?.contains(e.target as Node)) e.preventDefault();
}}
>
<div className="max-h-60 overflow-y-auto">
{filtered.map((opt, i) => (
<button
type="button"
key={opt}
onMouseDown={(e) => {
e.preventDefault();
pick(opt);
}}
onMouseEnter={() => setHighlight(i)}
className={cn(
"flex w-full cursor-default items-center rounded-sm px-2 py-1.5 text-left text-sm",
i === highlight ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
>
{opt}
</button>
))}
</div>
</PopoverContent>
)}
</Popover>
);
}
6 changes: 6 additions & 0 deletions src/providers/query-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
import type { FlattenedApplicant } from "@/services/query";
import {
calculateApplicantPoints,
extractColumnValues,
flattenApplicantData,
subscribeToApplicants,
} from "@/services/query";
Expand Down Expand Up @@ -138,6 +139,8 @@ interface QueryContextType {
// Final data after processing
tableData: FlattenedApplicant[];

columnValueOptions: Record<string, string[]>;

// Actions
onColumnToggle: (column: string) => void;
onGroupByChange: (selection: GroupBySelection | undefined) => void;
Expand Down Expand Up @@ -182,6 +185,8 @@ export function QueryProvider({ children }: QueryProviderProps) {
const [filterSelections, setFilterSelections] = useState<FilterRowsSelection[]>([]);
const [sorting, setSorting] = useState<SortingState>([]);

const columnValueOptions = useMemo(() => extractColumnValues(applicants), [applicants]);

const tableData = useMemo(() => {
let filtered = applicants;
if (filterSelections.length > 0) {
Expand Down Expand Up @@ -288,6 +293,7 @@ export function QueryProvider({ children }: QueryProviderProps) {
filterSelections,
sorting,
tableData,
columnValueOptions,
onColumnToggle: handleColumnToggle,
onGroupByChange: setGroupBySelection,
onFilterAdd: handleFilterAdd,
Expand Down
58 changes: 58 additions & 0 deletions src/services/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,64 @@ export interface FlattenedApplicant {
[key: string]: string | number | boolean | Date | null | Record<string, boolean> | undefined; // extra keys for group-by results
}

export const CATEGORICAL_COLUMNS: ReadonlySet<string> = new Set([
"applicationStatus",
"educationLevel",
"major",
"role",
"dietaryRestriction",
"culturalBackground",
"school",
"gender",
"countryOfResidence",
"academicYear",
"canadianStatus",
"disability",
"haveTransExperience",
"indigenousIdentification",
"jobPosition",
"travellingToHackathon",
"engagementSource",
"ageByHackathon",
"graduation",
]);

export const MULTI_VALUE_COLUMNS: ReadonlySet<string> = new Set([
"major",
"gender",
"dietaryRestriction",
"culturalBackground",
"role",
"engagementSource",
]);

export const extractColumnValues = (
applicants: FlattenedApplicant[],
): Record<string, string[]> => {
const result: Record<string, string[]> = {};
for (const column of CATEGORICAL_COLUMNS) {
const values = new Set<string>();
const isMultiValue = MULTI_VALUE_COLUMNS.has(column);
for (const applicant of applicants) {
const raw = applicant[column];
if (raw === null || raw === undefined) continue;
if (isMultiValue) {
for (const token of String(raw).split(",")) {
const trimmed = token.trim();
if (trimmed) values.add(trimmed);
}
} else {
const trimmed = String(raw).trim();
if (trimmed) values.add(trimmed);
}
}
result[column] = [...values].sort((a, b) =>
a.localeCompare(b, undefined, { sensitivity: "base" }),
);
}
return result;
};

/**
* Calculates all hackers' points from day-of events asynchronously
*
Expand Down
Loading