From 60a40c635476d5b4893cc947fcae45959e345013 Mon Sep 17 00:00:00 2001 From: Sidney Alcantara Date: Thu, 26 May 2022 21:16:02 +1000 Subject: [PATCH] add table filters UI --- package.json | 1 + src/atoms/globalScope/ui.ts | 27 + src/components/ButtonWithStatus.tsx | 3 +- src/components/ColumnMenu/ColumnMenu.tsx | 28 +- .../Table/ColumnHeader/ColumnHeaderSort.tsx | 4 +- .../TableToolbar/Filters/FilterInputs.tsx | 115 +++++ .../TableToolbar/Filters/Filters.tsx | 484 ++++++++++++++++++ .../TableToolbar/Filters/FiltersPopover.tsx | 112 ++++ src/components/TableToolbar/Filters/index.ts | 2 + .../TableToolbar/Filters/useFilterInputs.ts | 57 +++ src/components/TableToolbar/TableToolbar.tsx | 4 +- src/types/table.d.ts | 1 + yarn.lock | 9 +- 13 files changed, 833 insertions(+), 14 deletions(-) create mode 100644 src/components/TableToolbar/Filters/FilterInputs.tsx create mode 100644 src/components/TableToolbar/Filters/Filters.tsx create mode 100644 src/components/TableToolbar/Filters/FiltersPopover.tsx create mode 100644 src/components/TableToolbar/Filters/index.ts create mode 100644 src/components/TableToolbar/Filters/useFilterInputs.ts diff --git a/package.json b/package.json index ae4ebe67..acd3c560 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ "use-algolia": "^1.5.3", "use-async-memo": "^1.2.4", "use-debounce": "^8.0.0", + "use-memo-value": "^1.0.1", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/src/atoms/globalScope/ui.ts b/src/atoms/globalScope/ui.ts index 6230aafc..af2cefbf 100644 --- a/src/atoms/globalScope/ui.ts +++ b/src/atoms/globalScope/ui.ts @@ -6,6 +6,7 @@ import type { TableSettings, TableSchema, ColumnConfig, + TableFilter, } from "@src/types/table"; import { getTableSchemaAtom } from "./project"; @@ -198,6 +199,32 @@ export const columnModalAtom = atomWithHash<{ index?: number; } | null>("columnModal", null, { replaceState: true }); +export type TableFiltersPopoverState = { + open: boolean; + defaultQuery?: TableFilter; +}; +/** + * Store table filter popover state. + * Calling the set function resets props. + * + * @example Basic usage: + * ``` + * const openTableFiltersPopover = useSetAtom(tableFiltersPopoverAtom, globalScope); + * openTableFiltersPopover({ query: ... }); + * ``` + * + * @example Close: + * ``` + * openTableFiltersPopover({ open: false }) + * ``` + */ +export const tableFiltersPopoverAtom = atom( + { open: false } as TableFiltersPopoverState, + (_, set, update?: Partial) => { + set(tableFiltersPopoverAtom, { open: true, ...update }); + } +); + /** Store current JSON editor view */ export const jsonEditorAtom = atomWithStorage<"tree" | "code">( "__ROWY__JSON_EDITOR", diff --git a/src/components/ButtonWithStatus.tsx b/src/components/ButtonWithStatus.tsx index 39805b2b..d417e854 100644 --- a/src/components/ButtonWithStatus.tsx +++ b/src/components/ButtonWithStatus.tsx @@ -8,7 +8,7 @@ export interface IButtonWithStatusProps extends ButtonProps { } export const ButtonWithStatus = forwardRef(function ButtonWithStatus_( - { active = false, className, ...props }: IButtonWithStatusProps, + { active = false, className, sx, ...props }: IButtonWithStatusProps, ref: React.Ref ) { return ( @@ -52,6 +52,7 @@ export const ButtonWithStatus = forwardRef(function ButtonWithStatus_( }, } : {}, + ...((Array.isArray(sx) ? sx : [sx]) as any), ]} /> ); diff --git a/src/components/ColumnMenu/ColumnMenu.tsx b/src/components/ColumnMenu/ColumnMenu.tsx index cdb2475d..9cb8e16c 100644 --- a/src/components/ColumnMenu/ColumnMenu.tsx +++ b/src/components/ColumnMenu/ColumnMenu.tsx @@ -33,6 +33,7 @@ import { confirmDialogAtom, columnMenuAtom, columnModalAtom, + tableFiltersPopoverAtom, } from "@src/atoms/globalScope"; import { tableScope, @@ -73,6 +74,10 @@ export default function ColumnMenu() { const updateColumn = useSetAtom(updateColumnAtom, tableScope); const deleteColumn = useSetAtom(deleteColumnAtom, tableScope); const [tableOrders, setTableOrders] = useAtom(tableOrdersAtom, tableScope); + const openTableFiltersPopover = useSetAtom( + tableFiltersPopoverAtom, + globalScope + ); const altPress = useKeyPress("Alt"); if (!columnMenu) return null; @@ -150,12 +155,19 @@ export default function ColumnMenu() { { label: "Filter…", icon: , - // FIXME: onClick: () => { - // actions.update(column.key, { hidden: !column.hidden }); - // handleClose(); - // }, + onClick: () => { + openTableFiltersPopover({ + defaultQuery: { + key: column.fieldName, + operator: + getFieldProp("filter", column.type)!.operators[0]?.value || "==", + value: "", + }, + }); + handleClose(); + }, active: column.hidden, - disabled: true, + disabled: !getFieldProp("filter", column.type), }, { type: "subheader", label: "All users’ views" }, { @@ -196,9 +208,9 @@ export default function ColumnMenu() { }, active: column.fixed, }, - { type: "subheader", label: "Add column" }, + { type: "subheader", label: "Insert column" }, { - label: "Add new to left…", + label: "Insert to the left…", icon: , onClick: () => { openColumnModal({ type: "new", index: column.index - 1 }); @@ -206,7 +218,7 @@ export default function ColumnMenu() { }, }, { - label: "Add new to right…", + label: "Insert to the right…", icon: , onClick: () => { openColumnModal({ type: "new", index: column.index + 1 }); diff --git a/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx b/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx index 4fae5c31..481c7784 100644 --- a/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx +++ b/src/components/Table/ColumnHeader/ColumnHeaderSort.tsx @@ -118,6 +118,7 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) { viewBox="0 0 24 24" style={{ stroke: "currentColor", + strokeWidth: 2, position: "absolute", inset: (32 - 24) / 2, }} @@ -129,10 +130,9 @@ export default function ColumnHeaderSort({ column }: IColumnHeaderSortProps) { y1="1.04" x2="22.8633788" y2="20.7130253" - stroke-width="2" /> - + diff --git a/src/components/TableToolbar/Filters/FilterInputs.tsx b/src/components/TableToolbar/Filters/FilterInputs.tsx new file mode 100644 index 00000000..d89eb2cc --- /dev/null +++ b/src/components/TableToolbar/Filters/FilterInputs.tsx @@ -0,0 +1,115 @@ +import { Suspense, createElement } from "react"; +import { useForm } from "react-hook-form"; + +import { Grid, MenuItem, TextField, InputLabel } from "@mui/material"; + +import MultiSelect from "@rowy/multiselect"; +import FormAutosave from "@src/components/ColumnModals/ColumnConfigModal/FormAutosave"; +import FieldSkeleton from "@src/components/SideDrawer/Form/FieldSkeleton"; + +import type { useFilterInputs } from "./useFilterInputs"; +import { getFieldType, getFieldProp } from "@src/components/fields"; + +export interface IFilterInputsProps extends ReturnType { + disabled?: boolean; +} + +export default function FilterInputs({ + filterColumns, + selectedColumn, + handleChangeColumn, + availableFilters, + query, + setQuery, + disabled, +}: IFilterInputsProps) { + // Need to use react-hook-form with autosave for the value field, + // since we render the side drawer field for that type + const { control } = useForm({ + mode: "onBlur", + defaultValues: selectedColumn ? { [selectedColumn.key]: query.value } : {}, + }); + + const columnType = selectedColumn ? getFieldType(selectedColumn) : null; + + return ( + + + + + + + { + setQuery((query) => ({ + ...query, + operator: e.target.value as string, + })); + }} + SelectProps={{ displayEmpty: true }} + > + + Select operator + + {availableFilters?.operators.map((operator) => ( + + {operator.label} + + ))} + + + + + {query.key && query.operator && ( +
+ + Value + + + { + if (values[query.key] !== undefined) { + setQuery((query) => ({ + ...query, + value: values[query.key], + })); + } + }} + /> + }> + {columnType && + createElement(getFieldProp("SideDrawerField", columnType), { + column: selectedColumn, + control, + docRef: {}, + disabled, + onChange: () => {}, + })} + + + )} +
+
+ ); +} diff --git a/src/components/TableToolbar/Filters/Filters.tsx b/src/components/TableToolbar/Filters/Filters.tsx new file mode 100644 index 00000000..e95d05a9 --- /dev/null +++ b/src/components/TableToolbar/Filters/Filters.tsx @@ -0,0 +1,484 @@ +import { useState, useEffect } from "react"; +import { useAtom } from "jotai"; +import useMemoValue from "use-memo-value"; +import { isEmpty } from "lodash-es"; + +import { + Tab, + Badge, + Button, + Stack, + Divider, + FormControlLabel, + Checkbox, + Alert, +} from "@mui/material"; +import TabContext from "@mui/lab/TabContext"; +import TabList from "@mui/lab/TabList"; +import TabPanel from "@mui/lab/TabPanel"; + +import FiltersPopover from "./FiltersPopover"; +import FilterInputs from "./FilterInputs"; + +import { + globalScope, + userSettingsAtom, + updateUserSettingsAtom, + userRolesAtom, + tableFiltersPopoverAtom, +} from "@src/atoms/globalScope"; +import { + tableScope, + tableIdAtom, + tableSchemaAtom, + tableColumnsOrderedAtom, + tableFiltersAtom, + tableOrdersAtom, + updateTableSchemaAtom, +} from "@src/atoms/tableScope"; +import { useFilterInputs, INITIAL_QUERY } from "./useFilterInputs"; +import { analytics, logEvent } from "@src/analytics"; +import type { TableFilter } from "@src/types/table"; + +const shouldDisableApplyButton = (value: any) => + isEmpty(value) && + typeof value !== "boolean" && + typeof value !== "number" && + typeof value !== "object"; + +enum FilterType { + yourFilter = "local_filter", + tableFilter = "table_filter", +} + +export default function Filters() { + const [userSettings] = useAtom(userSettingsAtom, globalScope); + const [updateUserSettings] = useAtom(updateUserSettingsAtom, globalScope); + const [userRoles] = useAtom(userRolesAtom, globalScope); + const [tableId] = useAtom(tableIdAtom, tableScope); + const [tableSchema] = useAtom(tableSchemaAtom, tableScope); + const [tableColumnsOrdered] = useAtom(tableColumnsOrderedAtom, tableScope); + const [localFilters, setLocalFilters] = useAtom(tableFiltersAtom, tableScope); + const [, setTableOrders] = useAtom(tableOrdersAtom, tableScope); + const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope); + const [{ defaultQuery }] = useAtom(tableFiltersPopoverAtom, globalScope); + + const tableFilterInputs = useFilterInputs(tableColumnsOrdered); + const setTableQuery = tableFilterInputs.setQuery; + const userFilterInputs = useFilterInputs(tableColumnsOrdered, defaultQuery); + const setUserQuery = userFilterInputs.setQuery; + const { availableFilters } = userFilterInputs; + + // Get table filters & user filters from config documents + const tableFilters = useMemoValue( + tableSchema.filters, + (next, prev) => JSON.stringify(next) === JSON.stringify(prev) + ); + const tableFiltersOverridable = Boolean(tableSchema.filtersOverridable); + const userFilters = tableId + ? userSettings.tables?.[tableId]?.filters + : undefined; + // Helper booleans + const hasTableFilters = + Array.isArray(tableFilters) && tableFilters.length > 0; + const hasUserFilters = Array.isArray(userFilters) && userFilters.length > 0; + + // Set the local table filter + useEffect(() => { + console.log("Filters effect"); + // Set local state for UI + setTableQuery( + Array.isArray(tableFilters) && tableFilters[0] + ? tableFilters[0] + : INITIAL_QUERY + ); + setUserQuery( + Array.isArray(userFilters) && userFilters[0] + ? userFilters[0] + : INITIAL_QUERY + ); + setCanOverrideCheckbox(tableFiltersOverridable); + + let filtersToApply: TableFilter[] = []; + + // Allow override table-level filters with their own + // Set to null to completely ignore table filters + if (tableFiltersOverridable && (hasUserFilters || userFilters === null)) { + filtersToApply = userFilters ?? []; + } else if (hasTableFilters) { + filtersToApply = tableFilters; + } else if (hasUserFilters) { + filtersToApply = userFilters; + } + + setLocalFilters(filtersToApply); + // Reset order so we don’t have to make a new index + setTableOrders([]); + }, [ + hasTableFilters, + hasUserFilters, + setLocalFilters, + setTableOrders, + setTableQuery, + tableFilters, + tableFiltersOverridable, + setUserQuery, + userFilters, + userRoles, + ]); + + // Helper booleans for local table filter state + const appliedFilters = localFilters; + const hasAppliedFilters = Boolean( + appliedFilters && appliedFilters.length > 0 + ); + const tableFiltersOverridden = + (tableFiltersOverridable || userRoles.includes("ADMIN")) && + (hasUserFilters || userFilters === null) && + hasTableFilters; + + // Override table filters + const [canOverrideCheckbox, setCanOverrideCheckbox] = useState( + tableFiltersOverridable + ); + const [tab, setTab] = useState<"user" | "table">( + hasTableFilters && !tableFiltersOverridden && !defaultQuery + ? "table" + : "user" + ); + + // When defaultQuery (from atom) is updated, update the UI + useEffect(() => { + if (defaultQuery) { + setUserQuery(defaultQuery); + setTab("user"); + } + }, [setUserQuery, defaultQuery]); + + const [overrideTableFilters, setOverrideTableFilters] = useState( + tableFiltersOverridden + ); + + // Save table filters to table schema document + const setTableFilters = (filters: TableFilter[]) => { + logEvent(analytics, FilterType.tableFilter); + if (updateTableSchema) + updateTableSchema({ filters, filtersOverridable: canOverrideCheckbox }); + }; + // Save user filters to user document + // null overrides table filters + const setUserFilters = (filters: TableFilter[] | null) => { + logEvent(analytics, FilterType.yourFilter); + if (updateUserSettings && filters) + updateUserSettings({ tables: { [`${tableId}`]: { filters } } }); + }; + + return ( + + {({ handleClose }) => { + // ADMIN + if (userRoles.includes("ADMIN")) { + return ( + + setTab(v)} + variant="fullWidth" + aria-label="Filter tabs" + > + + Your filter + {tableFiltersOverridden && ( + + )} + + } + value="user" + style={{ flexDirection: "row" }} + /> + + Table filter + {tableFiltersOverridden ? ( + + ) : hasTableFilters ? ( + + ) : null} + + } + value="table" + style={{ flexDirection: "row" }} + /> + + + + + + + {hasTableFilters && ( + + setOverrideTableFilters(e.target.checked) + } + /> + } + label="Override table filters" + sx={{ justifyContent: "center", mb: 1, mr: 0 }} + /> + )} + + + + + + + + + + + + setCanOverrideCheckbox(e.target.checked)} + /> + } + label="All users can override table filters" + sx={{ justifyContent: "center", mb: 1, mr: 0 }} + /> + + +
    +
  • + The filter above will be set + {canOverrideCheckbox && " by default"} for all users who + view this table. +
  • + {canOverrideCheckbox ? ( + <> +
  • All users can override this.
  • +
  • Only ADMIN users can edit table filters.
  • + + ) : ( +
  • Only ADMIN users can override or edit this.
  • + )} +
