diff --git a/.vscode/settings.json b/.vscode/settings.json index 71faa4d..82934bf 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,6 +19,7 @@ "Geospatial", "gnis", "hostingchannels", + "immer", "lods", "noopener", "noreferrer", @@ -30,9 +31,11 @@ "tailwindcss", "tanstack", "topo", + "UDWR", "ugrc", "usgs", "vite", + "WILDADMIN", "wkid", "wrimaps" ], diff --git a/package-lock.json b/package-lock.json index 6fcef55..3e33c2a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,13 +14,15 @@ "@ugrc/layer-selector": "^6.2.7", "@ugrc/utah-design-system": "^1.4.1", "firebase": "^10.13.0", + "immer": "^10.1.1", "ky": "^1.7.1", "react": "^18.3.1", "react-aria": "^3.34.3", "react-aria-components": "^1.3.3", "react-dom": "^18.3.1", "react-error-boundary": "^4.0.13", - "react-stately": "^3.32.2" + "react-stately": "^3.32.2", + "use-immer": "^0.10.0" }, "devDependencies": { "@eslint/js": "^9.9.0", @@ -6838,6 +6840,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", @@ -10071,6 +10083,16 @@ "punycode": "^2.1.0" } }, + "node_modules/use-immer": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/use-immer/-/use-immer-0.10.0.tgz", + "integrity": "sha512-/eVwNR4TG9Tm/dd+aHYLLaI0FLfYKlkTqKMkn78Ah/EYVzWd/zJIgpkdoFEKbhQJOGo8XN7/mWrTx0exp1c+Ug==", + "license": "MIT", + "peerDependencies": { + "immer": ">=8.0.0", + "react": "^16.8.0 || ^17.0.1 || ^18.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.2.tgz", diff --git a/package.json b/package.json index 9754d71..8937200 100644 --- a/package.json +++ b/package.json @@ -41,13 +41,15 @@ "@ugrc/layer-selector": "^6.2.7", "@ugrc/utah-design-system": "^1.4.1", "firebase": "^10.13.0", + "immer": "^10.1.1", "ky": "^1.7.1", "react": "^18.3.1", "react-aria": "^3.34.3", "react-aria-components": "^1.3.3", "react-dom": "^18.3.1", "react-error-boundary": "^4.0.13", - "react-stately": "^3.32.2" + "react-stately": "^3.32.2", + "use-immer": "^0.10.0" }, "devDependencies": { "@eslint/js": "^9.9.0", diff --git a/src/App.tsx b/src/App.tsx index 9dc603d..3dff81f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { ErrorBoundary } from 'react-error-boundary'; import { useOverlayTriggerState } from 'react-stately'; import { MapContainer } from './components'; import { useAnalytics, useFirebaseApp } from './components/contexts'; +import { FilterProvider } from './components/contexts/FilterProvider'; import Filter from './components/Filter'; import { useMap } from './components/hooks'; import { DnrLogo } from './components/Logo'; @@ -116,40 +117,42 @@ export default function App() { {isAuthenticated ? (
- -
- -

Map controls

-
- - - + + +
+ +

Map controls

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

Selected records

+
+
- -
-
- - - - -
-

Selected records

-
-
-
-
+
) : (
diff --git a/src/components/Filter.tsx b/src/components/Filter.tsx index 3193609..c34dd12 100644 --- a/src/components/Filter.tsx +++ b/src/components/Filter.tsx @@ -1,78 +1,49 @@ import FeatureLayer from '@arcgis/core/layers/FeatureLayer'; -import { useQuery } from '@tanstack/react-query'; -import { Button, Checkbox, CheckboxGroup, TextField } from '@ugrc/utah-design-system'; -import ky from 'ky'; -import { useEffect } from 'react'; -import config from '../config'; +import { Button, TextField } from '@ugrc/utah-design-system'; +import { useEffect, useRef } from 'react'; +import { useFilter } from './contexts/FilterProvider'; +import Purpose from './filters/Purpose'; import { useMap } from './hooks'; +import { getStationQuery } from './queryHelpers'; const emptyDefinition = '1=0'; -type DomainValue = { - name: string; - code: string; -}; -type Field = { - name: string; - domain: { - codedValues: DomainValue[]; - }; -}; -type FeatureLayerDefinition = { - fields: Field[]; -}; - -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.events.SURVEY_PURPOSE, - ); - - if (!purposeField) { - throw new Error(`${config.fieldNames.events.SURVEY_PURPOSE} field not found in ${url}`); - } - - return purposeField.domain.codedValues; -} - -export default function Filter() { +export default function Filter(): JSX.Element { const { addLayers, mapView } = useMap(); - const purposeQuery = useQuery({ queryKey: ['purposes'], queryFn: getPurposes }); + const stationsLayer = useRef(); + const { filter } = useFilter(); useEffect(() => { - if (!mapView) { + if (!mapView || !addLayers) { return; } - const stations = new FeatureLayer({ + stationsLayer.current = new FeatureLayer({ url: 'https://wrimaps.utah.gov/arcgis/rest/services/Electrofishing/Public/MapServer/0', definitionExpression: emptyDefinition, }); - addLayers([stations]); + addLayers([stationsLayer.current]); }, [addLayers, mapView]); + useEffect(() => { + if (!stationsLayer.current) { + return; + } + + if (Object.keys(filter).length > 0) { + const newQuery = getStationQuery(Object.values(filter)); + console.log('new query:', newQuery); + stationsLayer.current.definitionExpression = newQuery; + } else { + stationsLayer.current.definitionExpression = emptyDefinition; + } + }, [filter]); + return ( <>

Map filters

-
-

Purpose

- - {purposeQuery.data?.map(({ name, code }) => ( -
- - -
- ))} -
-
-
- -
+

Species and length

diff --git a/src/components/contexts/FilterProvider.tsx b/src/components/contexts/FilterProvider.tsx new file mode 100644 index 0000000..e07a346 --- /dev/null +++ b/src/components/contexts/FilterProvider.tsx @@ -0,0 +1,63 @@ +import { createContext, Dispatch, useContext } from 'react'; +import { useImmerReducer } from 'use-immer'; + +export type QueryInfo = { + where: string; + table: string; +}; +type FilterState = Record; +type FilterKeys = 'purpose'; +type Action = + | { + type: 'UPDATE_TABLE'; + filterKey: FilterKeys; + value: QueryInfo; + } + | { + type: 'CLEAR_TABLE'; + filterKey: FilterKeys; + } + | { + type: 'CLEAR_FILTER'; + }; + +const FilterContext = createContext<{ filter: FilterState; filterDispatch: Dispatch } | null>(null); + +const initialState: FilterState = {}; + +function reducer(draft: FilterState, action: Action): FilterState { + switch (action.type) { + case 'UPDATE_TABLE': + draft[action.filterKey] = action.value; + + console.log('updated filter:', JSON.stringify(draft, null, 2)); + + return draft; + + case 'CLEAR_TABLE': + delete draft[action.filterKey]; + + console.log('updated filter:', JSON.stringify(draft, null, 2)); + + return draft; + + case 'CLEAR_FILTER': + return initialState; + } +} + +export function FilterProvider({ children }: { children: React.ReactNode }) { + const [filter, filterDispatch] = useImmerReducer(reducer, initialState); + + return {children}; +} + +export function useFilter() { + const context = useContext(FilterContext); + + if (!context) { + throw new Error('useFilter must be used within a FilterProvider'); + } + + return context; +} diff --git a/src/components/filters/Purpose.tsx b/src/components/filters/Purpose.tsx new file mode 100644 index 0000000..9b69e47 --- /dev/null +++ b/src/components/filters/Purpose.tsx @@ -0,0 +1,77 @@ +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[]; +}; + +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; +} + +export default function Purpose(): JSX.Element { + const purposesDomain = useQuery({ queryKey: ['purposes'], queryFn: getPurposes }); + const { filterDispatch } = useFilter(); + const [selectedValues, setSelectedValues] = useState([]); + + useEffect(() => { + if (selectedValues.length > 0) { + filterDispatch({ + type: 'UPDATE_TABLE', + filterKey: 'purpose', + value: { + where: `${config.fieldNames.SURVEY_PURPOSE} IN ('${selectedValues.join("','")}')`, + table: config.tableNames.events, + }, + }); + } else { + filterDispatch({ type: 'CLEAR_TABLE', filterKey: 'purpose' }); + } + }, [selectedValues, filterDispatch]); + + return ( + <> +
+

Purpose

+ + {purposesDomain.data?.map(({ name, code }) => ( +
+ + +
+ ))} +
+
+
+ +
+ + ); +} diff --git a/src/components/queryHelpers.test.ts b/src/components/queryHelpers.test.ts new file mode 100644 index 0000000..7a4e844 --- /dev/null +++ b/src/components/queryHelpers.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import config from '../config'; +import { getGridQuery, getIdsFromGridSelection, getStationQuery, removeIrrelevantWhiteSpace } from './queryHelpers'; + +describe('getStationQuery', () => { + it('builds the correct query text', () => { + const input = [ + { + where: '1 = 1', + table: 'TableOne', + }, + { + where: '1 = 2', + table: 'TableOne', + }, + { + where: '1 = 3', + table: 'TableTwo', + }, + { + where: '1 = 4', + table: 'TableThree', + }, + ]; + const expected = removeIrrelevantWhiteSpace( + `(${config.fieldNames.STATION_ID} IN (SELECT ${config.fieldNames.STATION_ID} FROM ${config.databaseName}.WILDADMIN.TableOne + WHERE 1 = 1 AND 1 = 2)) + AND (${config.fieldNames.STATION_ID} IN (SELECT ${config.fieldNames.STATION_ID} FROM ${config.databaseName}.WILDADMIN.TableTwo + WHERE 1 = 3)) + AND (${config.fieldNames.STATION_ID} IN (SELECT ${config.fieldNames.STATION_ID} FROM ${config.databaseName}.WILDADMIN.TableThree + WHERE 1 = 4))`, + ); + + const results = getStationQuery(input); + expect(results).toBe(expected); + }); + it('handles where clauses against the Stations table itself', () => { + const input = [ + { + where: '1 = 1', + table: config.tableNames.stations, + }, + { + where: '1 = 2', + table: 'TableOne', + }, + ]; + const expected = `(1 = 1) AND (${config.fieldNames.STATION_ID} IN (SELECT ${config.fieldNames.STATION_ID} FROM ${config.databaseName}.WILDADMIN.TableOne WHERE 1 = 2))`; + + const results = getStationQuery(input); + expect(results).toBe(expected); + }); + it('handles a single where clause against the Stations table itself', () => { + const input = [ + { + where: '1 = 1', + table: config.tableNames.stations, + }, + ]; + const expected = '(1 = 1)'; + + const results = getStationQuery(input); + expect(results).toBe(expected); + }); + it('handles tables that require an extra join back to stations', () => { + const input = [ + { + where: '1 = 1', + table: config.tableNames.fish, + }, + { + where: '2 = 2', + table: config.tableNames.events, + }, + ]; + const expected = removeIrrelevantWhiteSpace(`(${config.fieldNames.STATION_ID} IN ( + SELECT ${config.fieldNames.STATION_ID} FROM ${config.databaseName}.WILDADMIN.${config.tableNames.events} + WHERE ${config.fieldNames.EVENT_ID} IN ( + SELECT ${config.fieldNames.EVENT_ID} FROM ${config.databaseName}.WILDADMIN.${config.tableNames.fish} + WHERE 1 = 1 + ) AND 2 = 2 + ))`); + + expect(getStationQuery(input)).toBe(expected); + }); +}); + +describe('getGridQuery', () => { + it('builds the correct query text', () => { + const input = [ + { + where: '1 = 1', + table: 'TableOne', + }, + { + where: '1 = 2', + table: 'TableOne', + }, + { + where: '1 = 3', + table: 'TableTwo', + }, + { + where: '1 = 4', + table: 'TableThree', + }, + ]; + + const expected = '(1 = 1) AND (1 = 2) AND (1 = 3) AND (1 = 4)'; + + const results = getGridQuery(input); + expect(results).toBe(expected); + }); +}); + +describe('removeIrrelevantWhiteSpace', () => { + it('removes newlines and double spaces', () => { + const input = `testing + testing testing`; + + expect(removeIrrelevantWhiteSpace(input)).toBe('testing testing testing'); + }); + it('removes spaces around parenthesis', () => { + const input = '( test ) hello'; + + expect(removeIrrelevantWhiteSpace(input)).toBe('(test) hello'); + }); +}); + +describe('getIdsFromGridSelection', () => { + const rows = [ + { + EVENT_ID: '1', + }, + { + EVENT_ID: '2', + }, + { + EVENT_ID: '3', + }, + ]; + + it('returns all ids if there is no selection', () => { + expect(getIdsFromGridSelection(rows, {})).toEqual('1;2;3'); + }); + it('returns selected ids', () => { + const selection = { + 1: true, + 3: true, + }; + + expect(getIdsFromGridSelection(rows, selection)).toEqual('1;3'); + }); +}); diff --git a/src/components/queryHelpers.ts b/src/components/queryHelpers.ts new file mode 100644 index 0000000..d421348 --- /dev/null +++ b/src/components/queryHelpers.ts @@ -0,0 +1,82 @@ +import config from '../config'; +import { QueryInfo } from './contexts/FilterProvider'; + +export function getStationQuery(queryInfos: QueryInfo[]): string { + // Returns a query that selects stations given some related table queries + const tables: Record = {}; + + // organize where clauses by table + queryInfos.forEach((info) => { + // make sure that we don't mutate this object and mess up the grid query + info = Object.assign({}, info); + + if (info.table === config.tableNames.fish) { + // fish table requires an additional join + info.table = config.tableNames.events; + info.where = `${config.fieldNames.EVENT_ID} IN ( + SELECT ${config.fieldNames.EVENT_ID} FROM + ${config.databaseName}.WILDADMIN.${config.tableNames.fish} + WHERE ${info.where})`; + } + + if (tables[info.table]) { + tables[info.table].push(info.where); + } else { + tables[info.table] = [info.where]; + } + }); + + const query = Object.keys(tables).reduce((previous, current, currentIndex) => { + // concat where clauses for table + const where = tables[current].join(' AND '); + + // support multiple table queries + if (currentIndex > 0) { + previous += ' AND '; + } + + // no need for join on stations table query + if (current === config.tableNames.stations) { + return `${previous}(${where})`; + } + + return `${previous}(${config.fieldNames.STATION_ID} IN (SELECT ${config.fieldNames.STATION_ID} + FROM ${config.databaseName}.WILDADMIN.${current} WHERE ${where}))`; + }, ''); + + return removeIrrelevantWhiteSpace(query); +} + +export function getGridQuery(queryInfos: QueryInfo[]): string { + // Returns a query that selects rows in the grid + + return `(${queryInfos + .reduce((previous: string[], current) => { + previous.push(current.where); + + return previous; + }, []) + .join(') AND (')})`; +} + +export function getStationQueryFromIds(ids: string[]): string { + return `${config.fieldNames.STATION_ID} IN ('${ids.join("', '")}')`; +} + +export function removeIrrelevantWhiteSpace(text: string): string { + return text + .replace(/\n/g, '') // SQL doesn't like newline characters + .replace(/ +/g, ' ') // multiple whitespaces + .replace(/\(\s/g, '(') // spaces around parenthesis + .replace(/\s\)/g, ')'); // '' +} + +type Row = Record; +export function getIdsFromGridSelection(rows: Row[], selection: Record): string { + const selectedIds = Object.keys(selection); + if (selectedIds.length > 0) { + rows = rows.filter((row) => selectedIds.indexOf(row[config.fieldNames.EVENT_ID]) > -1); + } + + return rows.map((row) => row[config.fieldNames.EVENT_ID]).join(';'); +} diff --git a/src/config.js b/src/config.js index 1cb5f8f..98ec1a4 100644 --- a/src/config.js +++ b/src/config.js @@ -3,10 +3,44 @@ const config = { WEB_MERCATOR_WKID: 3857, MARKER_FILL_COLOR: [234, 202, 0, 0.5], MARKER_OUTLINE_COLOR: [77, 42, 84, 1], + databaseName: 'Electrofishing', fieldNames: { - events: { - SURVEY_PURPOSE: 'SURVEY_PURPOSE', - }, + // common + STATION_ID: 'STATION_ID', + + // Stations + NAME: 'NAME', + STREAM_TYPE: 'STREAM_TYPE', + WATER_ID: 'WATER_ID', + + // SamplingEvents + EVENT_ID: 'EVENT_ID', + SURVEY_PURPOSE: 'SURVEY_PURPOSE', + EVENT_DATE: 'EVENT_DATE', + OBSERVERS: 'OBSERVERS', + + // Fish + SPECIES: 'SPECIES', // dynamic field created via SQL query in AGSStores + SPECIES_CODE: 'SPECIES_CODE', + LENGTH: 'LENGTH', + + // Equipment + TYPES: 'TYPES', // dynamic field created via SQL query in AGSStores + + // Streams/Lakes + WaterName: 'WaterName', + DWR_WaterID: 'DWR_WaterID', + Permanent_Identifier: 'Permanent_Identifier', + ReachCode: 'ReachCode', + COUNTY: 'COUNTY', + }, + + tableNames: { + events: 'SamplingEvents_evw', + stations: 'Stations_evw', + fish: 'Fish_evw', + streams: 'UDWRStreams', + lakes: 'UDWRLakes', }, };