From 7aaddc1d6083f23edb3d281b592a5fb03d323a1b Mon Sep 17 00:00:00 2001 From: stdavis Date: Tue, 22 Oct 2024 16:27:28 -0600 Subject: [PATCH] feat: add data to rudimentary results grid #184 --- .vscode/settings.json | 1 + src/App.tsx | 69 ++++---- src/components/ResultsGrid.tsx | 185 +++++++++++++++++++++ src/components/Table.tsx | 133 +++++++++++++++ src/components/contexts/FilterProvider.tsx | 2 +- src/components/queryHelpers.test.ts | 15 ++ src/components/queryHelpers.ts | 8 +- src/components/utils.ts | 20 +++ src/config.ts | 2 + src/main.tsx | 5 +- 10 files changed, 403 insertions(+), 37 deletions(-) create mode 100644 src/components/ResultsGrid.tsx create mode 100644 src/components/Table.tsx create mode 100644 src/components/utils.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 3d6997c..0e08b5c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,6 +26,7 @@ "noopener", "noreferrer", "oidc", + "overscan", "packagejson", "sgid", "sitla", diff --git a/src/App.tsx b/src/App.tsx index 5dd42eb..855231a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,10 +17,11 @@ import { useOverlayTrigger } from 'react-aria'; import { ErrorBoundary } from 'react-error-boundary'; import { useOverlayTriggerState } from 'react-stately'; import { MapContainer } from './components'; -import { FilterProvider } from './components/contexts/FilterProvider'; +import { useFilter } from './components/contexts/FilterProvider'; import Filter from './components/Filter'; import { useMap } from './components/hooks'; import { DnrLogo } from './components/Logo'; +import ResultsGrid from './components/ResultsGrid'; import config from './config'; import ErrorFallback from './ErrorFallback'; @@ -168,6 +169,12 @@ export default function App() { } }, [currentUser]); + // open tray if filter is set + const { filter } = useFilter(); + useEffect(() => { + trayState.setOpen(Object.keys(filter).length > 0); + }, [filter, trayState]); + return ( <>
@@ -181,42 +188,40 @@ export default function App() { {currentUser ? (
- - -
- -

Map controls

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

Map controls

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

Selected records

-
-
- + +
+
+ + + + +
+ +
+
+
+
) : (
diff --git a/src/components/ResultsGrid.tsx b/src/components/ResultsGrid.tsx new file mode 100644 index 0000000..2c520c0 --- /dev/null +++ b/src/components/ResultsGrid.tsx @@ -0,0 +1,185 @@ +import { useQuery } from '@tanstack/react-query'; +import { useFirebaseAuth } from '@ugrc/utah-design-system'; +import { User } from 'firebase/auth'; +import ky from 'ky'; +import { TableBody } from 'react-aria-components'; +import config from '../config'; +import { useFilter } from './contexts/FilterProvider'; +import { getGridQuery, removeIrrelevantWhiteSpace } from './queryHelpers'; +import { Cell, Column, Row, Table, TableHeader } from './Table'; + +const STATION_NAME = 'STATION_NAME'; +type Result = Record; +async function getData(where: string, currentUser: User): Promise { + if (where === '') { + return []; + } + + const query = ` + SELECT DISTINCT + se.${config.fieldNames.EVENT_ID}, + ${config.fieldNames.EVENT_DATE}, + ${config.fieldNames.OBSERVERS}, + l.${config.fieldNames.WaterName} as ${config.fieldNames.WaterName}_Lake, + l.${config.fieldNames.DWR_WaterID} as ${config.fieldNames.DWR_WaterID}_Lake, + l.${config.fieldNames.ReachCode} as ${config.fieldNames.ReachCode}_Lake, + st.${config.fieldNames.WaterName} as ${config.fieldNames.WaterName}_Stream, + st.${config.fieldNames.DWR_WaterID} as ${config.fieldNames.DWR_WaterID}_Stream, + st.${config.fieldNames.ReachCode} as ${config.fieldNames.ReachCode}_Stream, + s.${config.fieldNames.NAME} as ${STATION_NAME}, + SPECIES = STUFF((SELECT DISTINCT ', ' + f.${config.fieldNames.SPECIES_CODE} + FROM ${config.databaseSecrets.databaseName}.${config.databaseSecrets.user}.Fish_evw as f + WHERE se.${config.fieldNames.EVENT_ID} = f.${config.fieldNames.EVENT_ID} + FOR XML PATH ('')), + 1, 1, ''), + TYPES = STUFF((SELECT DISTINCT ', ' + eq.TYPE + FROM ${config.databaseSecrets.databaseName}.${config.databaseSecrets.user}.Equipment_evw as eq + WHERE se.${config.fieldNames.EVENT_ID} = eq.${config.fieldNames.EVENT_ID} + FOR XML PATH ('')), + 1, 1, '') + FROM ${config.databaseSecrets.databaseName}.${config.databaseSecrets.user}.SamplingEvents_evw as se + + LEFT OUTER JOIN ${config.databaseSecrets.databaseName}.${config.databaseSecrets.user}.Fish_evw as f + ON se.${config.fieldNames.EVENT_ID} = f.${config.fieldNames.EVENT_ID} + + INNER JOIN ${config.databaseSecrets.databaseName}.${config.databaseSecrets.user}.Stations_evw as s + ON s.${config.fieldNames.STATION_ID} = se.${config.fieldNames.STATION_ID} + + LEFT OUTER JOIN ${config.databaseSecrets.databaseName}.${config.databaseSecrets.user}.UDWRLakes_evw as l + ON l.${config.fieldNames.Permanent_Identifier} = s.${config.fieldNames.WATER_ID} + + LEFT OUTER JOIN ${config.databaseSecrets.databaseName}.${config.databaseSecrets.user}.UDWRStreams_evw as st + ON st.${config.fieldNames.Permanent_Identifier} = s.${config.fieldNames.WATER_ID} + + WHERE ${where} +`; + + const params = { + f: 'json', + layer: JSON.stringify({ + id: 1, + source: { + type: 'dataLayer', + dataSource: { + type: 'queryTable', + workspaceId: config.dynamicWorkspaceId, + query: removeIrrelevantWhiteSpace(query), + oidFields: config.fieldNames.EVENT_ID, + }, + }, + }), + outFields: '*', + where: '1 = 1', + returnGeometry: false, + orderByFields: `${config.fieldNames.EVENT_DATE} DESC`, + }; + + const url = `${config.urls.featureService}/dynamicLayer/query`; + + type Response = { error?: { message: string }; features: Array<{ attributes: Result }> }; + const urlEncoded = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + urlEncoded.append(key, value as string); + } + const responseJson: Response = await ky + .post(url, { + body: urlEncoded, + headers: { + Authorization: `Bearer ${await currentUser?.getIdToken()}`, + }, + }) + .json(); + + if (responseJson.error) { + throw new Error(responseJson.error.message); + } + + return responseJson.features.map((feature) => ({ + ...feature.attributes, + id: feature.attributes.ESRI_OID, + })); +} + +export default function ResultsGrid() { + const { filter } = useFilter(); + + const { currentUser } = useFirebaseAuth(); + const gridQuery = getGridQuery(Object.values(filter)); + const { data, isPending, error } = useQuery({ + queryKey: ['grid', gridQuery], + queryFn: () => { + if (currentUser) { + return getData(gridQuery, currentUser); + } + return null; + }, + }); + + if (isPending) { + return loading...; + } + if (error) { + return {error.message}; + } + + return ( + + + + Event Date + + + Observers + + + Stream + + + Stream ID + + + Stream Reach Code + + + Lake + + + Lake ID + + + Lake Reach Code + + + Station Name + + + Species Codes + + + Equipment + + + Event ID + + + + {(row) => ( + + {new Date(row[config.fieldNames.EVENT_DATE] as number).toLocaleDateString()} + {row[config.fieldNames.OBSERVERS]} + {row[`${config.fieldNames.WaterName}_Stream`]} + {row[`${config.fieldNames.DWR_WaterID}_Stream`]} + {row[`${config.fieldNames.ReachCode}_Stream`]} + {row[`${config.fieldNames.WaterName}_Lake`]} + {row[`${config.fieldNames.DWR_WaterID}_Lake`]} + {row[`${config.fieldNames.ReachCode}_Lake`]} + {row[STATION_NAME]} + {row[config.fieldNames.SPECIES]} + {row[config.fieldNames.TYPES]} + {row[config.fieldNames.EVENT_ID]} + + )} + +
+ ); +} diff --git a/src/components/Table.tsx b/src/components/Table.tsx new file mode 100644 index 0000000..a35ec14 --- /dev/null +++ b/src/components/Table.tsx @@ -0,0 +1,133 @@ +import { Checkbox } from '@ugrc/utah-design-system'; +import { ArrowUp } from 'lucide-react'; +import { + Cell as AriaCell, + Column as AriaColumn, + Row as AriaRow, + Table as AriaTable, + TableHeader as AriaTableHeader, + Button, + CellProps, + Collection, + ColumnProps, + ColumnResizer, + Group, + ResizableTableContainer, + RowProps, + TableHeaderProps, + TableProps, + composeRenderProps, + useTableOptions, +} from 'react-aria-components'; +import { tv } from 'tailwind-variants'; +import { composeTailwindRenderProps, focusRing } from './utils.ts'; + +export function Table(props: TableProps) { + return ( + + + + ); +} + +const columnStyles = tv({ + extend: focusRing, + base: 'flex h-5 flex-1 items-center gap-1 overflow-hidden px-2', +}); + +const resizerStyles = tv({ + extend: focusRing, + base: 'box-content h-5 w-px translate-x-[8px] cursor-col-resize rounded bg-gray-400 bg-clip-content px-[8px] py-1 -outline-offset-2 resizing:w-[2px] resizing:bg-blue-600 resizing:pl-[7px] dark:bg-zinc-500 forced-colors:bg-[ButtonBorder] forced-colors:resizing:bg-[Highlight]', +}); + +export function Column(props: ColumnProps) { + return ( + + {composeRenderProps(props.children, (children, { allowsSorting, sortDirection }) => ( +
+ + {children} + {allowsSorting && ( + + {sortDirection && ( + + )} + + )} + + {!props.width && } +
+ ))} +
+ ); +} + +export function TableHeader(props: TableHeaderProps) { + const { selectionBehavior, selectionMode, allowsDragging } = useTableOptions(); + + return ( + + {/* Add extra columns for drag and drop and selection. */} + {allowsDragging && } + {selectionBehavior === 'toggle' && ( + + {selectionMode === 'multiple' && } + + )} + {props.children} + + ); +} + +const rowStyles = tv({ + extend: focusRing, + base: 'group/row relative cursor-default select-none text-sm text-zinc-700 -outline-offset-2 hover:bg-slate-200 selected:bg-secondary-600 selected:text-white selected:hover:bg-secondary-500 disabled:text-gray-300 dark:text-zinc-300 dark:hover:bg-zinc-700 dark:disabled:text-zinc-600', +}); + +export function Row({ id, columns, children, ...otherProps }: RowProps) { + const { selectionBehavior, allowsDragging } = useTableOptions(); + + return ( + + {allowsDragging && ( + + + + )} + {selectionBehavior === 'toggle' && ( + + + + )} + {children} + + ); +} + +const cellStyles = tv({ + extend: focusRing, + base: 'truncate border-b p-2 -outline-offset-2 [--selected-border:theme(colors.secondary.800)] group-last/row:border-b-0 group-selected/row:border-[--selected-border] dark:border-b-zinc-700 dark:[--selected-border:theme(colors.secondary.500)] [:has(+[data-selected])_&]:border-[--selected-border]', +}); + +export function Cell(props: CellProps) { + return ; +} diff --git a/src/components/contexts/FilterProvider.tsx b/src/components/contexts/FilterProvider.tsx index 488d9c3..ee23431 100644 --- a/src/components/contexts/FilterProvider.tsx +++ b/src/components/contexts/FilterProvider.tsx @@ -5,7 +5,7 @@ export type QueryInfo = { where: string; table: string; }; -type FilterState = Record; +export type FilterState = Partial>; type FilterKeys = 'purpose' | 'date' | 'speciesLength' | 'location'; type Action = | { diff --git a/src/components/queryHelpers.test.ts b/src/components/queryHelpers.test.ts index d775faa..4f84e58 100644 --- a/src/components/queryHelpers.test.ts +++ b/src/components/queryHelpers.test.ts @@ -111,6 +111,21 @@ describe('getGridQuery', () => { const results = getGridQuery(input); expect(results).toBe(expected); }); + + it('returns an empty string if there are no where clauses', () => { + const input = [ + { + where: '', + table: 'TableOne', + }, + ]; + + const expected = ''; + + const results = getGridQuery(input); + + expect(results).toBe(expected); + }); }); describe('removeIrrelevantWhiteSpace', () => { diff --git a/src/components/queryHelpers.ts b/src/components/queryHelpers.ts index 5604bff..936461f 100644 --- a/src/components/queryHelpers.ts +++ b/src/components/queryHelpers.ts @@ -50,13 +50,15 @@ export function getStationQuery(queryInfos: QueryInfo[]): string { export function getGridQuery(queryInfos: QueryInfo[]): string { // Returns a query that selects rows in the grid - return `(${queryInfos + const parts = queryInfos + .filter((info) => info.where) .reduce((previous: string[], current) => { previous.push(current.where); return previous; - }, []) - .join(') AND (')})`; + }, []); + + return parts.length > 0 ? `(${parts.join(') AND (')})` : ''; } export function getStationQueryFromIds(ids: string[]): string { diff --git a/src/components/utils.ts b/src/components/utils.ts new file mode 100644 index 0000000..35e0d82 --- /dev/null +++ b/src/components/utils.ts @@ -0,0 +1,20 @@ +import { composeRenderProps } from 'react-aria-components'; +import { twMerge } from 'tailwind-merge'; +import { tv } from 'tailwind-variants'; + +export const focusRing = tv({ + base: 'outline outline-offset-2 outline-primary-900 dark:outline-secondary-600', + variants: { + isFocusVisible: { + false: 'outline-0', + true: 'outline-2', + }, + }, +}); + +export function composeTailwindRenderProps( + className: string | ((v: T) => string) | undefined, + tw: string, +): string | ((v: T) => string) { + return composeRenderProps(className, (className) => twMerge(tw, className)); +} diff --git a/src/config.ts b/src/config.ts index a5e5a7f..874537b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -12,6 +12,7 @@ const config = { MARKER_FILL_COLOR: [234, 202, 0, 0.5], MARKER_OUTLINE_COLOR: [77, 42, 84, 1], databaseSecrets, + dynamicWorkspaceId: 'ElectrofishingQuery', fieldNames: { // common @@ -53,6 +54,7 @@ const config = { }, urls: { + featureService, stations: `${featureService}/0`, events: `${featureService}/1`, fish: `${featureService}/2`, diff --git a/src/main.tsx b/src/main.tsx index 34ebb93..939d0af 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,6 +6,7 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { ErrorBoundary } from 'react-error-boundary'; import App from './App'; +import { FilterProvider } from './components/contexts/FilterProvider'; import { MapProvider } from './components/contexts/MapProvider'; import './index.css'; @@ -49,7 +50,9 @@ createRoot(document.getElementById('root')!).render( - + + +