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
-
-
-
-
-
-
-
-
-
- clear all
-
-
+
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 }) => (
-
+
{name}
))}
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: e.target.value, min, max })}
+ >
+
+ {speciesDomain.data?.map(({ name, code }) => (
+
+ {name}
+
+ ))}
+
+
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}
+ />
+ ))}
+
+ setRows([emptyRow])}
+ >
+ Clear
+
+
+
+ >
+ );
+}
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;
+}