diff --git a/src/atoms/tableScope/table.ts b/src/atoms/tableScope/table.ts index f8d519c4..b88e34c4 100644 --- a/src/atoms/tableScope/table.ts +++ b/src/atoms/tableScope/table.ts @@ -59,6 +59,8 @@ export const tableColumnsReducer = ( /** Filters applied to the local view */ export const tableFiltersAtom = atom([]); +/** Join operator applied to mulitple filters */ +export const tableFiltersJoinAtom = atom<"AND" | "OR">("AND"); /** Sorts applied to the local view */ export const tableSortsAtom = atom([]); diff --git a/src/components/ColumnMenu/ColumnMenu.tsx b/src/components/ColumnMenu/ColumnMenu.tsx index b1944703..e8784879 100644 --- a/src/components/ColumnMenu/ColumnMenu.tsx +++ b/src/components/ColumnMenu/ColumnMenu.tsx @@ -59,6 +59,7 @@ import { getFieldProp } from "@src/components/fields"; import { analytics, logEvent } from "@src/analytics"; import { formatSubTableName, + generateId, getTableBuildFunctionPathname, getTableSchemaPath, } from "@src/utils/table"; @@ -251,6 +252,7 @@ export default function ColumnMenu({ : column.type )!.operators[0]?.value || "==", value: "", + id: generateId(), }, }); handleClose(); diff --git a/src/components/Table/ContextMenu/MenuContents.tsx b/src/components/Table/ContextMenu/MenuContents.tsx index 6f934c26..c54024b8 100644 --- a/src/components/Table/ContextMenu/MenuContents.tsx +++ b/src/components/Table/ContextMenu/MenuContents.tsx @@ -39,6 +39,7 @@ import { } from "@src/atoms/tableScope"; import { FieldType } from "@src/constants/fields"; import { TableRow } from "@src/types/table"; +import { generateId } from "@src/utils/table"; interface IMenuContentsProps { onClose: () => void; @@ -281,6 +282,7 @@ export default function MenuContents({ onClose }: IMenuContentsProps) { key: selectedColumn.fieldName, operator: columnFilters!.operators[0]?.value || "==", value: cellValue, + id: generateId(), }, ]; diff --git a/src/components/Table/Table.tsx b/src/components/Table/Table.tsx index e512fda8..9be648f1 100644 --- a/src/components/Table/Table.tsx +++ b/src/components/Table/Table.tsx @@ -316,12 +316,7 @@ export default function Table({ // for large screen heights useEffect(() => { fetchMoreOnBottomReached(containerRef.current); - }, [ - fetchMoreOnBottomReached, - tablePage, - tableNextPage.loading, - containerRef, - ]); + }, [fetchMoreOnBottomReached, tableNextPage.loading, containerRef]); // apply user default sort on first render const [applySort, setApplySort] = useState(true); diff --git a/src/components/TableToolbar/Filters/FilterInputs.tsx b/src/components/TableToolbar/Filters/FilterInputs.tsx index 24fa1b55..d236bf1c 100644 --- a/src/components/TableToolbar/Filters/FilterInputs.tsx +++ b/src/components/TableToolbar/Filters/FilterInputs.tsx @@ -10,6 +10,7 @@ import { Typography, TextField, InputLabel, + IconButton, } from "@mui/material"; import ColumnSelect from "@src/components/Table/ColumnSelect"; @@ -17,11 +18,28 @@ import FieldSkeleton from "@src/components/SideDrawer/FieldSkeleton"; import IdFilterInput from "./IdFilterInput"; import { InlineErrorFallback } from "@src/components/ErrorFallback"; -import type { useFilterInputs } from "./useFilterInputs"; import { getFieldType, getFieldProp } from "@src/components/fields"; +import type { IFieldConfig } from "@src/components/fields/types"; -export interface IFilterInputsProps extends ReturnType { +import { TableFilter } from "@src/types/table"; + +import DeleteIcon from "@mui/icons-material/Delete"; +import DragIndicatorOutlinedIcon from "@mui/icons-material/DragIndicatorOutlined"; +import { useFilterInputs } from "./useFilterInputs"; + +export interface IFilterInputsProps { + filterColumns: ReturnType["filterColumns"]; + selectedColumn: ReturnType["filterColumns"][0]; + availableFilters: IFieldConfig["filter"]; disabled?: boolean; + query: TableFilter; + setQuery: (query: TableFilter) => void; + handleDelete: () => void; + index: number; + noOfQueries: number; + handleChangeColumn: (column: string) => void; + joinOperator: "AND" | "OR"; + setJoinOperator: (operator: "AND" | "OR") => void; } export default function FilterInputs({ @@ -32,6 +50,11 @@ export default function FilterInputs({ query, setQuery, disabled, + joinOperator, + setJoinOperator, + handleDelete, + index, + noOfQueries, }: IFilterInputsProps) { const columnType = selectedColumn ? getFieldType(selectedColumn) : null; @@ -76,18 +99,18 @@ export default function FilterInputs({ return ( - + handleChangeColumn(newKey ?? "")} disabled={disabled} /> - + { - setQuery((query) => ({ + const newQuery = { ...query, - operator: e.target.value as string, - })); + operator: e.target.value as TableFilter["operator"], + }; + + setQuery(newQuery); }} SelectProps={{ displayEmpty: true }} - sx={{ "& .MuiSelect-select": { display: "flex" } }} + sx={{ + "& .MuiSelect-select": { display: "flex" }, + }} > Select operator @@ -113,39 +140,107 @@ export default function FilterInputs({ - - {query.key && query.operator && ( - - - Value - + + {query.key && + query.operator && + query.operator !== "is-empty" && + query.operator != "is-not-empty" && ( + + + Value + - }> - {columnType && - createElement( - query.key === "_rowy_ref.id" - ? IdFilterInput - : getFieldProp("filter.customInput" as any, columnType) || - getFieldProp("SideDrawerField", columnType), - { - column: selectedColumn, - _rowy_ref: {}, - value: query.value, - onChange: (value: any) => { - setQuery((query) => ({ ...query, value })); - }, - disabled, - operator: query.operator, - } - )} - - - )} + }> + {columnType && + createElement( + query.key === "_rowy_ref.id" + ? IdFilterInput + : getFieldProp("filter.customInput" as any, columnType) || + getFieldProp("SideDrawerField", columnType), + { + column: selectedColumn, + _rowy_ref: {}, + value: query.value, + onChange: (value: any) => { + const newQuery = { + ...query, + value, + }; + setQuery(newQuery); + }, + disabled, + operator: query.operator, + } + )} + + + )} + + + + {} + + + + + {noOfQueries > 1 && + index !== noOfQueries - 1 && + (index === 0 ? ( + + + + + setJoinOperator(e.target.value === "AND" ? "AND" : "OR") + } + sx={{ + "& .MuiSelect-select": { + display: "flex", + }, + }} + > + And + Or + + + + + ) : ( + + + {joinOperator === "AND" ? "And" : "Or"} + + + ))} ); } diff --git a/src/components/TableToolbar/Filters/FilterInputsCollection.tsx b/src/components/TableToolbar/Filters/FilterInputsCollection.tsx new file mode 100644 index 00000000..0f23f29d --- /dev/null +++ b/src/components/TableToolbar/Filters/FilterInputsCollection.tsx @@ -0,0 +1,128 @@ +import FilterInputs from "./FilterInputs"; + +import { Button } from "@mui/material"; + +import type { useFilterInputs } from "./useFilterInputs"; + +import { DragDropContext, Draggable, Droppable } from "react-beautiful-dnd"; +import AddIcon from "@mui/icons-material/Add"; +import { find } from "lodash-es"; +import { TableFilter } from "@src/types/table"; +import { generateId } from "@src/utils/table"; + +export interface IFilterInputsCollectionProps + extends ReturnType { + disabled?: boolean; +} + +export default function FilterInputsCollection({ + filterColumns, + selectedColumns, + handleColumnChange, + availableFiltersForEachSelectedColumn, + queries, + setQueries, + disabled, + joinOperator, + setJoinOperator, +}: IFilterInputsCollectionProps) { + const onDragEnd = (result: any) => { + if (!result.destination) return; + + setQueries((prevQueries) => { + const newQueries = [...prevQueries]; + const [reorderedItem] = newQueries.splice(result.source.index, 1); + newQueries.splice(result.destination.index, 0, reorderedItem); + return newQueries; + }); + }; + + return ( + <> + + + {(provided) => ( +
+ {queries.map((query, index) => { + return ( + + {(provided) => ( +
+ { + handleColumnChange(query.id, key); + }} + availableFilters={ + availableFiltersForEachSelectedColumn[index] + } + query={query} + setQuery={(newQuery: TableFilter) => { + setQueries((prevQueries) => { + const newQueries = [...prevQueries]; + newQueries[index] = newQuery; + return newQueries; + }); + }} + disabled={disabled} + joinOperator={joinOperator} + setJoinOperator={setJoinOperator} + handleDelete={() => { + setQueries((prevQueries) => { + const newQueries = [...prevQueries]; + newQueries.splice(index, 1); + return newQueries; + }); + }} + index={index} + noOfQueries={queries.length} + /> +
+ )} +
+ ); + })} + {provided.placeholder} +
+ )} +
+
+ + + + ); +} diff --git a/src/components/TableToolbar/Filters/Filters.tsx b/src/components/TableToolbar/Filters/Filters.tsx index 868d6f86..48ce54ef 100644 --- a/src/components/TableToolbar/Filters/Filters.tsx +++ b/src/components/TableToolbar/Filters/Filters.tsx @@ -1,10 +1,7 @@ -/* eslint-disable react-hooks/exhaustive-deps */ import { useState, useEffect } from "react"; -import { useAtom } from "jotai"; +import { useAtom, useSetAtom } from "jotai"; import useMemoValue from "use-memo-value"; import { isEmpty, isDate } from "lodash-es"; -import { useSearchParams } from "react-router-dom"; -import { useSnackbar } from "notistack"; import { Tab, @@ -21,8 +18,7 @@ import TabList from "@mui/lab/TabList"; import TabPanel from "@mui/lab/TabPanel"; import FiltersPopover from "./FiltersPopover"; -import FilterInputs from "./FilterInputs"; -import { changePageUrl, separateOperands } from "./utils"; +import FilterInputsCollection from "./FilterInputsCollection"; import { projectScope, @@ -39,16 +35,31 @@ import { tableSortsAtom, updateTableSchemaAtom, tableFiltersPopoverAtom, + tableFiltersJoinAtom, } from "@src/atoms/tableScope"; -import { useFilterInputs, INITIAL_QUERY } from "./useFilterInputs"; +import { useFilterInputs } from "./useFilterInputs"; import { analytics, logEvent } from "@src/analytics"; import type { TableFilter } from "@src/types/table"; +import { generateId } from "@src/utils/table"; +import { useFilterUrl } from "./useFilterUrl"; -const shouldDisableApplyButton = (value: any) => - isEmpty(value) && - !isDate(value) && - typeof value !== "boolean" && - typeof value !== "number"; +const shouldDisableApplyButton = (queries: any) => { + for (let query of queries) { + if (query.operator === "is-empty" || query.operator === "is-not-empty") { + continue; + } + + if ( + isEmpty(query.value) && + !isDate(query.value) && + typeof query.value !== "boolean" && + typeof query.value !== "number" + ) + return true; + } + + return false; +}; enum FilterType { yourFilter = "local_filter", @@ -66,17 +77,16 @@ export default function Filters() { const [, setTableSorts] = useAtom(tableSortsAtom, tableScope); const [updateTableSchema] = useAtom(updateTableSchemaAtom, tableScope); const [{ defaultQuery }] = useAtom(tableFiltersPopoverAtom, tableScope); + const tableFilterInputs = useFilterInputs(tableColumnsOrdered); - const setTableQuery = tableFilterInputs.setQuery; + const setTableQueries = tableFilterInputs.setQueries; const userFilterInputs = useFilterInputs(tableColumnsOrdered, defaultQuery); - const setUserQuery = userFilterInputs.setQuery; - const { availableFilters, filterColumns } = userFilterInputs; - const [searchParams] = useSearchParams(); - const { enqueueSnackbar } = useSnackbar(); - useEffect(() => { - let isFiltered = searchParams.get("filter"); - if (isFiltered) updateUserFilter(isFiltered); - }, [searchParams]); + const setUserQueries = userFilterInputs.setQueries; + const { availableFiltersForEachSelectedColumn } = userFilterInputs; + const availableFiltersForFirstColumn = + availableFiltersForEachSelectedColumn[0]; + + const setTableFiltersJoin = useSetAtom(tableFiltersJoinAtom, tableScope); // Get table filters & user filters from config documents const tableFilters = useMemoValue( @@ -91,58 +101,30 @@ export default function Filters() { const hasTableFilters = Array.isArray(tableFilters) && tableFilters.length > 0; const hasUserFilters = Array.isArray(userFilters) && userFilters.length > 0; - function updateUserFilter(str: string) { - let { operators, operands = [] } = separateOperands(str); - if (!operators.length) return; - if (operators.length) { - let appliedFilter: TableFilter[] = []; - appliedFilter = [ - { - key: operands[0], - operator: operators[0], - value: Number(operands[1]), - }, - ]; - let isValidFilter = checkFilterValidation(appliedFilter[0]); - if (isValidFilter) { - setOverrideTableFilters(true); - setUserFilters(appliedFilter); - } else { - enqueueSnackbar("Oops, Invalid filter!!!", { variant: "error" }); - setUserFilters([]); - setOverrideTableFilters(false); - userFilterInputs.resetQuery(); - } - } - } - function checkFilterValidation(filter: TableFilter): boolean { - let isFilterableColumn = filterColumns?.filter( - (item) => - item.key === filter.key || - item.label === filter.key || - item.type === filter.key - ); - if (!isFilterableColumn?.length) return false; - filter.key = isFilterableColumn?.[0]?.value; - filter.operator = filter.operator === "-is-" ? "id-equal" : filter.operator; - filter.value = - filter.operator === "id-equal" ? filter.value.toString() : filter.value; - return true; - } // Set the local table filter useEffect(() => { // 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 - ); + if ( + Array.isArray(tableFilters) && + tableFilters && + tableFilters.length > 0 + ) { + // Older filters do not have ID. Migrating them here. + for (const filter of tableFilters) { + if (!filter.id) filter.id = generateId(); + } + setTableQueries(tableFilters); + } + + if (Array.isArray(userFilters) && userFilters && userFilters.length > 0) { + // Older filters do not have ID. Migrating them here. + for (const filter of userFilters) { + if (!filter.id) filter.id = generateId(); + } + setUserQueries(userFilters); + } + setCanOverrideCheckbox(tableFiltersOverridable); let filtersToApply: TableFilter[] = []; @@ -156,7 +138,7 @@ export default function Filters() { } else if (hasUserFilters) { filtersToApply = userFilters; } - updatePageURL(filtersToApply); + setLocalFilters(filtersToApply); // Reset order so we don’t have to make a new index if (filtersToApply.length) { @@ -167,9 +149,10 @@ export default function Filters() { hasUserFilters, setLocalFilters, setTableSorts, + setTableQueries, tableFilters, tableFiltersOverridable, - setUserQuery, + setUserQueries, userFilters, userRoles, ]); @@ -197,50 +180,104 @@ export default function Filters() { // When defaultQuery (from atom) is updated, update the UI useEffect(() => { if (defaultQuery) { - setUserQuery(defaultQuery); + setUserQueries([defaultQuery]); setTab("user"); } - }, [setUserQuery, defaultQuery]); + }, [setUserQueries, defaultQuery]); const [overrideTableFilters, setOverrideTableFilters] = useState( tableFiltersOverridden ); + useEffect(() => { + if (userSettings.tables?.[tableId]?.joinOperator) { + userFilterInputs.setJoinOperator( + userSettings.tables?.[tableId]?.joinOperator === "AND" ? "AND" : "OR" + ); + } + + if (tableSchema.joinOperator) { + tableFilterInputs.setJoinOperator( + tableSchema.joinOperator === "AND" ? "AND" : "OR" + ); + } + }, [userSettings.tables?.[tableId]?.joinOperator, tableSchema.joinOperator]); + + useEffect(() => { + if (tableFiltersOverridable && (hasUserFilters || userFilters === null)) { + setTableFiltersJoin( + userSettings.tables?.[tableId]?.joinOperator === "AND" ? "AND" : "OR" + ); + } else if (hasTableFilters) { + setTableFiltersJoin(tableSchema.joinOperator === "AND" ? "AND" : "OR"); + } else if (hasUserFilters) { + setTableFiltersJoin( + userSettings.tables?.[tableId]?.joinOperator === "AND" ? "AND" : "OR" + ); + } + }, [ + tableFiltersOverridable, + hasUserFilters, + hasTableFilters, + userFilters, + tableSchema.joinOperator, + userSettings.tables?.[tableId]?.joinOperator, + ]); + // Save table filters to table schema document - const setTableFilters = (filters: TableFilter[]) => { + const setTableFilters = ( + filters: TableFilter[], + op: "AND" | "OR" = "AND" + ) => { logEvent(analytics, FilterType.tableFilter); if (updateTableSchema) - updateTableSchema({ filters, filtersOverridable: canOverrideCheckbox }); + updateTableSchema({ + filters, + filtersOverridable: canOverrideCheckbox, + joinOperator: op, + }); }; // Save user filters to user document // null overrides table filters - const setUserFilters = (filters: TableFilter[] | null) => { + const setUserFilters = ( + filters: TableFilter[] | null, + op: "AND" | "OR" = "AND" + ) => { logEvent(analytics, FilterType.yourFilter); if (updateUserSettings && filters) - updateUserSettings({ tables: { [`${tableId}`]: { filters } } }); + updateUserSettings({ + tables: { [`${tableId}`]: { filters, joinOperator: op } }, + }); }; - function updatePageURL(filters: TableFilter[]) { - if (!filters.length) { - changePageUrl(); - } else { - const [filter] = filters; - const fieldName = filter.key === "_rowy_ref.id" ? "ID" : filter.key; - const operator = - filter.operator === "id-equal" ? "-is-" : filter.operator; - const formattedValue = availableFilters?.valueFormatter - ? availableFilters.valueFormatter(filter.value, filter.operator) - : filter.value.toString(); - const queryParams = `?filter=${fieldName}${operator}${formattedValue}`; - changePageUrl(queryParams); + + const { filtersUrl, updateFilterQueryParam } = useFilterUrl(); + + // If the filter in URL is not the same as currently applied local filter + // then update the user filter. + useEffect(() => { + if ( + filtersUrl && + JSON.stringify(filtersUrl) !== JSON.stringify(appliedFilters) + ) { + setUserFilters(filtersUrl); + setOverrideTableFilters(true); } - } + }, [filtersUrl]); + + // Update queyy param if the locally applied filter changes + useEffect(() => { + if (appliedFilters) { + updateFilterQueryParam(appliedFilters); + } + }, [appliedFilters]); + return ( {({ handleClose }) => { @@ -305,7 +342,7 @@ export default function Filters() { - + {hasTableFilters && (