+
+ + + + + + +
+
+ ); + } + + // Non-ADMIN, override disabled + if (hasTableFilters && !tableFiltersOverridable) { + return ( +
+ + + + An ADMIN user has set the filter for this table + +
+ ); + } + + // Non-ADMIN, override enabled + if (hasTableFilters && tableFiltersOverridable) { + return ( +
+ + + setOverrideTableFilters(e.target.checked)} + /> + } + label="Override table filters" + sx={{ justifyContent: "center", mb: 1, mr: 0 }} + /> + + + + + + +
+ ); + } + + // Non-ADMIN, no table filters + return ( +
+ + + + + + + +
+ ); + }} +
+ ); +} diff --git a/src/components/TableToolbar/Filters/FiltersPopover.tsx b/src/components/TableToolbar/Filters/FiltersPopover.tsx new file mode 100644 index 00000000..6f2598c2 --- /dev/null +++ b/src/components/TableToolbar/Filters/FiltersPopover.tsx @@ -0,0 +1,112 @@ +import { useRef } from "react"; +import { useAtom } from "jotai"; + +import { Popover, Stack, Chip } from "@mui/material"; +import FilterIcon from "@mui/icons-material/FilterList"; + +import ButtonWithStatus from "@src/components/ButtonWithStatus"; + +import { globalScope, tableFiltersPopoverAtom } from "@src/atoms/globalScope"; +import type { TableFilter } from "@src/types/table"; +import type { useFilterInputs } from "./useFilterInputs"; + +export interface IFiltersPopoverProps { + appliedFilters: TableFilter[]; + hasAppliedFilters: boolean; + hasTableFilters: boolean; + tableFiltersOverridden: boolean; + availableFilters: ReturnType["availableFilters"]; + setUserFilters: (filters: TableFilter[]) => void; + + children: (props: { handleClose: () => void }) => React.ReactNode; +} + +export default function FiltersPopover({ + appliedFilters, + hasAppliedFilters, + hasTableFilters, + tableFiltersOverridden, + setUserFilters, + availableFilters, + children, +}: IFiltersPopoverProps) { + const [{ open }, setTableFiltersPopoverState] = useAtom( + tableFiltersPopoverAtom, + globalScope + ); + + const anchorEl = useRef(null); + const popoverId = open ? "filters-popover" : undefined; + const handleClose = () => setTableFiltersPopoverState({ open: false }); + + return ( + <> + + setTableFiltersPopoverState({ open: true })} + startIcon={} + active={hasAppliedFilters} + style={ + hasAppliedFilters + ? { + borderTopRightRadius: 0, + borderBottomRightRadius: 0, + position: "relative", + zIndex: 1, + } + : {} + } + aria-describedby={popoverId} + > + {hasAppliedFilters ? "Filtered" : "Filter"} + + + {appliedFilters.map((filter) => ( + setUserFilters([]) + } + sx={{ + borderRadius: 1, + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + borderLeft: "none", + + backgroundColor: "background.paper", + height: 32, + + "& .MuiChip-label": { px: 1.5 }, + }} + variant="outlined" + /> + ))} + + + + {children({ handleClose })} + + + ); +} diff --git a/src/components/TableToolbar/Filters/index.ts b/src/components/TableToolbar/Filters/index.ts new file mode 100644 index 00000000..50c40d2a --- /dev/null +++ b/src/components/TableToolbar/Filters/index.ts @@ -0,0 +1,2 @@ +export * from "./Filters"; +export { default } from "./Filters"; diff --git a/src/components/TableToolbar/Filters/useFilterInputs.ts b/src/components/TableToolbar/Filters/useFilterInputs.ts new file mode 100644 index 00000000..3fd91f0c --- /dev/null +++ b/src/components/TableToolbar/Filters/useFilterInputs.ts @@ -0,0 +1,57 @@ +import { useState } from "react"; +import { find } from "lodash-es"; + +import { getFieldType, getFieldProp } from "@src/components/fields"; +import type { ColumnConfig, TableFilter } from "@src/types/table"; +import type { IFieldConfig } from "@src/components/fields/types"; + +export const INITIAL_QUERY = { key: "", operator: "", value: "" }; + +export const useFilterInputs = ( + columns: ColumnConfig[], + defaultQuery?: TableFilter +) => { + // Get list of columns that can be filtered + const filterColumns = columns + .filter((c) => getFieldProp("filter", getFieldType(c))) + .map((c) => ({ value: c.key, label: c.name, ...c })); + + // State for filter inputs + const [query, setQuery] = useState( + defaultQuery || INITIAL_QUERY + ); + const resetQuery = () => setQuery(INITIAL_QUERY); + + // When the user sets a new column, automatically set the operator and value + const handleChangeColumn = (value: string | null) => { + const column = find(filterColumns, ["key", value]); + + if (column) { + const filter = getFieldProp("filter", getFieldType(column)); + setQuery({ + key: column.key, + operator: filter.operators[0].value, + value: filter.defaultValue ?? "", + }); + } else { + setQuery(INITIAL_QUERY); + } + }; + + // Get the column config + const selectedColumn = find(filterColumns, ["key", query?.key]); + // Get available filters from selected column type + const availableFilters: IFieldConfig["filter"] = selectedColumn + ? getFieldProp("filter", getFieldType(selectedColumn)) + : undefined; + + return { + filterColumns, + selectedColumn, + handleChangeColumn, + availableFilters, + query, + setQuery, + resetQuery, + } as const; +}; diff --git a/src/components/TableToolbar/TableToolbar.tsx b/src/components/TableToolbar/TableToolbar.tsx index 7c935dae..2709fa99 100644 --- a/src/components/TableToolbar/TableToolbar.tsx +++ b/src/components/TableToolbar/TableToolbar.tsx @@ -2,7 +2,7 @@ import { useAtom } from "jotai"; import { Stack } from "@mui/material"; import AddRow from "./AddRow"; -// import Filters from "./Filters"; +import Filters from "./Filters"; import ImportCSV from "./ImportCsv"; // import Export from "./Export"; import LoadedRowsStatus from "./LoadedRowsStatus"; @@ -65,7 +65,7 @@ export default function TableToolbar() { {/* Spacer */}
- {/* */} + {/* Spacer */}
diff --git a/src/types/table.d.ts b/src/types/table.d.ts index 87a3606a..3361d2a1 100644 --- a/src/types/table.d.ts +++ b/src/types/table.d.ts @@ -60,6 +60,7 @@ export type TableSchema = { columns?: Record; rowHeight?: number; filters?: TableFilter[]; + filtersOverridable?: boolean; functionConfigPath?: string; functionBuilderRef?: any; diff --git a/yarn.lock b/yarn.lock index 103319ab..d3caa3bc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11662,7 +11662,7 @@ shallow-copy@~0.0.1: resolved "https://registry.yarnpkg.com/shallow-copy/-/shallow-copy-0.0.1.tgz#415f42702d73d810330292cc5ee86eae1a11a170" integrity sha1-QV9CcC1z2BAzApLMXuhurhoRoXA= -shallowequal@^1.1.0: +"shallowequal@^0.1.0 || ^0.2.0 || ^1.0.0", shallowequal@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== @@ -12790,6 +12790,13 @@ use-latest@^1.2.1: dependencies: use-isomorphic-layout-effect "^1.1.1" +use-memo-value@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/use-memo-value/-/use-memo-value-1.0.1.tgz#71ba4500d143a1cf3ad3f18cc98a59365955f4a1" + integrity sha512-1DqRcvh5XguuuO8QXw9KeAp7MDUKrLPrCv3+GawjFqxjwRJN9FjzayW0Z3RBvWi826l5mim8ov+kCFSTs70svg== + dependencies: + shallowequal "^0.1.0 || ^0.2.0 || ^1.0.0" + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"