Skip to content

Commit

Permalink
feat: implement Species and Length filter
Browse files Browse the repository at this point in the history
Ref #183

Closes #45
  • Loading branch information
stdavis committed Nov 29, 2024
1 parent 78f26a5 commit 3328bfa
Show file tree
Hide file tree
Showing 9 changed files with 300 additions and 44 deletions.
15 changes: 2 additions & 13 deletions src/components/Filter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -51,19 +52,7 @@ export default function Filter(): JSX.Element {
<DateRange />
</div>
<div className="flex flex-col gap-4 rounded border border-zinc-200 p-3 dark:border-zinc-700">
<h3 className="text-lg font-semibold">Species and length</h3>
<div className="flex flex-col gap-2">
<div className="ml-2 flex gap-1">
<TextField label="species" className="w-20" />
</div>
<div className="ml-2 flex gap-1">
<TextField label="min" className="w-20" />
<TextField label="max" className="w-20" />
</div>
<div className="w-30 flex justify-end">
<Button variant="secondary">clear all</Button>
</div>
</div>
<SpeciesLength />
</div>
<div className="flex flex-col gap-4 rounded border border-zinc-200 p-3 dark:border-zinc-700">
<h3 className="text-lg font-semibold">Water body</h3>
Expand Down
2 changes: 1 addition & 1 deletion src/components/contexts/FilterProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export type QueryInfo = {
table: string;
};
type FilterState = Record<string, QueryInfo>;
type FilterKeys = 'purpose' | 'date';
type FilterKeys = 'purpose' | 'date' | 'speciesLength';
type Action =
| {
type: 'UPDATE_TABLE';
Expand Down
4 changes: 2 additions & 2 deletions src/components/filters/DateRange.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,15 +42,15 @@ export default function DateRange() {
<div className="flex gap-1">
<TextField
label="From"
className="w-32"
className="min-w-0 flex-grow"
type="date"
value={dates.from}
onChange={(newDate: string) => onChange(newDate, 'from')}
isInvalid={isInvalid}
/>
<TextField
label="To"
className="w-32"
className="min-w-0 flex-grow"
type="date"
value={dates.to}
onChange={(newDate: string) => onChange(newDate, 'to')}
Expand Down
32 changes: 4 additions & 28 deletions src/components/filters/Purpose.tsx
Original file line number Diff line number Diff line change
@@ -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<DomainValue[]> {
// 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 {
Expand Down Expand Up @@ -61,7 +37,7 @@ export default function Purpose(): JSX.Element {
<CheckboxGroup onChange={setSelectedValues} value={selectedValues}>
{purposesDomain.data?.map(({ name, code }) => (
<div key={code} className="flex gap-1">
<Checkbox type="checkbox" id={code} name={code} value={code} />
<Checkbox id={code} name={code} value={code} />
<label htmlFor={code}>{name}</label>
</div>
))}
Expand Down
18 changes: 18 additions & 0 deletions src/components/filters/SpeciesLength.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => (
<div className="w-[304px] rounded border p-3">
<QueryClientProvider client={new QueryClient()}>
<FilterProvider>
<SpeciesLength />
</FilterProvider>
</QueryClientProvider>
</div>
);
149 changes: 149 additions & 0 deletions src/components/filters/SpeciesLength.tsx
Original file line number Diff line number Diff line change
@@ -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<DomainValue[]> {
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 (
<>
<div className="flex w-full items-end gap-1">
{/* TODO: switch this out with a design system component when it's implemented */}
<select
id="species"
className="w-40 flex-grow"
value={species}
onChange={(e) => onChange({ species: e.target.value, min, max })}
>
<option value=""></option>
{speciesDomain.data?.map(({ name, code }) => (
<option key={code} value={code}>
{name}
</option>
))}
</select>
<TextField
label="Min"
className="min-w-0 flex-grow"
value={min}
type="number"
isInvalid={(min.length && !isPositiveWholeNumber(min)) || isInvalidRange}
onChange={(newValue: string) => onChange({ species, min: newValue, max })}
/>
<TextField
label="Max"
className="min-w-0 flex-grow"
value={max}
type="number"
isInvalid={(max.length && !isPositiveWholeNumber(max)) || isInvalidRange}
onChange={(newValue: string) => onChange({ species, min, max: newValue })}
/>
{isLast ? (
<Button aria-label="add new filter" variant="icon" className="w-16" onPress={addRow}>
<PlusCircleIcon />
</Button>
) : (
<Button aria-label="remove this filter" variant="icon" className="w-16" onPress={removeRow}>
<MinusCircleIcon />
</Button>
)}
</div>
{isInvalidRange && <p className="text-sm text-red-500">"Min" must be less than "Max"</p>}
</>
);
}

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<SpeciesLengthRow[]>([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 (
<>
<h3 className="text-lg font-semibold">Species and Length (mm)</h3>
<div className="relative flex flex-col gap-2">
{rows.map((row: SpeciesLengthRow, i) => (
<RowControls
key={i}
{...row}
onChange={(newValue: SpeciesLengthRow) => onRowChange(i, newValue)}
addRow={addRow}
removeRow={() => removeRow(i)}
isLast={i === rows.length - 1}
/>
))}
<div className="w-30 flex justify-end">
<Button
aria-label="clear all species and length filters"
variant="secondary"
onPress={() => setRows([emptyRow])}
>
Clear
</Button>
</div>
</div>
</>
);
}
10 changes: 10 additions & 0 deletions src/components/filters/filters.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type DomainValue = {
name: string;
code: string;
};

export type SpeciesLengthRow = {
species: string;
min: string;
max: string;
};
61 changes: 61 additions & 0 deletions src/components/filters/utilities.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading

0 comments on commit 3328bfa

Please sign in to comment.