diff --git a/src/components/Table/ColumnMenu/FieldSettings/FormAutosave.tsx b/src/components/Table/ColumnMenu/FieldSettings/FormAutosave.tsx index 5ae4646f..52fda33d 100644 --- a/src/components/Table/ColumnMenu/FieldSettings/FormAutosave.tsx +++ b/src/components/Table/ColumnMenu/FieldSettings/FormAutosave.tsx @@ -7,12 +7,17 @@ import { Control, useWatch } from "react-hook-form"; export interface IAutosaveProps { control: Control; handleSave: (values: any) => void; + debounce?: number; } -export default function FormAutosave({ control, handleSave }: IAutosaveProps) { +export default function FormAutosave({ + control, + handleSave, + debounce = 1000, +}: IAutosaveProps) { const values = useWatch({ control }); - const [debouncedValue] = useDebounce(values, 1000, { + const [debouncedValue] = useDebounce(values, debounce, { equalityFn: _isEqual, }); diff --git a/src/components/Table/Filters/index.tsx b/src/components/Table/Filters/index.tsx deleted file mode 100644 index 15e5cc94..00000000 --- a/src/components/Table/Filters/index.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import { useState, useEffect, Suspense, createElement } from "react"; -import _find from "lodash/find"; -import _sortBy from "lodash/sortBy"; -import _isEmpty from "lodash/isEmpty"; -import { useForm } from "react-hook-form"; - -import { - Popover, - Button, - IconButton, - Grid, - MenuItem, - TextField, - Chip, - InputLabel, -} from "@mui/material"; -import FilterIcon from "@mui/icons-material/FilterList"; -import CloseIcon from "@mui/icons-material/Close"; - -import ButtonWithStatus from "@src/components/ButtonWithStatus"; -import FormAutosave from "@src/components/Table/ColumnMenu/FieldSettings/FormAutosave"; -import FieldSkeleton from "@src/components/SideDrawer/Form/FieldSkeleton"; - -import { FieldType } from "@src/constants/fields"; -import { TableFilter } from "@src/hooks/useTable"; -import { useProjectContext } from "@src/contexts/ProjectContext"; -import { useAppContext } from "@src/contexts/AppContext"; -import { DocActions } from "@src/hooks/useDoc"; -import { getFieldProp } from "@src/components/fields"; - -const getType = (column) => - column.type === FieldType.derivative - ? column.config.renderFieldType - : column.type; - -export default function Filters() { - const { tableState, tableActions } = useProjectContext(); - const { userDoc } = useAppContext(); - const [anchorEl, setAnchorEl] = useState(null); - - useEffect(() => { - if (userDoc.state.doc && tableState?.config.id) { - if (userDoc.state.doc.tables?.[tableState?.config.id]?.filters) { - tableActions?.table.filter( - userDoc.state.doc.tables[tableState?.config.id].filters - ); - tableActions?.table.orderBy(); - } - } - }, [userDoc.state, tableState?.config.id]); - - const filterColumns = _sortBy(Object.values(tableState!.columns), "index") - .filter((c) => getFieldProp("filter", c.type)) - .map((c) => ({ - key: c.key, - label: c.name, - type: c.type, - options: c.options, - ...c, - })); - - const [selectedColumn, setSelectedColumn] = useState(); - - const [query, setQuery] = useState({ - key: "", - operator: "", - value: "", - }); - - const [selectedFilter, setSelectedFilter] = useState(); - const type = selectedColumn ? getType(selectedColumn) : null; - useEffect(() => { - if (selectedColumn) { - const _filter = getFieldProp("filter", selectedColumn.type); - setSelectedFilter(_filter); - let updatedQuery: TableFilter = { - key: selectedColumn.key, - operator: _filter.operators[0].value, - value: _filter.defaultValue, - }; - setQuery(updatedQuery); - } - }, [selectedColumn]); - - const handleClose = () => setAnchorEl(null); - - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(anchorEl ? null : event.currentTarget); - }; - - const handleChangeColumn = (e) => { - const column = _find(filterColumns, (c) => c.key === e.target.value); - setSelectedColumn(column); - }; - const open = Boolean(anchorEl); - - const id = open ? "simple-popper" : undefined; - - const handleUpdateFilters = (filters: TableFilter[]) => { - userDoc.dispatch({ - action: DocActions.update, - data: { - tables: { [`${tableState?.config.id}`]: { filters } }, - }, - }); - }; - - const { control } = useForm({ - mode: "onBlur", - }); - return ( - <> - - } - active={tableState?.filters && tableState?.filters.length > 0} - sx={ - tableState?.filters && tableState?.filters.length > 0 - ? { - borderTopRightRadius: 0, - borderBottomRightRadius: 0, - position: "relative", - zIndex: 1, - } - : {} - } - > - {tableState?.filters && tableState?.filters.length > 0 - ? "Filtered" - : "Filter"} - - - {(tableState?.filters ?? []).map((filter) => ( - handleUpdateFilters([])} - sx={{ - borderRadius: 1, - borderTopLeftRadius: 0, - borderBottomLeftRadius: 0, - borderLeft: "none", - - backgroundColor: "background.paper", - height: 32, - - "& .MuiChip-label": { px: 1.5 }, - }} - variant="outlined" - /> - ))} - - - theme.spacing(0.5), - right: (theme) => theme.spacing(0.5), - }} - > - - -
- - - - - Select column - - {filterColumns.map((c) => ( - - {c.label} - - ))} - - - - - { - setQuery((query) => ({ - ...query, - operator: e.target.value as string, - })); - }} - SelectProps={{ displayEmpty: true }} - > - - Select condition - - {selectedFilter?.operators.map((operator) => ( - - {operator.label} - - ))} - - - - {query.key && query.operator && ( -
- - Value - - - - setQuery((query) => ({ - ...query, - value: values[query.key], - })) - } - /> - }> - {query.operator && - createElement(getFieldProp("SideDrawerField", type), { - column: selectedColumn, - control, - docRef: {}, - disabled: false, - onChange: () => {}, - })} - - - )} -
-
- - - - - - - - -
-
- - ); -} diff --git a/src/components/TableHeader/Filters/FilterInputs.tsx b/src/components/TableHeader/Filters/FilterInputs.tsx new file mode 100644 index 00000000..4755d26a --- /dev/null +++ b/src/components/TableHeader/Filters/FilterInputs.tsx @@ -0,0 +1,121 @@ +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/Table/ColumnMenu/FieldSettings/FormAutosave"; +import FieldSkeleton from "@src/components/SideDrawer/Form/FieldSkeleton"; + +import type { useFilterInputs } from "./useFilterInputs"; +import { FieldType } from "@src/constants/fields"; +import { 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 } : {}, + }); + + // Get column type to render for the value field + const columnType = selectedColumn + ? selectedColumn.type === FieldType.derivative + ? selectedColumn.config.renderFieldType + : selectedColumn.type + : 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], + })); + } + }} + /> + }> + {createElement(getFieldProp("SideDrawerField", columnType), { + column: selectedColumn, + control, + docRef: {}, + disabled, + onChange: () => {}, + })} + + + )} +
+
+ ); +} diff --git a/src/components/TableHeader/Filters/FiltersPopover.tsx b/src/components/TableHeader/Filters/FiltersPopover.tsx new file mode 100644 index 00000000..146da7a1 --- /dev/null +++ b/src/components/TableHeader/Filters/FiltersPopover.tsx @@ -0,0 +1,106 @@ +import { useRef, useState } from "react"; + +import { Popover, Stack, Chip } from "@mui/material"; +import FilterIcon from "@mui/icons-material/FilterList"; + +import ButtonWithStatus from "@src/components/ButtonWithStatus"; + +import type { TableFilter } from "@src/hooks/useTable"; +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 anchorEl = useRef(null); + const [open, setOpen] = useState(false); + const popoverId = open ? "filters-popover" : undefined; + const handleClose = () => setOpen(false); + + return ( + <> + + setOpen(true)} + startIcon={} + active={hasAppliedFilters} + sx={ + 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/TableHeader/Filters/index.tsx b/src/components/TableHeader/Filters/index.tsx new file mode 100644 index 00000000..0f4cf204 --- /dev/null +++ b/src/components/TableHeader/Filters/index.tsx @@ -0,0 +1,332 @@ +import { useState, useEffect } from "react"; +import _isEmpty from "lodash/isEmpty"; + +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 { useFilterInputs, INITIAL_QUERY } from "./useFilterInputs"; +import type { TableFilter } from "@src/hooks/useTable"; +import { useProjectContext } from "@src/contexts/ProjectContext"; +import { useAppContext } from "@src/contexts/AppContext"; +import { DocActions } from "@src/hooks/useDoc"; + +const shouldDisableApplyButton = (value: any) => + _isEmpty(value) && + typeof value !== "boolean" && + typeof value !== "number" && + typeof value !== "object"; + +export default function Filters() { + const { table, tableState, tableActions } = useProjectContext(); + const { userDoc, userClaims } = useAppContext(); + + const tableFilterInputs = useFilterInputs(tableState?.columns || []); + const userFilterInputs = useFilterInputs(tableState?.columns || []); + const { availableFilters } = userFilterInputs; + + // Get table filters & user filters from config documents + const tableId = table?.id; + const userDocData = userDoc.state.doc; + const tableSchemaDoc = tableState?.config?.tableConfig?.doc; + const tableFilters = tableSchemaDoc?.filters; + const userFilters = tableId + ? userDocData.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(() => { + // Set local state for UI + tableFilterInputs.setQuery( + Array.isArray(tableFilters) && tableFilters[0] + ? tableFilters[0] + : INITIAL_QUERY + ); + userFilterInputs.setQuery( + Array.isArray(userFilters) && userFilters[0] + ? userFilters[0] + : INITIAL_QUERY + ); + + if (!tableActions) return; + + let filtersToApply: TableFilter[] = []; + + // Allow admin to override table-level filters with their own + // Set to null to show all filters for the admin user + if ( + userClaims?.roles.includes("ADMIN") && + (hasUserFilters || userFilters === null) + ) { + filtersToApply = userFilters ?? []; + } else if (hasTableFilters) { + filtersToApply = tableFilters; + } else if (hasUserFilters) { + filtersToApply = userFilters; + } + + tableActions.table.filter(filtersToApply); + // Reset order so we don’t have to make a new index + tableActions.table.orderBy(); + }, [tableFilters, userFilters, userClaims?.roles]); + + // Helper booleans for local table filter state + const appliedFilters = tableState?.filters || []; + const hasAppliedFilters = Boolean( + appliedFilters && appliedFilters.length > 0 + ); + const tableFiltersOverridden = + userClaims?.roles.includes("ADMIN") && + (hasUserFilters || userFilters === null) && + hasTableFilters; + + // ADMIN overrides + const [tab, setTab] = useState<"user" | "table">( + hasTableFilters && !tableFiltersOverridden ? "table" : "user" + ); + const [overrideTableFilters, setOverrideTableFilters] = useState( + tableFiltersOverridden + ); + + // Save table filters to table schema document + const setTableFilters = (filters: TableFilter[]) => { + tableActions?.table.updateConfig("filters", filters); + }; + // Save user filters to user document + // null overrides table filters - only available to ADMINs + const setUserFilters = (filters: TableFilter[] | null) => { + userDoc.dispatch({ + action: DocActions.update, + data: { + tables: { [`${tableState?.config.id}`]: { filters } }, + }, + }); + }; + + return ( + + {({ handleClose }) => + // ADMIN + userClaims?.roles.includes("ADMIN") ? ( + + 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" }} + /> + + + + + + + setOverrideTableFilters(e.target.checked)} + /> + } + label="Override table filters" + sx={{ justifyContent: "center", mb: 1, mr: 0 }} + /> + + + + + + + + + + + + + The filter above will be set for all users who view this table. + Only ADMIN users can override or edit this. + + + + + + + + + + ) : // Non-ADMIN cannot override table filters + hasTableFilters ? ( +
+ + + + An ADMIN user has set the filter for this table + +
+ ) : ( + // Non-ADMIN can set own filters, since there are no table filters +
+ + + + + + + +
+ ) + } +
+ ); +} diff --git a/src/components/TableHeader/Filters/useFilterInputs.ts b/src/components/TableHeader/Filters/useFilterInputs.ts new file mode 100644 index 00000000..4dffc6ab --- /dev/null +++ b/src/components/TableHeader/Filters/useFilterInputs.ts @@ -0,0 +1,50 @@ +import { useState } from "react"; +import _find from "lodash/find"; +import _sortBy from "lodash/sortBy"; + +import { TableState, TableFilter } from "@src/hooks/useTable"; +import { getFieldProp } from "@src/components/fields"; + +export const INITIAL_QUERY = { key: "", operator: "", value: "" }; + +export const useFilterInputs = (columns: TableState["columns"]) => { + // Get list of columns that can be filtered + const filterColumns = _sortBy(Object.values(columns), "index") + .filter((c) => getFieldProp("filter", c.type)) + .map((c) => ({ value: c.key, label: c.name, ...c })); + + // State for filter inputs + const [query, setQuery] = useState(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", column.type); + 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 = getFieldProp("filter", selectedColumn?.type); + + return { + filterColumns, + selectedColumn, + handleChangeColumn, + availableFilters, + query, + setQuery, + resetQuery, + } as const; +}; diff --git a/src/components/Table/HiddenFields.tsx b/src/components/TableHeader/HiddenFields.tsx similarity index 100% rename from src/components/Table/HiddenFields.tsx rename to src/components/TableHeader/HiddenFields.tsx diff --git a/src/components/TableHeader/index.tsx b/src/components/TableHeader/index.tsx index c07ea563..400f7ee8 100644 --- a/src/components/TableHeader/index.tsx +++ b/src/components/TableHeader/index.tsx @@ -3,13 +3,13 @@ import { Stack } from "@mui/material"; import { isCollectionGroup } from "@src/utils/fns"; import AddRow from "./AddRow"; -import Filters from "../Table/Filters"; +import Filters from "./Filters"; import ImportCSV from "./ImportCsv"; import Export from "./Export"; import LoadedRowsStatus from "./LoadedRowsStatus"; import TableSettings from "./TableSettings"; import CloudLogs from "./CloudLogs"; -import HiddenFields from "../Table/HiddenFields"; +import HiddenFields from "./HiddenFields"; import RowHeight from "./RowHeight"; import Extensions from "./Extensions"; import Webhooks from "./Webhooks"; diff --git a/src/hooks/useTable/index.ts b/src/hooks/useTable/index.ts index 4b476be3..0dabda0f 100644 --- a/src/hooks/useTable/index.ts +++ b/src/hooks/useTable/index.ts @@ -15,7 +15,7 @@ export type TableActions = { table: { set: (id: string, collection: string, filters: TableFilter[]) => void; filter: Function; - updateConfig: Function; + updateConfig: (key: string, value: any, callback?: Function) => void; orderBy: Function; }; }; diff --git a/src/hooks/useTable/useTableConfig.ts b/src/hooks/useTable/useTableConfig.ts index 7e2dd3ee..cb9fcbaf 100644 --- a/src/hooks/useTable/useTableConfig.ts +++ b/src/hooks/useTable/useTableConfig.ts @@ -154,7 +154,7 @@ const useTableConfig = (tableId?: string) => { * @param key name of parameter eg. rowHeight * @param value new value eg. 65 */ - const updateConfig = (key: string, value: unknown, callback?: Function) => { + const updateConfig = (key: string, value: any, callback?: Function) => { documentDispatch({ action: DocActions.update, data: { [key]: value }, diff --git a/src/theme/components.tsx b/src/theme/components.tsx index 21cadbbb..ab7a0f74 100644 --- a/src/theme/components.tsx +++ b/src/theme/components.tsx @@ -35,6 +35,11 @@ declare module "@mui/material/MenuItem" { error: true; } } +declare module "@mui/material/Badge" { + interface BadgePropsVariantOverrides { + inlineDot: true; + } +} export const components = (theme: Theme): ThemeOptions => { const buttonPrimaryHover = colord(theme.palette.primary.main) @@ -1002,6 +1007,10 @@ export const components = (theme: Theme): ThemeOptions => { marginTop: 4, }, }, + + "&:hover .MuiCheckbox-root, &:hover .MuiRadio-root": { + backgroundColor: theme.palette.action.hover, + }, }, label: { marginTop: 10, @@ -1068,6 +1077,28 @@ export const components = (theme: Theme): ThemeOptions => { }, }, + MuiBadge: { + variants: [ + { + props: { variant: "inlineDot" }, + style: { + marginLeft: theme.spacing(1), + marginRight: theme.spacing(-1), + + "& .MuiBadge-badge": { + position: "static", + transform: "none", + + minWidth: theme.spacing(1), + height: theme.spacing(1), + borderRadius: theme.spacing(0.5), + padding: 0, + }, + }, + }, + ], + }, + MuiAlertTitle: { styleOverrides: { root: {