diff --git a/code/src/ui/graphic/table/action/numeric-column-filter.tsx b/code/src/ui/graphic/table/action/numeric-column-filter.tsx new file mode 100644 index 000000000..a09f4cbf6 --- /dev/null +++ b/code/src/ui/graphic/table/action/numeric-column-filter.tsx @@ -0,0 +1,228 @@ +import { useState } from "react"; +import Button from "ui/interaction/button"; +import SimpleSelector from "ui/interaction/dropdown/simple-selector"; +import { SelectOptionType } from "ui/interaction/dropdown/simple-selector"; +import { Icon } from "@mui/material"; + +interface NumericColumnFilterProps { + options: string[]; + label: string; + onSubmission: (_options: string[]) => void; +} + +type ComparisonOperator = | "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "between" + +type LogicOperator = "and" | "or"; + +const operators: { value: ComparisonOperator; label: string; }[] = [ + { value: "eq", label: "Equals" }, + { value: "neq", label: "Does not equal" }, + { value: "gt", label: "Greater than" }, + { value: "gte", label: "Greater than or equal to" }, + { value: "lt", label: "Less than" }, + { value: "lte", label: "Less than or equal to" }, + { value: "between", label: "Between" }, +] + +/** + * Evaluates a single numeric comparison condition. + */ +function matchesCondition(value: number, operator: ComparisonOperator, target: number, target2?: number): boolean { + switch (operator) { + case "eq": return value === target; + case "neq": return value !== target; + case "gt": return value > target; + case "gte": return value >= target; + case "lt": return value < target; + case "lte": return value <= target; + case "between": return target2 !== undefined && value >= target && value <= target2; + } +} + +/** + * A numeric column filter component that allows filtering table data using one or two + * numeric comparison conditions combined with AND/OR logic. + * + * @param {string[]} options The available column values to filter against. + * @param {string} label The name of the column. + * @param {void} onSubmission Function that submits the filtered options. + */ +export default function NumericColumnFilter(props: Readonly) { + const [value1, setValue1] = useState(null); + const [value2, setValue2] = useState(null); + const [value3, setValue3] = useState(null); + const [selectedOperator1, setSelectedOperator1] = useState("eq"); + const [selectedOperator2, setSelectedOperator2] = useState("eq"); + const [logicOperator, setLogicOperator] = useState("and"); + + const hasFirstValue: boolean = value1 !== null && !Number.isNaN(value1); + const hasSecondValue: boolean = value2 !== null && !Number.isNaN(value2); + const hasThirdValue: boolean = value3 !== null && !Number.isNaN(value3); + const isBetweenFirst: boolean = selectedOperator1 === "between"; + const isBetweenSecond: boolean = selectedOperator2 === "between"; + + const handleFilter = (): void => { + if (!hasFirstValue) return; + + const filtered: string[] = props.options.filter((option) => { + const numericValue: number = Number(option); + if (Number.isNaN(numericValue)) return false; + + if (isBetweenFirst) { + if (!hasSecondValue) return false; + return matchesCondition(numericValue, "between", value1!, value2!); + } + + const condition1: boolean = matchesCondition(numericValue, selectedOperator1, value1!); + + if (!hasSecondValue) return condition1; + + if (isBetweenSecond) { + if (!hasThirdValue) return false; + const condition2Between: boolean = matchesCondition(numericValue, "between", value2!, value3!); + return logicOperator === "and" ? condition1 && condition2Between : condition1 || condition2Between; + } + + const condition2: boolean = matchesCondition(numericValue, selectedOperator2, value2!); + + return logicOperator === "and" ? condition1 && condition2 : condition1 || condition2; + }); + + props.onSubmission(filtered); + } + + const blockInvalidNumberKeys = (e: React.KeyboardEvent) => { + if (["e", "E", "+", "-"].includes(e.key)) { + e.preventDefault(); + } + }; + + return ( +
+ { + if (selected) { + setSelectedOperator1((selected as SelectOptionType).value as ComparisonOperator); + } + }} + /> +
+ + search + + { + const value = e.currentTarget.valueAsNumber; + setValue1(Number.isNaN(value) ? null : value); + }} + /> +
+ {hasFirstValue && !isBetweenFirst && ( + <> + {/* AND / OR radio toggle */} +
+ + +
+ { + if (selected) { + setSelectedOperator2((selected as SelectOptionType).value as ComparisonOperator); + } + }} + /> + + )} + {(hasFirstValue || isBetweenFirst || isBetweenSecond) && +
+ + search + + { + const value = e.currentTarget.valueAsNumber; + setValue2(Number.isNaN(value) ? null : value); + }} + /> +
+ } + {!isBetweenFirst && isBetweenSecond && ( +
+ + search + + { + const value = e.currentTarget.valueAsNumber; + setValue3(Number.isNaN(value) ? null : value); + }} + /> +
+ )} + +
+ ); +} \ No newline at end of file diff --git a/code/src/ui/graphic/table/cell/header-cell.tsx b/code/src/ui/graphic/table/cell/header-cell.tsx index 8d6c8e709..f86565a45 100644 --- a/code/src/ui/graphic/table/cell/header-cell.tsx +++ b/code/src/ui/graphic/table/cell/header-cell.tsx @@ -12,6 +12,7 @@ import PopoverActionButton from "ui/interaction/action/popover/popover-button"; import SearchSelector from "ui/interaction/dropdown/search-selector"; import Tooltip from "ui/interaction/tooltip/tooltip"; import TableCell from "./table-cell"; +import NumericColumnFilter from "../action/numeric-column-filter"; interface HeaderCellProps { type: string; @@ -58,6 +59,8 @@ export default function HeaderCell(props: Readonly) { props.filters, ); + const showNumericFilter: boolean = options.length > 0 && options.every((option) => option.trim() !== "" && !Number.isNaN(Number(option))); + return ( ) { setShowFilterDropdown(!showFilterDropdown); }} > - { - props.header.column.setFilterValue(selectedOptions); - props.table.resetRowSelection(); - props.table.resetPageIndex(); - }} - setSearchString={setSearch} - /> + + {showNumericFilter ? ( + { + props.header.column.setFilterValue(selectedOptions); + props.table.resetRowSelection(); + props.table.resetPageIndex(); + }} + /> + ) : ( + { + props.header.column.setFilterValue(selectedOptions); + props.table.resetRowSelection(); + props.table.resetPageIndex(); + }} + setSearchString={setSearch} + /> + )} {isLoading && } }