From 228e67ce4745644c888b048d137106baaea2099e Mon Sep 17 00:00:00 2001 From: Anthony Whitaker Date: Thu, 26 Nov 2020 03:22:54 -0500 Subject: [PATCH] Update roster page to support filtering --- package-lock.json | 4 +- package.json | 1 + server/api/roster/index.ts | 9 + server/api/roster/roster.controller.ts | 139 ++++- server/api/roster/roster.model.ts | 2 + ...606327183104-StartAndEndDateColumnsType.ts | 27 + .../pages/roster-page/roster-page.styles.ts | 33 ++ .../pages/roster-page/roster-page.tsx | 201 +++++-- .../query-builder/query-builder.styles.ts | 28 + .../query-builder/query-builder.tsx | 491 ++++++++++++++++++ src/hooks/use-debounced.ts | 12 + src/hooks/use-deep-equality.ts | 18 + 12 files changed, 899 insertions(+), 66 deletions(-) create mode 100644 server/migration/1606327183104-StartAndEndDateColumnsType.ts create mode 100644 src/components/query-builder/query-builder.styles.ts create mode 100644 src/components/query-builder/query-builder.tsx create mode 100644 src/hooks/use-debounced.ts create mode 100644 src/hooks/use-deep-equality.ts diff --git a/package-lock.json b/package-lock.json index 46958cb1..2ca733b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8429,7 +8429,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha1-On1WtVnWy8PrUSMlJE5hmmXGxSU=" + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "2.2.7", @@ -11982,7 +11982,7 @@ "lodash": { "version": "4.17.20", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", - "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" + "integrity": "sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI=" }, "lodash._reinterpolate": { "version": "3.0.0", diff --git a/package.json b/package.json index 75d5d91b..a403f605 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "express": "^4.17.1", "express-async-errors": "^3.1.1", "express-session": "^1.17.1", + "fast-deep-equal": "^3.1.3", "http-proxy-middleware": "^0.20.0", "json-2-csv": "^3.7.8", "lodash": "^4.17.20", diff --git a/server/api/roster/index.ts b/server/api/roster/index.ts index 68fe3535..db310049 100644 --- a/server/api/roster/index.ts +++ b/server/api/roster/index.ts @@ -76,6 +76,7 @@ router.get( requireRolePermission(role => role.canManageRoster), controller.getRosterInfo, ); + router.get( '/:orgId', requireOrgAccess, @@ -83,6 +84,14 @@ router.get( controller.getRoster, ); +router.post( + '/:orgId/search', + requireOrgAccess, + requireRolePermission(role => role.canManageRoster), + bodyParser.json(), + controller.searchRoster, +); + router.post( '/:orgId', requireOrgAccess, diff --git a/server/api/roster/roster.controller.ts b/server/api/roster/roster.controller.ts index 9e8cd673..4fccafed 100644 --- a/server/api/roster/roster.controller.ts +++ b/server/api/roster/roster.controller.ts @@ -18,6 +18,20 @@ import { Role } from '../role/role.model'; import { Unit } from '../unit/unit.model'; class RosterController { + static getColumnSelect(column: RosterColumnInfo) { + // Make sure custom columns are converted to appropriate types + if (column.custom) { + switch (column.type) { + case RosterColumnType.Boolean: + return `(roster.custom_columns ->> '${column.name}')::BOOLEAN`; + case RosterColumnType.Number: + return `(roster.custom_columns ->> '${column.name}')::DOUBLE PRECISION`; + default: + return `roster.custom_columns ->> '${column.name}'`; + } + } + return `roster.${snakeCase(column.name)}`; + } async addCustomColumn(req: ApiRequest, res: Response) { if (!req.body.name) { @@ -180,6 +194,94 @@ class RosterController { }); } + async searchRoster(req: ApiRequest, res: Response) { + const limit = (req.query.limit != null) ? parseInt(req.query.limit) : 100; + const page = (req.query.page != null) ? parseInt(req.query.page) : 0; + const rosterColumns = await getAllowedRosterColumns(req.appOrg!, req.appRole!); + + const columns: RosterColumnInfo[] = [{ + name: 'unit', + displayName: 'Unit', + custom: false, + phi: false, + pii: false, + type: RosterColumnType.String, + updatable: false, + required: false, + }, ...rosterColumns]; + + async function makeQueryBuilder() { + return Object.keys(req.body) + .reduce((queryBuilder, key) => { + const column = columns.find(col => col.name === key); + if (!column) { + throw new BadRequestError('Malformed search query. Unexpected column name.'); + } + + const { op, value } = req.body[key]; + const needsQuotedValue = column.type === RosterColumnType.Date || column.type === RosterColumnType.DateTime; + const maybeQuote = (v: CustomColumnValue) => (v !== null && needsQuotedValue ? `'${v}'` : v); + const columnName = RosterController.getColumnSelect(column); + + if (op === 'between' || op === 'in') { + if (!Array.isArray(value)) { + throw new BadRequestError('Malformed search query. Expected array for value.'); + } + + if (op === 'in') { + return queryBuilder.andWhere(`${columnName} ${op} (:...${key})`, { + [key]: value, + }); + } + + if (op === 'between') { + return queryBuilder.andWhere(`${columnName} >= (:${key}Min) and ${columnName} <= (:${key}Max)`, { + [`${key}Min`]: maybeQuote(value[0]), + [`${key}Max`]: maybeQuote(value[1]), + }); + } + } + + if (Array.isArray(value)) { + throw new BadRequestError('Malformed search query. Expected scalar value.'); + } + + if (op === '=' || op === '<>' || op === '>' || op === '<') { + return queryBuilder.andWhere(`${columnName} ${op} :${key}`, { + [key]: maybeQuote(value), + }); + } + + if (op === '~' || op === 'startsWith' || op === 'endsWith') { + const prefix = op !== 'startsWith' ? '%' : ''; + const suffix = op !== 'endsWith' ? '%' : ''; + return queryBuilder.andWhere(`${columnName} like :${key}`, { + [key]: `${prefix}${value}${suffix}`, + }); + } + + throw new BadRequestError('Malformed search query. Received unexpected value for "op".'); + }, await queryAllowedRoster(req.appOrg!, req.appRole!)); + } + + const queryBuilder = await makeQueryBuilder(); + + const roster = await queryBuilder.clone() + .skip(page * limit) + .take(limit) + .orderBy({ + edipi: 'ASC', + }) + .getRawMany(); + + const totalRowsCount = await queryBuilder.getCount(); + + res.json({ + rows: roster, + totalRowsCount, + }); + } + async uploadRosterEntries(req: ApiRequest, res: Response) { const org = req.appOrg!; @@ -393,31 +495,14 @@ async function queryAllowedRoster(org: Org, role: Role) { // Add all columns that are allowed by the user's role columns.forEach(column => { - if (column.custom) { - // Make sure custom columns are converted to appropriate types - let selection: string; - switch (column.type) { - case RosterColumnType.Boolean: - selection = `(roster.custom_columns ->> '${column.name}')::BOOLEAN`; - break; - case RosterColumnType.Number: - selection = `(roster.custom_columns ->> '${column.name}')::DOUBLE PRECISION`; - break; - default: - selection = `roster.custom_columns ->> '${column.name}'`; - break; - } - queryBuilder.addSelect(selection, column.name); - } else { - queryBuilder.addSelect(`roster.${snakeCase(column.name)}`, column.name); - } + queryBuilder.addSelect(RosterController.getColumnSelect(column), column.name); }); // Filter out roster entries that are not on the active roster or are not allowed by the role's index prefix. return queryBuilder .where('u.org_id = :orgId', { orgId: org.id }) - .andWhere('(roster.end_date IS NULL OR roster.end_date > now())') - .andWhere('(roster.start_date IS NULL OR roster.start_date < now())') + .andWhere('(roster.end_date IS NULL OR roster.end_date >= CURRENT_DATE)') + .andWhere('(roster.start_date IS NULL OR roster.start_date <= CURRENT_DATE)') .andWhere('u.id like :name', { name: role.indexPrefix.replace('*', '%') }); } @@ -592,6 +677,20 @@ interface RosterInfo { columns: RosterColumnInfo[], } +type GetRosterQuery = { + limit: string + page: string +}; + +type QueryOp = '=' | '<>' | '~' | '>' | '<' | 'startsWith' | 'endsWith' | 'in' | 'between'; + +type SearchRosterBody = { + [column: string]: { + op: QueryOp + value: CustomColumnValue | CustomColumnValue[] + } +}; + type ReportDateQuery = { reportDate: string }; diff --git a/server/api/roster/roster.model.ts b/server/api/roster/roster.model.ts index c83d8bdb..cea67b80 100644 --- a/server/api/roster/roster.model.ts +++ b/server/api/roster/roster.model.ts @@ -31,12 +31,14 @@ export class Roster extends BaseEntity { lastName!: string; @Column({ + type: 'date', nullable: true, default: () => 'null', }) startDate?: Date; @Column({ + type: 'date', nullable: true, default: () => 'null', }) diff --git a/server/migration/1606327183104-StartAndEndDateColumnsType.ts b/server/migration/1606327183104-StartAndEndDateColumnsType.ts new file mode 100644 index 00000000..17bd6e60 --- /dev/null +++ b/server/migration/1606327183104-StartAndEndDateColumnsType.ts @@ -0,0 +1,27 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class StartAndEndDateColumnsType1606327183104 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "roster" ADD "start_date_value" DATE DEFAULT null`); + await queryRunner.query(`UPDATE "roster" SET "start_date_value" = DATE(start_date)`); + await queryRunner.query(`ALTER TABLE "roster" ALTER COLUMN start_date TYPE DATE USING start_date_value`); + await queryRunner.query(`ALTER TABLE "roster" DROP COLUMN "start_date_value"`); + + await queryRunner.query(`ALTER TABLE "roster" ADD "end_date_value" DATE DEFAULT null`); + await queryRunner.query(`UPDATE "roster" SET "end_date_value" = DATE(end_date)`); + await queryRunner.query(`ALTER TABLE "roster" ALTER COLUMN end_date TYPE DATE USING end_date_value`); + await queryRunner.query(`ALTER TABLE "roster" DROP COLUMN "end_date_value"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "roster" ADD "start_date_value" timestamp without time zone DEFAULT null`); + await queryRunner.query(`UPDATE "roster" SET "start_date_value" = start_date::TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "roster" ALTER COLUMN start_date TYPE timestamp without time zone USING start_date_value`); + await queryRunner.query(`ALTER TABLE "roster" DROP COLUMN "start_date_value"`); + + await queryRunner.query(`ALTER TABLE "roster" ADD "end_date_value" timestamp without time zone DEFAULT null`); + await queryRunner.query(`UPDATE "roster" SET "end_date_value" = end_date::TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "roster" ALTER COLUMN end_date TYPE timestamp without time zone USING end_date_value`); + await queryRunner.query(`ALTER TABLE "roster" DROP COLUMN "end_date_value"`); + } +} diff --git a/src/components/pages/roster-page/roster-page.styles.ts b/src/components/pages/roster-page/roster-page.styles.ts index ce31d221..d48c8bff 100644 --- a/src/components/pages/roster-page/roster-page.styles.ts +++ b/src/components/pages/roster-page/roster-page.styles.ts @@ -17,4 +17,37 @@ export default makeStyles((theme: Theme) => createStyles({ fillWidth: { width: '100%', }, + secondaryButtons: { + marginLeft: theme.spacing(2), + marginTop: theme.spacing(2), + }, + secondaryButton: { + borderWidth: 2, + marginRight: theme.spacing(2), + + '&:hover': { + borderWidth: 2, + }, + }, + secondaryButtonCount: { + backgroundColor: '#005ea2', + borderRadius: '50%', + color: '#fff', + fontSize: '12px !important', + height: '18px', + minWidth: '18px', + }, + tableWrapper: { + overflowY: 'auto', + + '& tr > *': { + maxWidth: '220px', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + }, + columnItem: { + paddingBottom: 0, + paddingTop: 0, + }, })); diff --git a/src/components/pages/roster-page/roster-page.tsx b/src/components/pages/roster-page/roster-page.tsx index cc827e17..ab67c09f 100644 --- a/src/components/pages/roster-page/roster-page.tsx +++ b/src/components/pages/roster-page/roster-page.tsx @@ -1,11 +1,15 @@ import { Button, + Checkbox, Container, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, + FormControlLabel, + Menu, + MenuItem, Paper, Table, TableContainer, @@ -13,10 +17,12 @@ import { TableRow, } from '@material-ui/core'; import AddCircleOutlineIcon from '@material-ui/icons/AddCircleOutline'; +import FilterListIcon from '@material-ui/icons/FilterList'; import PublishIcon from '@material-ui/icons/Publish'; import GetAppIcon from '@material-ui/icons/GetApp'; +import ViewWeekIcon from '@material-ui/icons/ViewWeek'; import React, { - ChangeEvent, MouseEvent, useCallback, useEffect, useState, + ChangeEvent, MouseEvent, useCallback, useEffect, useRef, useState, } from 'react'; import axios from 'axios'; import { useDispatch, useSelector } from 'react-redux'; @@ -42,6 +48,19 @@ import { EditRosterEntryDialog, EditRosterEntryDialogProps } from './edit-roster import { ButtonWithSpinner } from '../../buttons/button-with-spinner'; import { Unit } from '../../../actions/unit.actions'; import { UnitSelector } from '../../../selectors/unit.selector'; +import { QueryBuilder, QueryFieldType, QueryFilterState } from '../../query-builder/query-builder'; +import { formatMessage } from '../../../utility/errors'; + +const unitColumn: ApiRosterColumnInfo = { + name: 'unit', + displayName: 'Unit', + custom: false, + phi: false, + pii: false, + type: ApiRosterColumnType.String, + updatable: false, + required: false, +}; export const RosterPage = () => { const classes = useStyles(); @@ -54,6 +73,7 @@ export const RosterPage = () => { const [rows, setRows] = useState([]); const [page, setPage] = useState(0); + const initialLoad = useRef(true); const [totalRowsCount, setTotalRowsCount] = useState(0); const [rowsPerPage, setRowsPerPage] = useState(10); const [unitNameMap, setUnitNameMap] = useState<{[key: string]: string}>({}); @@ -64,8 +84,13 @@ export const RosterPage = () => { const [deleteRosterEntryLoading, setDeleteRosterEntryLoading] = useState(false); const [downloadTemplateLoading, setDownloadTemplateLoading] = useState(false); const [exportRosterLoading, setExportRosterLoading] = useState(false); + const [queryFilterState, setQueryFilterState] = useState(); const [rosterColumnInfos, setRosterColumnInfos] = useState([]); - + const [visibleColumns, setVisibleColumns] = useState([]); + const [filtersOpen, setFiltersOpen] = useState(false); + const [visibleColumnsMenuOpen, setVisibleColumnsMenuOpen] = useState(false); + const [applyingFilters, setApplyingFilters] = useState(false); + const visibleColumnsButtonRef = useRef(null); const orgId = useSelector(state => state.user).activeRole?.org?.id; const orgName = useSelector(state => state.user).activeRole?.org?.name; @@ -74,16 +99,42 @@ export const RosterPage = () => { // const reloadTable = useCallback(async () => { - const response = await axios.get(`api/roster/${orgId}/?limit=${rowsPerPage}&page=${page}`); - const data = response.data as ApiRosterPaginated; - setRows(data.rows); - setTotalRowsCount(data.totalRowsCount); - }, [page, rowsPerPage, orgId]); + try { + + if (queryFilterState) { + setApplyingFilters(true); + setRows([]); + setTotalRowsCount(0); + const response = await axios.post(`api/roster/${orgId}/search/?limit=${rowsPerPage}&page=${page}`, queryFilterState); + const data = response.data as ApiRosterPaginated; + setRows(data.rows); + setTotalRowsCount(data.totalRowsCount); + setApplyingFilters(false); + } else { + const response = await axios.get(`api/roster/${orgId}/?limit=${rowsPerPage}&page=${page}`); + const data = response.data as ApiRosterPaginated; + setRows(data.rows); + setTotalRowsCount(data.totalRowsCount); + } + + } catch (e) { + setAlertDialogProps({ + open: true, + title: 'Error', + message: formatMessage(e, 'Error Applying Filters'), + onClose: () => { setAlertDialogProps({ open: false }); }, + }); + } + }, [page, rowsPerPage, orgId, queryFilterState]); const initializeRosterColumnInfo = useCallback(async () => { try { const infos = (await axios.get(`api/roster/${orgId}/info`)).data as ApiRosterColumnInfo[]; - setRosterColumnInfos(infos); + const unitColumnWithInfos = [unitColumn, ...infos]; + setRosterColumnInfos(unitColumnWithInfos); + if (initialLoad.current) { + setVisibleColumns(unitColumnWithInfos.slice(0, maxNumColumnsToShow)); + } } catch (error) { let message = 'Internal Server Error'; if (error.response?.data?.errors && error.response.data.errors.length > 0) { @@ -99,13 +150,14 @@ export const RosterPage = () => { }, [orgId]); const initializeTable = useCallback(async () => { - dispatch(AppFrame.setPageLoading(true)); + dispatch(AppFrame.setPageLoading(initialLoad.current)); if (orgId) { await dispatch(Unit.fetch(orgId)); } await initializeRosterColumnInfo(); await reloadTable(); dispatch(AppFrame.setPageLoading(false)); + initialLoad.current = false; }, [dispatch, orgId, initializeRosterColumnInfo, reloadTable]); useEffect(() => { @@ -309,14 +361,6 @@ export const RosterPage = () => { } }; - const getVisibleColumns = () => { - const unitColumn = { - name: 'unit', - displayName: 'Unit', - }; - return [unitColumn, ...rosterColumnInfos.slice(0, maxNumColumnsToShow)]; - }; - // // Render // @@ -376,32 +420,101 @@ export const RosterPage = () => { - - - - - + + + + setVisibleColumnsMenuOpen(false)} + > + {rosterColumnInfos.map(column => ( + + col.name === column.name)} + onChange={event => { + const { checked } = event.target; + if (checked) { + setVisibleColumns(rosterColumnInfos.filter(col => col.name === column.name || visibleColumns.some(({ name }) => name === col.name))); + } else { + setVisibleColumns([...visibleColumns.filter(col => col.name !== column.name)]); + } + }} + /> + )} + label={column.displayName} /> - - -
+ + ))} + + { + return { + items: column.name === 'unit' ? units.map(({ id, name }) => ({ label: name, value: id })) : undefined, + displayName: column.displayName, + name: column.name, + type: column.type as unknown as QueryFieldType, + }; + })} + onChange={setQueryFilterState} + open={filtersOpen} + /> +
+ + + + + + + +
+
{deleteRosterEntryDialogOpen && ( @@ -428,10 +541,10 @@ export const RosterPage = () => { )} {editRosterEntryDialogProps.open && ( - + )} {alertDialogProps.open && ( - + )} ); diff --git a/src/components/query-builder/query-builder.styles.ts b/src/components/query-builder/query-builder.styles.ts new file mode 100644 index 00000000..24952661 --- /dev/null +++ b/src/components/query-builder/query-builder.styles.ts @@ -0,0 +1,28 @@ +import { createStyles, Theme } from '@material-ui/core'; +import { makeStyles } from '@material-ui/styles'; + +export default makeStyles((theme: Theme) => createStyles({ + root: { + flexGrow: 1, + padding: theme.spacing(2), + }, + rowsContainer: { + flexGrow: 1, + '& > .MuiGrid-container': { + flexWrap: 'nowrap', + }, + }, + addRowButton: { + borderColor: 'transparent', + marginTop: theme.spacing(2), + }, + removeRowButton: { + color: '#E41D3D', + }, + textField: { + margin: 0, + }, + pickListSelect: { + minWidth: '193px', + }, +})); diff --git a/src/components/query-builder/query-builder.tsx b/src/components/query-builder/query-builder.tsx new file mode 100644 index 00000000..32556e12 --- /dev/null +++ b/src/components/query-builder/query-builder.tsx @@ -0,0 +1,491 @@ +import React, { useEffect, useState } from 'react'; +import { + Button, Collapse, Grid, IconButton, Select, Switch, TextField, +} from '@material-ui/core'; +import AddCircleIcon from '@material-ui/icons/AddCircle'; +import HighlightOffIcon from '@material-ui/icons/HighlightOff'; +import MomentUtils from '@date-io/moment'; +import { DatePicker, DateTimePicker, MuiPickersUtilsProvider } from '@material-ui/pickers'; +import moment, { Moment } from 'moment'; +import useStyles from './query-builder.styles'; +import useDebounced from '../../hooks/use-debounced'; +import useDeepEquality from '../../hooks/use-deep-equality'; + +const toDate = (date: Moment) => date.format('Y-M-D'); + +export enum QueryFieldType { + String = 'string', + Boolean = 'boolean', + Date = 'date', + DateTime = 'datetime', + Number = 'number', +} + +export type QueryOp = '=' | '<>' | '~' | '>' | '<' | 'startsWith' | 'endsWith' | 'in' | 'between'; +export type QueryValueScalarType = string | number | boolean | undefined; +export type QueryValueType = QueryValueScalarType | QueryValueScalarType[]; + +export type QueryFieldPickListItem = { + label: string + value: string | number +}; + +export type QueryField = { + editor?: React.ReactElement + displayName?: string + name: string + type: QueryFieldType + items?: QueryFieldPickListItem[], +}; + +export type QueryRow = { + field: QueryField + op: QueryOp + value: QueryValueType +}; + +export type QueryFilterState = { + [key: string]: { + op: QueryOp + value: QueryValueType + } +}; + +interface ValueEditorProps { + onChange: (row: QueryRow) => void + row: QueryRow +} + +const StringValue = ({ onChange, row }: ValueEditorProps) => { + const classes = useStyles(); + + return ( + { + row.value = event.target.value; + onChange({ ...row }); + }} + placeholder={row.field.displayName} + type={row.field.type === QueryFieldType.Number ? 'number' : 'text'} + value={row.value} + /> + ); +}; + +const BooleanValue = ({ onChange, row }: ValueEditorProps) => { + return ( + { + row.value = event.target.checked; + onChange({ ...row }); + }} + color="primary" + inputProps={{ 'aria-label': 'primary checkbox' }} + /> + ); +}; + +interface DateTimeValueEditorProps extends ValueEditorProps { + hasTime: boolean +} + +const DateTimeValue = ({ hasTime, onChange, row }: DateTimeValueEditorProps) => { + const classes = useStyles(); + + useEffect(() => { + if (!row.value) { + onChange({ ...row, value: getDefaultValueForType(row.field) }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const Picker = hasTime ? DateTimePicker : DatePicker; + + return ( + + { + if (date) { + row.value = hasTime ? date.format() : toDate(date); + } else { + row.value = getDefaultValueForType(row.field); + } + onChange({ ...row }); + }} + /> + + ); +}; + +interface RangeValueEditorProps extends ValueEditorProps { + component: React.ReactElement +} + +const RangeValue = ({ component, onChange, row }: RangeValueEditorProps) => { + const array = [undefined, undefined] + .map((_: any, index: number) => { + return (Array.isArray(row.value) && row.value.length > index) ? row.value[index] : _; + }); + + return ( + + {array.map((value: QueryValueType, index: number) => ( + + {React.cloneElement(component, { + onChange: ({ value: subValue }: QueryRow) => { + array[index] = subValue; + row.value = array.slice(); + onChange(row); + }, + row: { ...row, value }, + })} + + ))} + + ); +}; + +const PickListValue = (props: ValueEditorProps) => { + const classes = useStyles(); + + const { onChange, row } = props; + return ( + + ); +}; + +const Editor = (props: ValueEditorProps) => { + let { editor } = props.row.field; + + if (!editor) { + if (props.row.field.items) { + editor = ; + } else { + switch (props.row.field.type) { + case QueryFieldType.Date: + editor = ; + break; + case QueryFieldType.DateTime: + editor = ; + break; + case QueryFieldType.Boolean: + editor = ; + break; + default: + editor = ; + } + } + } + + if (props.row.op === 'between') { + return ; + } + return editor; +}; + +type FieldSelectorProps = { + fields: QueryField[] + onChange: (field: QueryField) => void +}; + +const FieldSelector = (props: FieldSelectorProps) => { + const { fields, onChange } = props; + return ( + + ); +}; + +type OpsDesc = { + [key: string]: string +}; + +type QueryOpSelectorProps = { + onChange: (op: QueryOp) => void + value: QueryOp + ops: OpsDesc +}; + +const getOpDesc = (field: QueryField): OpsDesc => { + if (field.items || field.type === QueryFieldType.Boolean) { + return { + '=': 'is', + '<>': 'is not', + }; + } + if (field.type === QueryFieldType.String) { + return { + '=': 'is', + '<>': 'is not', + '~': 'contains', + startsWith: 'starts with', + endsWith: 'ends with', + }; + } + if (field.type === QueryFieldType.Date || field.type === QueryFieldType.DateTime) { + return { + '=': 'is', + '<>': 'is not', + '>': 'after', + '<': 'before', + between: 'between', + }; + } + if (field.type === QueryFieldType.Number) { + return { + '=': 'is', + '<>': 'is not', + '>': 'greater than', + '<': 'less than', + between: 'between', + }; + } + return {}; +}; + +const getDefaultOp = (field: QueryField): QueryOp => { + const opDesc = getOpDesc(field); + if (opDesc) { + return Object.keys(opDesc)[0] as QueryOp; + } + return '='; +}; + +const isArrayOp = (op: QueryOp) => op === 'between' || op === 'in'; + +const getDefaultValueForType = (field: QueryField): QueryValueScalarType => { + if (field.items?.length) { + return field.items[0].value; + } + if (field.type === QueryFieldType.Boolean) { + return false; + } + if (field.type === QueryFieldType.Date) { + return toDate(moment()); + } + if (field.type === QueryFieldType.DateTime) { + const today = new Date(Date.now()); + return today.toISOString(); + } + return ''; +}; + +const getDefaultValue = (field: QueryField, op: QueryOp): QueryValueType => { + const isArray = isArrayOp(op); + const value = getDefaultValueForType(field); + return isArray ? [value] : value; +}; + +const getNewValue = (row: QueryRow, newField: QueryField, newOp: QueryOp): QueryValueType => { + if (row.field.items || newField.items) { + return getDefaultValueForType(newField); + } + if (row.field.type === newField.type) { + if (isArrayOp(row.op) === isArrayOp(newOp)) { + return row.value; + } + if (isArrayOp(newOp)) { + return [row.value as QueryValueScalarType]; + } + if (Array.isArray(row.value)) { + return row.value[0]; + } + } + return getDefaultValueForType(newField); +}; + +const QueryOpSelector = (props: QueryOpSelectorProps) => { + const { onChange, ops, value } = props; + return ( + + ); +}; + +type QueryBuilderRowProps = { + availableFields: QueryField[] + onChange: (row: QueryRow) => void + row: QueryRow +}; + +const QueryBuilderRow = (props: QueryBuilderRowProps) => { + const { + availableFields, onChange, row, + } = props; + + const ops = getOpDesc(row.field); + + return ( + <> + + { + const newOps = getOpDesc(field); + const op = row.op in newOps ? row.op : getDefaultOp(field); + onChange({ field, op, value: getNewValue(row, field, op) }); + }} + /> + + + {ops && ( + + { + onChange({ field: row.field, op, value: getNewValue(row, row.field, op) }); + }} + ops={ops} + value={row.op} + /> + + )} + + + + + + ); +}; + +export interface QueryBuilderProps { + fields: QueryField[] + onChange: (query: QueryFilterState | undefined) => void + open: boolean +} + +export const QueryBuilder = (props: QueryBuilderProps) => { + const classes = useStyles(); + const { + fields, onChange, open, + } = props; + const [rows, setRows] = useState([]); + const availableFields = fields.filter(field => !rows.some(row => row.field.name === field.name)); + const [filterState, setFilterState] = useDeepEquality(); + const debouncedRows = useDebounced(rows, 333); + + const addRow = () => { + if (availableFields.length > 0) { + const field = availableFields[0]; + const op = getDefaultOp(field); + setRows([...rows, { field, op, value: getDefaultValue(field, op) }]); + } + }; + + const removeRow = (rowIndex: number) => () => { + setRows(rows.filter((_, index) => index !== rowIndex)); + }; + + const onRowChange = (index: number) => (row: QueryRow) => { + const newRows = [...rows]; + newRows.splice(index, 1, row); + setRows(newRows); + }; + + useEffect(() => { + const queryableRows = debouncedRows.filter(row => row.field && row.value !== ''); + const query = queryableRows.length && queryableRows.reduce( + (accum, { field, op, value }) => { + accum[field!.name] = { op, value }; + return accum; + }, {} as QueryFilterState, + ); + + setFilterState(query || undefined); + }, [debouncedRows, setFilterState]); + + useEffect(() => { + onChange(filterState); + }, [filterState, onChange]); + + useEffect(() => { + if (open && rows.length === 0) { + addRow(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); + + return ( +
+ +
+ {rows.map((row, index) => ( + + + + + + + ))} +
+ {rows.every(row => row.field) && ( + + )} +
+
+ ); +}; diff --git a/src/hooks/use-debounced.ts b/src/hooks/use-debounced.ts new file mode 100644 index 00000000..8b83295a --- /dev/null +++ b/src/hooks/use-debounced.ts @@ -0,0 +1,12 @@ +import { useState, useEffect } from 'react'; + +export default function useDebounced(value: T, delay: number) { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timeout = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timeout); + }, [value, delay]); + + return debounced; +} diff --git a/src/hooks/use-deep-equality.ts b/src/hooks/use-deep-equality.ts new file mode 100644 index 00000000..9caac5d2 --- /dev/null +++ b/src/hooks/use-deep-equality.ts @@ -0,0 +1,18 @@ +import { + useState, useEffect, SetStateAction, Dispatch, +} from 'react'; +import deepEquals from 'fast-deep-equal'; + +export default function useDeepEquality(value?: T | undefined): [T | undefined, Dispatch>, T | undefined] { + const [deep, setDeepValue] = useState(value); + const [candidate, setCandidateValue] = useState(value); + + useEffect(() => { + if (!deepEquals(candidate, deep)) { + setDeepValue(candidate); + } + }, [candidate, deep, setDeepValue]); + + return [deep, setCandidateValue, candidate]; +} +