diff --git a/app/components/combobox-utlegg.tsx b/app/components/combobox-utlegg.tsx new file mode 100644 index 0000000..ae86ae3 --- /dev/null +++ b/app/components/combobox-utlegg.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/ui/command"; +import { Drawer, DrawerContent, DrawerTrigger } from "@/ui/drawer"; +import { Popover, PopoverContent, PopoverTrigger } from "@/ui/popover"; +import { useMediaQuery } from "@mantine/hooks"; +import { Component } from "lucide-react"; +import { useState } from "react"; + +type Item = { + value: string; + label: string; +}; + +type ComboBoxProps = { + items: Array; + value: string; + onChange?: (value: string) => void; + defaultItem?: Item; + className?: string; +}; + +export function ComboBoxResponsive({ + items, + value, + onChange, + defaultItem, + className, +}: ComboBoxProps) { + const [open, setOpen] = useState(false); + const isDesktop = useMediaQuery("(min-width: 768px)"); + const selectedItem = + items.find((item) => item.value === value) ?? defaultItem ?? null; + + function ItemList() { + return ( + + + + No results found. + + {items.map((item) => ( + { + onChange?.(val); + setOpen(false); + }} + > + {item.label} + + ))} + + + + ); + } + + if (isDesktop) { + return ( + + + + + + + + + ); + } + + return ( + + + + + +
+ +
+
+
+ ); +} diff --git a/app/components/combobox.tsx b/app/components/combobox.tsx index ee99082..752120e 100644 --- a/app/components/combobox.tsx +++ b/app/components/combobox.tsx @@ -80,7 +80,7 @@ export function ComboBoxResponsive({ className={cn("min-w-min justify-start", className)} > - {selectedItem ? <>{selectedItem.label} : <>+ Set item} + {selectedItem ? <>{selectedItem.label} : <> Set item} diff --git a/app/components/data-table-filter.tsx b/app/components/data-table-filter.tsx new file mode 100644 index 0000000..55bd5ad --- /dev/null +++ b/app/components/data-table-filter.tsx @@ -0,0 +1,1766 @@ +// This component is taken from https://ui.bazza.dev/docs/data-table-filter +// Biome linting rules are ignored in the config file + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calender"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverAnchor, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Slider } from "@/components/ui/slider"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useIsMobile } from "@/hooks/use-mobile"; +import { take, uniq } from "@/lib/array"; +import { cn } from "@/lib/utils"; +import type { Column, ColumnMeta, RowData, Table } from "@tanstack/react-table"; +import { format, isEqual } from "date-fns"; +import { ArrowRight, Ellipsis, Filter, FilterXIcon, X } from "lucide-react"; +import { + cloneElement, + isValidElement, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import type { DateRange } from "react-day-picker"; +import type { ColumnOption, ElementType } from "../lib/filters"; +import { + type ColumnDataType, + type FilterModel, + createNumberRange, + dateFilterDetails, + determineNewOperator, + filterTypeOperatorDetails, + getColumn, + getColumnMeta, + isColumnOptionArray, + isFilterableColumn, + multiOptionFilterDetails, + numberFilterDetails, + optionFilterDetails, + textFilterDetails, +} from "../lib/filters"; + +export function DataTableFilter({ + table, +}: { + table: Table; +}) { + const isMobile = useIsMobile(); + + if (isMobile) { + return ( +
+
+ + +
+ + + +
+ ); + } + + return ( +
+
+ + +
+ +
+ ); +} + +export function ActiveFiltersMobileContainer({ + children, +}: { + children: React.ReactNode; +}) { + const scrollContainerRef = useRef(null); + const [showLeftBlur, setShowLeftBlur] = useState(false); + const [showRightBlur, setShowRightBlur] = useState(true); + + // Check if there's content to scroll and update blur states + const checkScroll = () => { + if (scrollContainerRef.current) { + const { scrollLeft, scrollWidth, clientWidth } = + scrollContainerRef.current; + + // Show left blur if scrolled to the right + setShowLeftBlur(scrollLeft > 0); + + // Show right blur if there's more content to scroll to the right + // Add a small buffer (1px) to account for rounding errors + setShowRightBlur(scrollLeft + clientWidth < scrollWidth - 1); + } + }; + + // Log blur states for debugging + // useEffect(() => { + // console.log('left:', showLeftBlur, ' right:', showRightBlur) + // }, [showLeftBlur, showRightBlur]) + + // Set up ResizeObserver to monitor container size + useEffect(() => { + if (scrollContainerRef.current) { + const resizeObserver = new ResizeObserver(() => { + checkScroll(); + }); + resizeObserver.observe(scrollContainerRef.current); + return () => { + resizeObserver.disconnect(); + }; + } + }, []); + + // Update blur states when children change + useEffect(() => { + checkScroll(); + }, [children]); + + return ( +
+ {/* Left blur effect */} + {showLeftBlur && ( +
+ )} + + {/* Scrollable container */} +
+ {children} +
+ + {/* Right blur effect */} + {showRightBlur && ( +
+ )} +
+ ); +} + +export function FilterActions({ table }: { table: Table }) { + const hasFilters = table.getState().columnFilters.length > 0; + + function clearFilters() { + table.setColumnFilters([]); + table.setGlobalFilter(""); + } + + return ( + + ); +} + +export function FilterSelector({ table }: { table: Table }) { + const [open, setOpen] = useState(false); + const [value, setValue] = useState(""); + const [property, setProperty] = useState(undefined); + const inputRef = useRef(null); + + const column = property ? getColumn(table, property) : undefined; + const columnMeta = property ? getColumnMeta(table, property) : undefined; + + const properties = table.getAllColumns().filter(isFilterableColumn); + + const hasFilters = table.getState().columnFilters.length > 0; + + useEffect(() => { + if (property && inputRef) { + inputRef.current?.focus(); + setValue(""); + } + }, [property]); + + useEffect(() => { + if (!open) setTimeout(() => setValue(""), 150); + }, [open]); + + const content = useMemo( + () => + property && column && columnMeta ? ( + + ) : ( + + + No results. + + + {properties.map((column) => ( + + ))} + + + + ), + [property, column, columnMeta, value, table, properties] + ); + + return ( + { + setOpen(value); + if (!value) setTimeout(() => setProperty(undefined), 100); + }} + > + + + + + {content} + + + ); +} + +export function FilterableColumn({ + column, + setProperty, +}: { + column: Column; + table: Table; + setProperty: (value: string) => void; +}) { + const Icon = column.columnDef.meta?.icon!; + return ( + setProperty(column.id)} className="group"> +
+
+ {} + {column.columnDef.meta?.displayName} +
+ +
+
+ ); +} + +export function DebouncedInput({ + value: initialValue, + onChange, + debounce = 500, + ...props +}: { + value: string | number; + onChange: (value: string | number) => void; + debounce?: number; +} & Omit, "onChange">) { + const [value, setValue] = useState(initialValue); + + useEffect(() => { + setValue(initialValue); + }, [initialValue]); + + useEffect(() => { + const timeout = setTimeout(() => { + onChange(value); + }, debounce); + + return () => clearTimeout(timeout); + }, [value, onChange, debounce]); + + return ( + setValue(e.target.value)} + /> + ); +} + +export function ActiveFilters({ table }: { table: Table }) { + const filters = table.getState().columnFilters; + + return ( + <> + {filters.map((filter) => { + const { id } = filter; + + const column = getColumn(table, id); + const meta = getColumnMeta(table, id); + + // Skip if no filter value + if (!filter.value) return null; + + // Narrow the type based on meta.type and cast filter accordingly + switch (meta.type) { + case "text": + return renderFilter( + filter as { id: string; value: FilterModel<"text", TData> }, + column, + meta as ColumnMeta & { type: "text" }, + table + ); + case "number": + return renderFilter( + filter as { id: string; value: FilterModel<"number", TData> }, + column, + meta as ColumnMeta & { type: "number" }, + table + ); + case "date": + return renderFilter( + filter as { id: string; value: FilterModel<"date", TData> }, + column, + meta as ColumnMeta & { type: "date" }, + table + ); + case "option": + return renderFilter( + filter as { id: string; value: FilterModel<"option", TData> }, + column, + meta as ColumnMeta & { type: "option" }, + table + ); + case "multiOption": + return renderFilter( + filter as { + id: string; + value: FilterModel<"multiOption", TData>; + }, + column, + meta as ColumnMeta & { + type: "multiOption"; + }, + table + ); + default: + return null; // Handle unknown types gracefully + } + })} + + ); +} + +// Generic render function for a filter with type-safe value +function renderFilter( + filter: { id: string; value: FilterModel }, + column: Column, + meta: ColumnMeta & { type: T }, + table: Table +) { + const { value } = filter; + + return ( +
+ + + + /> + + + + +
+ ); +} + +/****** Property Filter Subject ******/ + +export function FilterSubject({ + meta, +}: { + meta: ColumnMeta; +}) { + const hasIcon = !!meta?.icon; + return ( + + {hasIcon && } + {meta.displayName} + + ); +} + +/****** Property Filter Operator ******/ + +// Renders the filter operator display and menu for a given column filter +// The filter operator display is the label and icon for the filter operator +// The filter operator menu is the dropdown menu for the filter operator +export function FilterOperator({ + column, + columnMeta, + filter, +}: { + column: Column; + columnMeta: ColumnMeta; + filter: FilterModel; +}) { + const [open, setOpen] = useState(false); + + const close = () => setOpen(false); + + return ( + + + + + + + + No results. + + + + + + + ); +} + +export function FilterOperatorDisplay({ + filter, + filterType, +}: { + filter: FilterModel; + filterType: T; +}) { + const details = filterTypeOperatorDetails[filterType][filter.operator]; + + return {details.label}; +} + +interface FilterOperatorControllerProps { + column: Column; + closeController: () => void; +} + +export function FilterOperatorController({ + column, + closeController, +}: FilterOperatorControllerProps) { + const { type } = column.columnDef.meta!; + + switch (type) { + case "option": + return ( + + ); + case "multiOption": + return ( + + ); + case "date": + return ( + + ); + case "text": + return ( + + ); + case "number": + return ( + + ); + default: + return null; + } +} + +function FilterOperatorOptionController({ + column, + closeController, +}: FilterOperatorControllerProps) { + const filter = column.getFilterValue() as FilterModel<"option", TData>; + const filterDetails = optionFilterDetails[filter.operator]; + + const relatedFilters = Object.values(optionFilterDetails).filter( + (o) => o.target === filterDetails.target + ); + + const changeOperator = (value: string) => { + column.setFilterValue((old: typeof filter) => ({ + ...old, + operator: value, + })); + closeController(); + }; + + return ( + + {relatedFilters.map((r) => { + return ( + + {r.label} + + ); + })} + + ); +} + +function FilterOperatorMultiOptionController({ + column, + closeController, +}: FilterOperatorControllerProps) { + const filter = column.getFilterValue() as FilterModel<"multiOption", TData>; + const filterDetails = multiOptionFilterDetails[filter.operator]; + + const relatedFilters = Object.values(multiOptionFilterDetails).filter( + (o) => o.target === filterDetails.target + ); + + const changeOperator = (value: string) => { + column.setFilterValue((old: typeof filter) => ({ + ...old, + operator: value, + })); + closeController(); + }; + + return ( + + {relatedFilters.map((r) => { + return ( + + {r.label} + + ); + })} + + ); +} + +function FilterOperatorDateController({ + column, + closeController, +}: FilterOperatorControllerProps) { + const filter = column.getFilterValue() as FilterModel<"date", TData>; + const filterDetails = dateFilterDetails[filter.operator]; + + const relatedFilters = Object.values(dateFilterDetails).filter( + (o) => o.target === filterDetails.target + ); + + const changeOperator = (value: string) => { + column.setFilterValue((old: typeof filter) => ({ + ...old, + operator: value, + })); + closeController(); + }; + + return ( + + {relatedFilters.map((r) => { + return ( + + {r.label} + + ); + })} + + ); +} + +export function FilterOperatorTextController({ + column, + closeController, +}: FilterOperatorControllerProps) { + const filter = column.getFilterValue() as FilterModel<"text", TData>; + const filterDetails = textFilterDetails[filter.operator]; + + const relatedFilters = Object.values(textFilterDetails).filter( + (o) => o.target === filterDetails.target + ); + + const changeOperator = (value: string) => { + column.setFilterValue((old: typeof filter) => ({ + ...old, + operator: value, + })); + closeController(); + }; + + return ( + + {relatedFilters.map((r) => { + return ( + + {r.label} + + ); + })} + + ); +} + +function FilterOperatorNumberController({ + column, + closeController, +}: FilterOperatorControllerProps) { + const filter = column.getFilterValue() as FilterModel<"number", TData>; + + // Show all related operators + const relatedFilters = Object.values(numberFilterDetails); + const relatedFilterOperators = relatedFilters.map((r) => r.value); + + const changeOperator = (value: (typeof relatedFilterOperators)[number]) => { + column.setFilterValue((old: typeof filter) => { + // Clear out the second value when switching to single-input operators + const target = numberFilterDetails[value].target; + + const newValues = + target === "single" ? [old.values[0]] : createNumberRange(old.values); + + return { ...old, operator: value, values: newValues }; + }); + closeController(); + }; + + return ( +
+ + {relatedFilters.map((r) => ( + changeOperator(r.value)} + value={r.value} + key={r.value} + > + {r.label} {/**/} + + ))} + +
+ ); +} + +/****** Property Filter Value ******/ + +export function FilterValue({ + id, + column, + columnMeta, + table, +}: { + id: string; + column: Column; + columnMeta: ColumnMeta; + table: Table; +}) { + return ( + + + + + + + + + + ); +} + +interface FilterValueDisplayProps { + id: string; + column: Column; + columnMeta: ColumnMeta; + table: Table; +} + +export function FilterValueDisplay({ + id, + column, + columnMeta, + table, +}: FilterValueDisplayProps) { + switch (columnMeta.type) { + case "option": + return ( + + ); + case "multiOption": + return ( + + ); + case "date": + return ( + + ); + case "text": + return ( + + ); + case "number": + return ( + + ); + default: + return null; + } +} + +export function FilterValueOptionDisplay({ + id, + column, + columnMeta, + table, +}: FilterValueDisplayProps) { + let options: ColumnOption[]; + const columnVals = table + .getCoreRowModel() + .rows.flatMap((r) => r.getValue(id)) + .filter((v): v is NonNullable => v !== undefined && v !== null); + const uniqueVals = uniq(columnVals); + + // If static options are provided, use them + if (columnMeta.options) { + options = columnMeta.options; + } + + // No static options provided, + // We should dynamically generate them based on the column data + else if (columnMeta.transformOptionFn) { + const transformOptionFn = columnMeta.transformOptionFn; + + options = uniqueVals.map((v) => + transformOptionFn(v as ElementType>) + ); + } + + // Make sure the column data conforms to ColumnOption type + else if (isColumnOptionArray(uniqueVals)) { + options = uniqueVals; + } + + // Invalid configuration + else { + throw new Error( + `[data-table-filter] [${id}] Either provide static options, a transformOptionFn, or ensure the column data conforms to ColumnOption type` + ); + } + + const filter = column.getFilterValue() as FilterModel<"option", TData>; + const selected = options.filter((o) => filter?.values.includes(o.value)); + + // We display the selected options based on how many are selected + // + // If there is only one option selected, we display its icon and label + // + // If there are multiple options selected, we display: + // 1) up to 3 icons of the selected options + // 2) the number of selected options + if (selected.length === 1) { + const { label, icon: Icon } = selected[0]; + const hasIcon = !!Icon; + return ( + + {hasIcon && + (isValidElement(Icon) ? ( + Icon + ) : ( + + ))} + {label} + + ); + } + const name = columnMeta.displayName.toLowerCase(); + const pluralName = name.endsWith("s") ? `${name}es` : `${name}s`; + + const hasOptionIcons = !options?.some((o) => !o.icon); + + return ( +
+ {hasOptionIcons && + take(selected, 3).map(({ value, icon }) => { + const Icon = icon!; + return isValidElement(Icon) ? ( + Icon + ) : ( + + ); + })} + + {selected.length} {pluralName} + +
+ ); +} + +export function FilterValueMultiOptionDisplay({ + id, + column, + columnMeta, + table, +}: FilterValueDisplayProps) { + let options: ColumnOption[]; + const columnVals = table + .getCoreRowModel() + .rows.flatMap((r) => r.getValue(id)) + .filter((v): v is NonNullable => v !== undefined && v !== null); + const uniqueVals = uniq(columnVals); + + // If static options are provided, use them + if (columnMeta.options) { + options = columnMeta.options; + } + + // No static options provided, + // We should dynamically generate them based on the column data + else if (columnMeta.transformOptionFn) { + const transformOptionFn = columnMeta.transformOptionFn; + + options = uniqueVals.map((v) => + transformOptionFn(v as ElementType>) + ); + } + + // Make sure the column data conforms to ColumnOption type + else if (isColumnOptionArray(uniqueVals)) { + options = uniqueVals; + } + + // Invalid configuration + else { + throw new Error( + `[data-table-filter] [${id}] Either provide static options, a transformOptionFn, or ensure the column data conforms to ColumnOption type` + ); + } + + const filter = column.getFilterValue() as FilterModel<"multiOption", TData>; + const selected = options.filter((o) => filter?.values[0].includes(o.value)); + + if (selected.length === 1) { + const { label, icon: Icon } = selected[0]; + const hasIcon = !!Icon; + return ( + + {hasIcon && + (isValidElement(Icon) ? ( + Icon + ) : ( + + ))} + + {label} + + ); + } + + const name = columnMeta.displayName.toLowerCase(); + + const hasOptionIcons = !columnMeta.options?.some((o) => !o.icon); + + return ( +
+ {hasOptionIcons && ( +
+ {take(selected, 3).map(({ value, icon }) => { + const Icon = icon!; + return isValidElement(Icon) ? ( + cloneElement(Icon, { key: value }) + ) : ( + + ); + })} +
+ )} + + {selected.length} {name} + +
+ ); +} + +function formatDateRange(start: Date, end: Date) { + const sameMonth = start.getMonth() === end.getMonth(); + const sameYear = start.getFullYear() === end.getFullYear(); + + if (sameMonth && sameYear) { + return `${format(start, "MMM d")} - ${format(end, "d, yyyy")}`; + } + + if (sameYear) { + return `${format(start, "MMM d")} - ${format(end, "MMM d, yyyy")}`; + } + + return `${format(start, "MMM d, yyyy")} - ${format(end, "MMM d, yyyy")}`; +} + +export function FilterValueDateDisplay({ + column, +}: FilterValueDisplayProps) { + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"date", TData>) + : undefined; + + if (!filter) return null; + if (filter.values.length === 0) return ; + if (filter.values.length === 1) { + const value = filter.values[0]; + + const formattedDateStr = format(value, "MMM d, yyyy"); + + return {formattedDateStr}; + } + + const formattedRangeStr = formatDateRange(filter.values[0], filter.values[1]); + + return {formattedRangeStr}; +} + +export function FilterValueTextDisplay({ + column, +}: FilterValueDisplayProps) { + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"text", TData>) + : undefined; + + if (!filter) return null; + if (filter.values.length === 0 || filter.values[0].trim() === "") + return ; + + const value = filter.values[0]; + + return {value}; +} + +export function FilterValueNumberDisplay({ + column, + columnMeta, +}: FilterValueDisplayProps) { + const maxFromMeta = columnMeta.max; + const cappedMax = maxFromMeta ?? 20000; + + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"number", TData>) + : undefined; + + if (!filter) return null; + + if ( + filter.operator === "is between" || + filter.operator === "is not between" + ) { + const minValue = filter.values[0]; + const maxValue = + filter.values[1] === Number.POSITIVE_INFINITY || + filter.values[1] >= cappedMax + ? `${cappedMax}+` + : filter.values[1]; + + return ( + + {minValue} and {maxValue} + + ); + } + + if (!filter.values || filter.values.length === 0) { + return null; + } + + const value = filter.values[0]; + return {value}; +} + +export function FitlerValueController({ + id, + column, + columnMeta, + table, +}: { + id: string; + column: Column; + columnMeta: ColumnMeta; + table: Table; +}) { + switch (columnMeta.type) { + case "option": + return ( + + ); + case "multiOption": + return ( + + ); + case "date": + return ( + + ); + case "text": + return ( + + ); + case "number": + return ( + + ); + default: + return null; + } +} + +interface ProperFilterValueMenuProps { + id: string; + column: Column; + columnMeta: ColumnMeta; + table: Table; +} + +export function FilterValueOptionController({ + id, + column, + columnMeta, + table, +}: ProperFilterValueMenuProps) { + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"option", TData>) + : undefined; + + let options: ColumnOption[]; + const columnVals = table + .getCoreRowModel() + .rows.flatMap((r) => r.getValue(id)) + .filter((v): v is NonNullable => v !== undefined && v !== null); + const uniqueVals = uniq(columnVals); + + // If static options are provided, use them + if (columnMeta.options) { + options = columnMeta.options; + } + + // No static options provided, + // We should dynamically generate them based on the column data + else if (columnMeta.transformOptionFn) { + const transformOptionFn = columnMeta.transformOptionFn; + + options = uniqueVals.map((v) => + transformOptionFn(v as ElementType>) + ); + } + + // Make sure the column data conforms to ColumnOption type + else if (isColumnOptionArray(uniqueVals)) { + options = uniqueVals; + } + + // Invalid configuration + else { + throw new Error( + `[data-table-filter] [${id}] Either provide static options, a transformOptionFn, or ensure the column data conforms to ColumnOption type` + ); + } + + const optionsCount: Record = columnVals.reduce( + (acc, curr) => { + const { value } = columnMeta.transformOptionFn + ? columnMeta.transformOptionFn(curr as ElementType>) + : { value: curr as string }; + + acc[value] = (acc[value] ?? 0) + 1; + return acc; + }, + {} as Record + ); + + function handleOptionSelect(value: string, check: boolean) { + if (check) + column?.setFilterValue( + (old: undefined | FilterModel<"option", TData>) => { + if (!old || old.values.length === 0) + return { + operator: "is", + values: [value], + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"option", TData>; + + const newValues = [...old.values, value]; + + return { + operator: "is any of", + values: newValues, + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"option", TData>; + } + ); + else + column?.setFilterValue( + (old: undefined | FilterModel<"option", TData>) => { + if (!old || old.values.length <= 1) return undefined; + + const newValues = old.values.filter((v) => v !== value); + return { + operator: newValues.length > 1 ? "is any of" : "is", + values: newValues, + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"option", TData>; + } + ); + } + + return ( + + + No results. + + + {options.map((v) => { + const checked = Boolean(filter?.values.includes(v.value)); + const count = optionsCount[v.value] ?? 0; + + return ( + { + handleOptionSelect(v.value, !checked); + }} + className="group flex items-center justify-between gap-1.5" + > +
+ + {v.icon && + (isValidElement(v.icon) ? ( + v.icon + ) : ( + + ))} + + {v.label} + + {count < 100 ? count : "100+"} + + +
+
+ ); + })} +
+
+
+ ); +} + +export function FilterValueMultiOptionController< + TData extends RowData, + TValue +>({ + id, + column, + columnMeta, + table, +}: ProperFilterValueMenuProps) { + const filter = column.getFilterValue() as + | FilterModel<"multiOption", TData> + | undefined; + + let options: ColumnOption[]; + const columnVals = table + .getCoreRowModel() + .rows.flatMap((r) => r.getValue(id)) + .filter((v): v is NonNullable => v !== undefined && v !== null); + const uniqueVals = uniq(columnVals); + + // If static options are provided, use them + if (columnMeta.options) { + options = columnMeta.options; + } + + // No static options provided, + // We should dynamically generate them based on the column data + else if (columnMeta.transformOptionFn) { + const transformOptionFn = columnMeta.transformOptionFn; + + options = uniqueVals.map((v) => + transformOptionFn(v as ElementType>) + ); + } + + // Make sure the column data conforms to ColumnOption type + else if (isColumnOptionArray(uniqueVals)) { + options = uniqueVals; + } + + // Invalid configuration + else { + throw new Error( + `[data-table-filter] [${id}] Either provide static options, a transformOptionFn, or ensure the column data conforms to ColumnOption type` + ); + } + + const optionsCount: Record = columnVals.reduce( + (acc, curr) => { + const value = columnMeta.options + ? (curr as string) + : columnMeta.transformOptionFn!( + curr as ElementType> + ).value; + + acc[value] = (acc[value] ?? 0) + 1; + return acc; + }, + {} as Record + ); + + // Handles the selection/deselection of an option + function handleOptionSelect(value: string, check: boolean) { + if (check) { + column.setFilterValue( + (old: undefined | FilterModel<"multiOption", TData>) => { + if ( + !old || + old.values.length === 0 || + !old.values[0] || + old.values[0].length === 0 + ) + return { + operator: "include", + values: [[value]], + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"multiOption", TData>; + + const newValues = [uniq([...old.values[0], value])]; + + return { + operator: determineNewOperator( + "multiOption", + old.values, + newValues, + old.operator + ), + values: newValues, + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"multiOption", TData>; + } + ); + } else + column.setFilterValue( + (old: undefined | FilterModel<"multiOption", TData>) => { + if (!old?.values[0] || old.values[0].length <= 1) return undefined; + + const newValues = [ + uniq([...old.values[0], value]).filter((v) => v !== value), + ]; + + return { + operator: determineNewOperator( + "multiOption", + old.values, + newValues, + old.operator + ), + values: newValues, + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"multiOption", TData>; + } + ); + } + + return ( + + + No results. + + + {options.map((v) => { + const checked = Boolean(filter?.values[0]?.includes(v.value)); + const count = optionsCount[v.value] ?? 0; + + return ( + { + handleOptionSelect(v.value, !checked); + }} + className="group flex items-center justify-between gap-1.5" + > +
+ + {v.icon && + (isValidElement(v.icon) ? ( + v.icon + ) : ( + + ))} + + {v.label} + + {count < 100 ? count : "100+"} + + +
+
+ ); + })} +
+
+
+ ); +} + +export function FilterValueDateController({ + column, +}: ProperFilterValueMenuProps) { + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"date", TData>) + : undefined; + + const [date, setDate] = useState({ + from: filter?.values[0] ?? new Date(), + to: filter?.values[1] ?? undefined, + }); + + function changeDateRange(value: DateRange | undefined) { + const start = value?.from; + const end = + start && value && value.to && !isEqual(start, value.to) + ? value.to + : undefined; + + setDate({ from: start, to: end }); + + const isRange = start && end; + + const newValues = isRange ? [start, end] : start ? [start] : []; + + column.setFilterValue((old: undefined | FilterModel<"date", TData>) => { + if (!old || old.values.length === 0) + return { + operator: newValues.length > 1 ? "is between" : "is", + values: newValues, + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"date", TData>; + + return { + operator: + old.values.length < newValues.length + ? "is between" + : old.values.length > newValues.length + ? "is" + : old.operator, + values: newValues, + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"date", TData>; + }); + } + + return ( + + {/* */} + {/* No results. */} + + +
+ +
+
+
+
+ ); +} + +export function FilterValueTextController({ + column, +}: ProperFilterValueMenuProps) { + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"text", TData>) + : undefined; + + const changeText = (value: string | number) => { + column.setFilterValue((old: undefined | FilterModel<"text", TData>) => { + if (!old || old.values.length === 0) + return { + operator: "contains", + values: [String(value)], + columnMeta: column.columnDef.meta, + } satisfies FilterModel<"text", TData>; + return { operator: old.operator, values: [String(value)] }; + }); + }; + + return ( + + + + + + + + + + ); +} + +export function FilterValueNumberController({ + table, + column, + columnMeta, +}: ProperFilterValueMenuProps) { + const maxFromMeta = columnMeta.max; + /* const cappedMax = maxFromMeta ?? Number.MAX_SAFE_INTEGER; + */ + const cappedMax = maxFromMeta ?? 30000; + + const filter = column.getFilterValue() + ? (column.getFilterValue() as FilterModel<"number", TData>) + : undefined; + + const isNumberRange = + !!filter && numberFilterDetails[filter.operator].target === "multiple"; + + const [datasetMin] = column.getFacetedMinMaxValues() ?? [0, 0]; + + const initialValues = () => { + if (filter?.values) { + return filter.values.map((val) => + val >= cappedMax ? `${cappedMax}+` : val.toString() + ); + } + return [datasetMin.toString()]; + }; + + const [inputValues, setInputValues] = useState(initialValues); + + const changeNumber = (value: number[]) => { + const sortedValues = [...value].sort((a, b) => a - b); + + column.setFilterValue((old: undefined | FilterModel<"number", TData>) => { + if (!old || old.values.length === 0) { + return { + operator: "is", + values: sortedValues, + }; + } + + const operator = numberFilterDetails[old.operator]; + let newValues: number[]; + + if (operator.target === "single") { + newValues = [sortedValues[0]]; + } else { + newValues = [ + sortedValues[0] >= cappedMax ? cappedMax : sortedValues[0], + sortedValues[1] >= cappedMax + ? Number.POSITIVE_INFINITY + : sortedValues[1], + ]; + } + + return { + operator: old.operator, + values: newValues, + }; + }); + }; + + const handleInputChange = (index: number, value: string) => { + const newValues = [...inputValues]; + if (isNumberRange && Number.parseInt(value, 10) >= cappedMax) { + newValues[index] = `${cappedMax}+`; + } else { + newValues[index] = value; + } + + setInputValues(newValues); + + const parsedValues = newValues.map((val) => { + if (val.trim() === "") return 0; + if (val === `${cappedMax}+`) return cappedMax; + return Number.parseInt(val, 10); + }); + + changeNumber(parsedValues); + }; + + const changeType = (type: "single" | "range") => { + column.setFilterValue((old: undefined | FilterModel<"number", TData>) => { + if (type === "single") { + return { + operator: "is", + values: [old?.values[0] ?? 0], + }; + } + const newMaxValue = old?.values[0] ?? cappedMax; + return { + operator: "is between", + values: [0, newMaxValue], + }; + }); + + if (type === "single") { + setInputValues([inputValues[0]]); + } else { + const maxValue = inputValues[0] || cappedMax.toString(); + setInputValues(["0", maxValue]); + } + }; + + const slider = { + value: inputValues.map((val) => + val === "" || val === `${cappedMax}+` + ? cappedMax + : Number.parseInt(val, 10) + ), + onValueChange: (value: number[]) => { + const values = value.map((val) => (val >= cappedMax ? cappedMax : val)); + setInputValues( + values.map((v) => (v >= cappedMax ? `${cappedMax}+` : v.toString())) + ); + changeNumber(values); + }, + }; + + return ( + + + +
+ + changeType(v === "range" ? "range" : "single") + } + > + + Single + Range + + + { + handleInputChange(0, value[0].toString()); + }} + min={datasetMin} + max={cappedMax} + step={1} + aria-orientation="horizontal" + /> +
+ Value + handleInputChange(0, e.target.value)} + max={cappedMax} + /> +
+
+ + +
+
+ Min + handleInputChange(0, e.target.value)} + max={cappedMax} + /> +
+
+ Max + handleInputChange(1, e.target.value)} + max={cappedMax} + /> +
+
+
+
+
+
+
+
+ ); +} diff --git a/app/components/data-table-utlegg.tsx b/app/components/data-table-utlegg.tsx new file mode 100644 index 0000000..b67888a --- /dev/null +++ b/app/components/data-table-utlegg.tsx @@ -0,0 +1,149 @@ +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import type { + ColumnDef, + ColumnFiltersState, + VisibilityState, +} from "@tanstack/react-table"; +import { + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { useState } from "react"; +import { DataTableFilter } from "./data-table-filter"; +import { DataTablePagination } from "./data-table-pagination"; + +interface DataTableProps { + columns: Array>; + data: Array; +} + +export function DataTable({ + columns, + data, +}: DataTableProps) { + const [columnFilters, setColumnFilters] = useState([]); + + const [columnVisibility, setColumnVisibility] = useState({ + id: false, + region: false, + }); + const [rowSelection, setRowSelection] = useState({}); + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + columnFilters, + columnVisibility, + rowSelection, + }, + }); + + return ( +
+
+ + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ); + })} + + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ); + })} + + ))} + + + {table.getRowModel().rows?.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ +
+ ); +} diff --git a/app/components/ui/accordion.tsx b/app/components/ui/accordion.tsx new file mode 100644 index 0000000..d360b34 --- /dev/null +++ b/app/components/ui/accordion.tsx @@ -0,0 +1,57 @@ +"use client"; + +import * as AccordionPrimitive from "@radix-ui/react-accordion"; +import { ChevronDown } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +const Accordion = AccordionPrimitive.Root; + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +AccordionItem.displayName = "AccordionItem"; + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger }; diff --git a/app/components/ui/calender.tsx b/app/components/ui/calender.tsx new file mode 100644 index 0000000..21e2dcf --- /dev/null +++ b/app/components/ui/calender.tsx @@ -0,0 +1,74 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; +import * as React from "react"; +import { DayPicker } from "react-day-picker"; + +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export type CalendarProps = React.ComponentProps; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: CalendarProps) { + return ( + .day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md" + : "[&:has([aria-selected])]:rounded-md" + ), + day: cn( + buttonVariants({ variant: "ghost" }), + "h-8 w-8 p-0 font-normal aria-selected:opacity-100" + ), + day_range_start: "day-range-start", + day_range_end: "day-range-end", + day_selected: + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground", + day_today: "bg-accent text-accent-foreground", + day_outside: + "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground", + day_disabled: "text-muted-foreground opacity-50", + day_range_middle: + "aria-selected:bg-accent aria-selected:text-accent-foreground", + day_hidden: "invisible", + ...classNames, + }} + components={{ + IconLeft: ({ className, ...props }) => ( + + ), + IconRight: ({ className, ...props }) => ( + + ), + }} + {...props} + /> + ); +} +Calendar.displayName = "Calendar"; + +export { Calendar }; diff --git a/app/components/ui/combobox.tsx b/app/components/ui/combobox.tsx new file mode 100644 index 0000000..a28ddc2 --- /dev/null +++ b/app/components/ui/combobox.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Check, ChevronsUpDown } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +const frameworks = [ + { + value: "next.js", + label: "Next.js", + }, + { + value: "sveltekit", + label: "SvelteKit", + }, + { + value: "nuxt.js", + label: "Nuxt.js", + }, + { + value: "remix", + label: "Remix", + }, + { + value: "astro", + label: "Astro", + }, +]; + +export function ComboboxDemo() { + const [open, setOpen] = React.useState(false); + const [value, setValue] = React.useState(""); + + return ( + + + + + + + + + No framework found. + + {frameworks.map((framework) => ( + { + setValue(currentValue === value ? "" : currentValue); + setOpen(false); + }} + > + {framework.label} + + + ))} + + + + + + ); +} diff --git a/app/components/ui/form.tsx b/app/components/ui/form.tsx index 78fc2d6..e076b3e 100644 --- a/app/components/ui/form.tsx +++ b/app/components/ui/form.tsx @@ -1,6 +1,6 @@ -import * as React from "react" -import * as LabelPrimitive from "@radix-ui/react-label" -import { Slot } from "@radix-ui/react-slot" +import * as LabelPrimitive from "@radix-ui/react-label"; +import { Slot } from "@radix-ui/react-slot"; +import * as React from "react"; import { Controller, FormProvider, @@ -9,27 +9,27 @@ import { type ControllerProps, type FieldPath, type FieldValues, -} from "react-hook-form" +} from "react-hook-form"; -import { cn } from "@/lib/utils" -import { Label } from "@/components/ui/label" +import { Label } from "@/components/ui/label"; +import { cn } from "@/lib/utils"; -const Form = FormProvider +const Form = FormProvider; type FormFieldContextValue< TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, + TName extends FieldPath = FieldPath > = { - name: TName -} + name: TName; +}; const FormFieldContext = React.createContext( {} as FormFieldContextValue -) +); const FormField = < TFieldValues extends FieldValues = FieldValues, - TName extends FieldPath = FieldPath, + TName extends FieldPath = FieldPath >({ ...props }: ControllerProps) => { @@ -37,21 +37,21 @@ const FormField = < - ) -} + ); +}; const useFormField = () => { - const fieldContext = React.useContext(FormFieldContext) - const itemContext = React.useContext(FormItemContext) - const { getFieldState } = useFormContext() - const formState = useFormState({ name: fieldContext.name }) - const fieldState = getFieldState(fieldContext.name, formState) + const fieldContext = React.useContext(FormFieldContext); + const itemContext = React.useContext(FormItemContext); + const { getFieldState } = useFormContext(); + const formState = useFormState({ name: fieldContext.name }); + const fieldState = getFieldState(fieldContext.name, formState); if (!fieldContext) { - throw new Error("useFormField should be used within ") + throw new Error("useFormField should be used within "); } - const { id } = itemContext + const { id } = itemContext; return { id, @@ -60,19 +60,19 @@ const useFormField = () => { formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, ...fieldState, - } -} + }; +}; type FormItemContextValue = { - id: string -} + id: string; +}; const FormItemContext = React.createContext( {} as FormItemContextValue -) +); function FormItem({ className, ...props }: React.ComponentProps<"div">) { - const id = React.useId() + const id = React.useId(); return ( @@ -82,28 +82,27 @@ function FormItem({ className, ...props }: React.ComponentProps<"div">) { {...props} /> - ) + ); } function FormLabel({ className, ...props }: React.ComponentProps) { - const { error, formItemId } = useFormField() + const { error } = useFormField(); return (