diff --git a/src/components/Filter.tsx b/src/components/Filter.tsx index 4b78cb7..d20cc56 100644 --- a/src/components/Filter.tsx +++ b/src/components/Filter.tsx @@ -5,6 +5,7 @@ import config from '../config'; import { useFilter } from './contexts/FilterProvider'; import DateRange from './filters/DateRange'; import Purpose from './filters/Purpose'; +import SpeciesLength from './filters/SpeciesLength'; import { useMap } from './hooks'; import { getStationQuery } from './queryHelpers'; @@ -51,19 +52,7 @@ export default function Filter(): JSX.Element {
-

Species and length

-
-
- -
-
- - -
-
- -
-
+

Water body

diff --git a/src/components/contexts/FilterProvider.tsx b/src/components/contexts/FilterProvider.tsx index ffcdd57..2ee0265 100644 --- a/src/components/contexts/FilterProvider.tsx +++ b/src/components/contexts/FilterProvider.tsx @@ -6,7 +6,7 @@ export type QueryInfo = { table: string; }; type FilterState = Record; -type FilterKeys = 'purpose' | 'date'; +type FilterKeys = 'purpose' | 'date' | 'speciesLength'; type Action = | { type: 'UPDATE_TABLE'; diff --git a/src/components/filters/DateRange.tsx b/src/components/filters/DateRange.tsx index 3deb603..fd91cba 100644 --- a/src/components/filters/DateRange.tsx +++ b/src/components/filters/DateRange.tsx @@ -42,7 +42,7 @@ export default function DateRange() {
onChange(newDate, 'from')} @@ -50,7 +50,7 @@ export default function DateRange() { /> onChange(newDate, 'to')} diff --git a/src/components/filters/Purpose.tsx b/src/components/filters/Purpose.tsx index 2ffc85c..ea9b21f 100644 --- a/src/components/filters/Purpose.tsx +++ b/src/components/filters/Purpose.tsx @@ -1,37 +1,13 @@ import { useQuery } from '@tanstack/react-query'; import { Button, Checkbox, CheckboxGroup } from '@ugrc/utah-design-system'; -import ky from 'ky'; import { useEffect, useState } from 'react'; import config from '../../config'; import { useFilter } from '../contexts/FilterProvider'; - -type DomainValue = { - name: string; - code: string; -}; -type Field = { - name: string; - domain: { - codedValues: DomainValue[]; - }; -}; -type FeatureLayerDefinition = { - fields: Field[]; -}; +import { DomainValue } from './filters.types'; +import { getDomainValues } from './utilities'; async function getPurposes(): Promise { - // TODO: this should probably come from env var - // if we do end up staying with the public service, then it will need to be published to the test server (wrimaps.at.utah.gov) - const url = 'https://wrimaps.utah.gov/arcgis/rest/services/Electrofishing/Public/MapServer/1?f=json'; - const responseJson = (await ky(url).json()) as FeatureLayerDefinition; - - const purposeField = responseJson.fields.find((field: Field) => field.name === config.fieldNames.SURVEY_PURPOSE); - - if (!purposeField) { - throw new Error(`${config.fieldNames.SURVEY_PURPOSE} field not found in ${url}`); - } - - return purposeField.domain.codedValues; + return await getDomainValues(config.urls.events, config.fieldNames.SURVEY_PURPOSE); } export default function Purpose(): JSX.Element { @@ -61,7 +37,7 @@ export default function Purpose(): JSX.Element { {purposesDomain.data?.map(({ name, code }) => (
- +
))} diff --git a/src/components/filters/SpeciesLength.stories.tsx b/src/components/filters/SpeciesLength.stories.tsx new file mode 100644 index 0000000..54c980d --- /dev/null +++ b/src/components/filters/SpeciesLength.stories.tsx @@ -0,0 +1,18 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { FilterProvider } from '../contexts/FilterProvider'; +import SpeciesLength from './SpeciesLength'; + +export default { + title: 'SpeciesLength', + component: SpeciesLength, +}; + +export const Default = () => ( +
+ + + + + +
+); diff --git a/src/components/filters/SpeciesLength.tsx b/src/components/filters/SpeciesLength.tsx new file mode 100644 index 0000000..7e101fc --- /dev/null +++ b/src/components/filters/SpeciesLength.tsx @@ -0,0 +1,149 @@ +import { MinusCircleIcon, PlusCircleIcon } from '@heroicons/react/16/solid'; +import { useQuery } from '@tanstack/react-query'; +import { Button, TextField } from '@ugrc/utah-design-system'; +import { useEffect, useState } from 'react'; +import config from '../../config'; +import { useFilter } from '../contexts/FilterProvider'; +import { DomainValue, SpeciesLengthRow } from './filters.types'; +import { getDomainValues, getIsInvalidRange, getQuery, isPositiveWholeNumber } from './utilities'; + +type RowControlsProps = { + onChange: (newValue: SpeciesLengthRow) => void; + addRow: () => void; + removeRow: () => void; + isLast?: boolean; +} & SpeciesLengthRow; + +async function getSpecies(): Promise { + return await getDomainValues(config.urls.fish, config.fieldNames.SPECIES_CODE); +} + +function RowControls({ species, min, max, onChange, addRow, removeRow, isLast }: RowControlsProps) { + const isInvalidRange = getIsInvalidRange(min, max); + const speciesDomain = useQuery({ queryKey: ['species'], queryFn: getSpecies }); + + return ( + <> +
+ {/* TODO: switch this out with a design system component when it's implemented */} + + onChange({ species, min: newValue, max })} + /> + onChange({ species, min, max: newValue })} + /> + {isLast ? ( + + ) : ( + + )} +
+ {isInvalidRange &&

"Min" must be less than "Max"

} + + ); +} + +const filterKey = 'speciesLength'; +const emptyRow: SpeciesLengthRow = { + species: '', + min: '', + max: '', +}; +function isEmpty(row: SpeciesLengthRow) { + return row.species === '' && row.min === '' && row.max === ''; +} + +export default function SpeciesLength() { + const [rows, setRows] = useState([emptyRow]); + const { filterDispatch } = useFilter(); + + const onRowChange = (i: number, value: SpeciesLengthRow) => { + const newRows = [...rows]; + newRows[i] = value; + setRows(newRows); + }; + + const addRow = () => { + setRows([...rows, emptyRow]); + }; + + const removeRow = (i: number) => { + const newRows = [...rows]; + newRows.splice(i, 1); + setRows(newRows); + }; + + useEffect(() => { + if (rows.length === 1 && isEmpty(rows[0])) { + filterDispatch({ type: 'CLEAR_TABLE', filterKey }); + } else { + const newQuery = rows + .map(getQuery) + .filter((row) => row) + .join(' OR '); + + filterDispatch({ + type: 'UPDATE_TABLE', + filterKey, + value: { + where: newQuery, + table: config.tableNames.fish, + }, + }); + } + }, [rows, filterDispatch]); + + return ( + <> +

Species and Length (mm)

+
+ {rows.map((row: SpeciesLengthRow, i) => ( + onRowChange(i, newValue)} + addRow={addRow} + removeRow={() => removeRow(i)} + isLast={i === rows.length - 1} + /> + ))} +
+ +
+
+ + ); +} diff --git a/src/components/filters/filters.types.ts b/src/components/filters/filters.types.ts new file mode 100644 index 0000000..13b2e7b --- /dev/null +++ b/src/components/filters/filters.types.ts @@ -0,0 +1,10 @@ +export type DomainValue = { + name: string; + code: string; +}; + +export type SpeciesLengthRow = { + species: string; + min: string; + max: string; +}; diff --git a/src/components/filters/utilities.test.ts b/src/components/filters/utilities.test.ts new file mode 100644 index 0000000..9e2e4dd --- /dev/null +++ b/src/components/filters/utilities.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest'; +import config from '../../config'; +import { SpeciesLengthRow } from './filters.types'; +import { getQuery, isPositiveWholeNumber } from './utilities'; + +describe('isPositiveWholeNumber', () => { + it('should return true for positive integers', () => { + expect(isPositiveWholeNumber('1')).toBe(true); + }); + + it('should return false for negative integers', () => { + expect(isPositiveWholeNumber('-1')).toBe(false); + expect(isPositiveWholeNumber('-5.4')).toBe(false); + }); + + it('should return false for non-integers', () => { + expect(isPositiveWholeNumber('1.1')).toBe(false); + }); + + it('should return false for non-numbers', () => { + expect(isPositiveWholeNumber('a')).toBe(false); + }); + + it('should return false for zero', () => { + expect(isPositiveWholeNumber('0')).toBe(false); + }); +}); + +describe('getQuery', () => { + it('should return a valid query when all fields are provided', () => { + const row: SpeciesLengthRow = { species: 'Salmon', min: '10', max: '20' }; + const result = getQuery(row); + expect(result).toBe( + `(${config.fieldNames.SPECIES_CODE} = 'Salmon' AND ${config.fieldNames.LENGTH} >= 10 AND ${config.fieldNames.LENGTH} <= 20)`, + ); + }); + + it('should return a valid query when some fields are missing', () => { + const row: SpeciesLengthRow = { species: 'Salmon', min: '', max: '20' }; + const result = getQuery(row); + expect(result).toBe(`(${config.fieldNames.SPECIES_CODE} = 'Salmon' AND ${config.fieldNames.LENGTH} <= 20)`); + }); + + it('should return null when no fields are provided', () => { + const row: SpeciesLengthRow = { species: '', min: '', max: '' }; + const result = getQuery(row); + expect(result).toBeNull(); + }); + + it('should handle edge cases with empty strings', () => { + const row: SpeciesLengthRow = { species: '', min: '10', max: '' }; + const result = getQuery(row); + expect(result).toBe(`(${config.fieldNames.LENGTH} >= 10)`); + }); + + it('should return null if the range is invalid', () => { + const row: SpeciesLengthRow = { species: 'Salmon', min: '20', max: '10' }; + const result = getQuery(row); + expect(result).toBeNull(); + }); +}); diff --git a/src/components/filters/utilities.ts b/src/components/filters/utilities.ts new file mode 100644 index 0000000..677bf22 --- /dev/null +++ b/src/components/filters/utilities.ts @@ -0,0 +1,53 @@ +import ky from 'ky'; +import config from '../../config'; +import { DomainValue, SpeciesLengthRow } from './filters.types'; + +export function isPositiveWholeNumber(value: string): boolean { + return Number.isInteger(Number(value)) && Number(value) > 0; +} + +type Field = { + name: string; + domain: { + codedValues: DomainValue[]; + }; +}; +type FeatureLayerDefinition = { + fields: Field[]; +}; + +export async function getDomainValues(url: string, fieldName: string): Promise { + const responseJson = (await ky(`${url}?f=json`).json()) as FeatureLayerDefinition; + + const field = responseJson.fields.find((field: Field) => field.name === fieldName); + + if (!field) { + throw new Error(`${fieldName} field not found in ${url}`); + } + + return field.domain.codedValues; +} + +export function getIsInvalidRange(min: string, max: string) { + return isPositiveWholeNumber(min) && isPositiveWholeNumber(max) && Number(min) > Number(max); +} + +export function getQuery(row: SpeciesLengthRow): string | null { + if (getIsInvalidRange(row.min, row.max)) { + return null; + } + + const queryInfos = [ + [row.species, '=', config.fieldNames.SPECIES_CODE, "'"], + [row.min, '>=', config.fieldNames.LENGTH, ''], + [row.max, '<=', config.fieldNames.LENGTH, ''], + ]; + + const parts = queryInfos + .filter(([node]) => node.length > 0) + .map(([node, comparison, fieldName, quote]) => { + return `${fieldName} ${comparison} ${quote}${node}${quote}`; + }); + + return parts.length ? `(${parts.join(' AND ')})` : null; +}