diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilter.tsx b/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilter.tsx new file mode 100644 index 000000000..ddb56ac1d --- /dev/null +++ b/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilter.tsx @@ -0,0 +1,80 @@ +import { Button } from '@/components/ui/v3/button'; +import { Input } from '@/components/ui/v3/input'; +import { + useDataGridFilter, + type DataGridFilterOperator, +} from '@/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider'; +import { cn, isNotEmptyValue } from '@/lib/utils'; +import { X } from 'lucide-react'; +import DataGridFilterColumn from './DataGridFilterColumn'; +import DataGridFilterOperators from './DataGridFilterOperators'; + +type FilterProps = { + column: string; + op: DataGridFilterOperator; + value: string; + index: number; + columns: Array<{ id: string; dataType: string }>; + error?: string; +}; + +function DataGridFilter({ + column, + op, + value, + index, + columns, + error, +}: FilterProps) { + const { setColumn, setOp, setValue, removeFilter } = useDataGridFilter(); + + function handleOpChange(newOp: DataGridFilterOperator) { + setOp(index, newOp); + if ( + newOp === '$like' || + newOp === '$ilike' || + newOp === '$nlike' || + newOp === '$nilike' + ) { + setValue(index, '%%'); + } else if (newOp === '$in' || newOp === '$nin') { + setValue(index, '[]'); + } + } + + return ( +
+ setColumn(index, newColumn)} + columns={columns} + /> + +
+ setValue(index, event.target.value)} + /> + + {error} + +
+ +
+ ); +} + +export default DataGridFilter; diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilterColumn.tsx b/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilterColumn.tsx new file mode 100644 index 000000000..e7adf0c9e --- /dev/null +++ b/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilterColumn.tsx @@ -0,0 +1,42 @@ +import { Badge } from '@/components/ui/v3/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/v3/select'; + +type DataFilterColumnProps = { + value: string; + onChange: (newValue: string) => void; + columns: Array<{ id: string; dataType: string }>; +}; + +function DataGrdiFitlerColumn({ + value, + onChange, + columns, +}: DataFilterColumnProps) { + return ( + + ); +} + +export default DataGrdiFitlerColumn; diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilterOperators.tsx b/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilterOperators.tsx new file mode 100644 index 000000000..490c80f27 --- /dev/null +++ b/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilterOperators.tsx @@ -0,0 +1,50 @@ +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, +} from '@/components/ui/v3/select'; +import type { DataGridFilterOperator } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider'; + +const OPERATORS = [ + { op: '$eq', label: '[$eq] equals' }, + { op: '$ne', label: '[$ne] not equals' }, + { op: '$in', label: '[$in] in' }, + { op: '$nin', label: '[$nin] not in' }, + { op: '$gt', label: '[$gt] >' }, + { op: '$lt', label: '[$lt] <' }, + { op: '$gte', label: '[$gte] >=' }, + { op: '$lte', label: '[$lte] <=' }, + { op: '$like', label: '[$like] like' }, + { op: '$nlike', label: '[$nlike] not like' }, + { op: '$ilike', label: '[$ilike] like (case-insensitive)' }, + { op: '$nilike', label: '[$nilike] not like (case-insensitive)' }, + { op: '$similar', label: '[$similar] similar' }, + { op: '$nsimilar', label: '[$nsimilar] not similar' }, + { op: '$regex', label: '[$regex] ~' }, + { op: '$iregex', label: '[$iregex] ~*' }, + { op: '$nregex', label: '[$nregex] !~' }, + { op: '$niregex', label: '[$niregex] !~*' }, +]; + +type DataFilterProps = { + value: DataGridFilterOperator; + onChange: (newOp: DataGridFilterOperator) => void; +}; + +function DataGridOperators({ value, onChange }: DataFilterProps) { + return ( + + ); +} + +export default DataGridOperators; diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilterTrigger.tsx b/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilterTrigger.tsx new file mode 100644 index 000000000..9c5161832 --- /dev/null +++ b/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilterTrigger.tsx @@ -0,0 +1,34 @@ +import { Button, type ButtonProps } from '@/components/ui/v3/button'; +import { useDataGridFilter } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider'; +import { cn } from '@/lib/utils'; +import { Funnel } from 'lucide-react'; +import { type ForwardedRef, forwardRef } from 'react'; + +function DataBrowserCustomizerTrigger( + props: ButtonProps, + ref: ForwardedRef, +) { + const { appliedFilters } = useDataGridFilter(); + const numberOfAppliedFilters = appliedFilters.length; + + const { className, ...buttonProps } = props; + + return ( + + ); +} + +export default forwardRef(DataBrowserCustomizerTrigger); diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilters.tsx b/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilters.tsx new file mode 100644 index 000000000..2130632ac --- /dev/null +++ b/dashboard/src/features/orgs/projects/common/components/DataGridFilters/DataGridFilters.tsx @@ -0,0 +1,104 @@ +import { Button } from '@/components/ui/v3/button'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/v3/popover'; +import { + type DataGridFilter as Filter, + useDataGridFilter, +} from '@/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider'; +import { useDataGridConfig } from '@/features/orgs/projects/storage/dataGrid/components/DataGridConfigProvider'; +import { isEmptyValue, isNotEmptyValue } from '@/lib/utils'; +import { useState } from 'react'; +import { v4 as uuidV4 } from 'uuid'; +import DataGridFilter from './DataGridFilter'; +import DataGridFilterTrigger from './DataGridFilterTrigger'; + +function hasErrors(filters: Filter[]) { + return filters.reduce((errors, { op, value, column }, index) => { + if (isEmptyValue(value)) { + return { ...errors, [`${column}.${index}`]: 'Empty filter' }; + } + if (['$in', '$nin'].includes(op)) { + try { + JSON.parse(value); + } catch { + return { + ...errors, + [`${column}.${index}`]: 'Invalid format. ["item1","item 2"]', + }; + } + } + + return errors; + }, {}); +} + +function DataGridFilters() { + const { filters, addFilter, appliedFilters, setFilters, setAppliedFilters } = + useDataGridFilter(); + const { columns } = useDataGridConfig(); + const [errors, setErrors] = useState({}); + + function resetFilters() { + setFilters(appliedFilters); + } + + function handleApplyFilter() { + const filterErrors = hasErrors(filters); + setErrors(filterErrors); + if (isEmptyValue(filterErrors)) { + setAppliedFilters(filters); + } + } + + function handleOpenChange(newOpenState: boolean) { + if (!newOpenState) { + resetFilters(); + } + } + + function handleAddFilter() { + addFilter({ column: columns[0].id, op: '$eq', value: '', id: uuidV4() }); + } + + return ( + + + + + +
+ {isNotEmptyValue(filters) && + filters.map((filter, index) => ( + + ))} + {isEmptyValue(filters) && ( +

+ No filters applied to this table +
+ Add a filter below to filter the table +

+ )} +
+
+ + +
+
+
+ ); +} + +export default DataGridFilters; diff --git a/dashboard/src/features/orgs/projects/common/components/DataGridFilters/index.ts b/dashboard/src/features/orgs/projects/common/components/DataGridFilters/index.ts new file mode 100644 index 000000000..d5c39086b --- /dev/null +++ b/dashboard/src/features/orgs/projects/common/components/DataGridFilters/index.ts @@ -0,0 +1 @@ +export { default as DataGridFilters } from './DataGridFilters'; diff --git a/dashboard/src/features/orgs/projects/database/common/hooks/useTablePath/useTablePath.ts b/dashboard/src/features/orgs/projects/database/common/hooks/useTablePath/useTablePath.ts index ae2f1b3bc..a6fd24bd7 100644 --- a/dashboard/src/features/orgs/projects/database/common/hooks/useTablePath/useTablePath.ts +++ b/dashboard/src/features/orgs/projects/database/common/hooks/useTablePath/useTablePath.ts @@ -9,7 +9,6 @@ export default function useTablePath() { const { query: { dataSourceSlug, schemaSlug, tableSlug }, } = useRouter(); - if (!dataSourceSlug || !schemaSlug || !tableSlug) { return ''; } diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataBrowserGrid.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataBrowserGrid.tsx index 4f7bfa0ad..f1e7d19ff 100644 --- a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataBrowserGrid.tsx +++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataBrowserGrid.tsx @@ -3,6 +3,7 @@ import { FormActivityIndicator } from '@/components/form/FormActivityIndicator'; import { InlineCode } from '@/components/ui/v3/inline-code'; import { useTablePath } from '@/features/orgs/projects/database/common/hooks/useTablePath'; import { DataBrowserEmptyState } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserEmptyState'; +import { useDataGridFilter } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider'; import { DataBrowserGridControls } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserGridControls'; import { useTableQuery } from '@/features/orgs/projects/database/dataGrid/hooks/useTableQuery'; import type { UpdateRecordVariables } from '@/features/orgs/projects/database/dataGrid/hooks/useUpdateRecordMutation'; @@ -26,6 +27,7 @@ import { DataGridNumericCell } from '@/features/orgs/projects/storage/dataGrid/c import { DataGridTextCell } from '@/features/orgs/projects/storage/dataGrid/components/DataGridTextCell'; import { isNotEmptyValue } from '@/lib/utils'; +import { useTableRows } from '@/features/orgs/projects/database/dataGrid/hooks/useTableRows'; import { useQueryClient } from '@tanstack/react-query'; import { KeyRound } from 'lucide-react'; import dynamic from 'next/dynamic'; @@ -157,16 +159,45 @@ export default function DataBrowserGrid({ const [currentOffset, setCurrentOffset] = useState( parseInt(page as string, 10) - 1 || 0, ); - + const { appliedFilters } = useDataGridFilter(); const { mutateAsync: updateRow } = useUpdateRecordWithToastMutation(); const sortByString = isNotEmptyValue(sortBy?.[0]) ? `${sortBy[0].id}.${sortBy[0].desc}` : 'default-order'; + const filterString = isNotEmptyValue(appliedFilters) + ? appliedFilters + .map((filter) => `${filter.column}-${filter.op}-${filter.value}`) + .join('') + : 'no-filter'; - const { data, status, error, refetch } = useTableQuery( - [currentTablePath, currentOffset, sortByString], + const { + data, + status, + error, + refetch, + isFetching: isTableDataFetching, + } = useTableQuery([currentTablePath], { + limit, + offset: currentOffset * limit, + orderBy: + sortBy?.map(({ id, desc }) => ({ + columnName: id, + mode: desc ? 'DESC' : 'ASC', + })) || [], + filters: appliedFilters, + }); + + const { columns, metadata } = data || { + columns: [] as NormalizedQueryDataRow[], + }; + + const columnNames = columns?.map((column) => column.column_name); + + const { data: tableRowsData, isFetching: isTableRowsFetching } = useTableRows( + [currentTablePath, currentOffset, sortByString, filterString], { + columnNames, limit, offset: currentOffset * limit, orderBy: @@ -174,15 +205,16 @@ export default function DataBrowserGrid({ columnName: id, mode: desc ? 'DESC' : 'ASC', })) || [], + filters: appliedFilters, + queryOptions: { + enabled: !isTableDataFetching, + }, }, ); - const { columns, rows, numberOfRows, metadata } = data || { - columns: [] as NormalizedQueryDataRow[], - rows: [] as NormalizedQueryDataRow[], - numberOfRows: 0, - }; - + const rows = tableRowsData?.rows || ([] as NormalizedQueryDataRow[]); + const numberOfRows = tableRowsData?.numberOfRows || 0; + const tableRowsError = tableRowsData?.error; useEffect(() => { if ( currentTablePath && @@ -262,8 +294,6 @@ export default function DataBrowserGrid({ [columns, currentTablePath, queryClient, updateRow], ); - const memoizedData = useMemo(() => rows, [rows]); - async function handleInsertRowClick() { openDrawer({ title: 'Insert a New Row', @@ -324,11 +354,17 @@ export default function DataBrowserGrid({ Error: {tableRowsError} + ) : ( + 'No rows found.' + ) + } loading={status === 'loading'} sortBy={sortBy} className="pb-17 sm:pb-0" @@ -352,6 +388,7 @@ export default function DataBrowserGrid({ refetchData={refetch} /> } + isFetching={!!isTableRowsFetching} {...props} /> ); diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider/DataGridFilterProvider.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider/DataGridFilterProvider.tsx new file mode 100644 index 000000000..ac47f95aa --- /dev/null +++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider/DataGridFilterProvider.tsx @@ -0,0 +1,168 @@ +import { useTablePath } from '@/features/orgs/projects/database/common/hooks/useTablePath'; +import PersistenDataGrdiFilterStorage from '@/features/orgs/projects/database/dataGrid/utils/PersistentDataGridFilterStorage'; +import { + createContext, + useContext, + useEffect, + useMemo, + useState, + type PropsWithChildren, +} from 'react'; + +function updateFilterInArray( + filters: DataGridFilter[], + index: number, + newValue: DataGridFilter, +) { + return [...filters.slice(0, index), newValue, ...filters.slice(index + 1)]; +} + +function updateFilter( + oldFilters: DataGridFilter[], + index: number, + filterKey: keyof DataGridFilter, + newValue: string | DataGridFilterOperator, +) { + const filter = oldFilters[index]; + const filterToUpdate = { + ...filter, + [filterKey]: newValue, + }; + return updateFilterInArray(oldFilters, index, filterToUpdate); +} + +export type DataGridFilterOperator = + | '$eq' + | '$ne' + | '$in' + | '$nin' + | '$gt' + | '$lt' + | '$gte' + | '$lte' + | '$like' + | '$nlike' + | '$ilike' + | '$nilike' + | '$similar' + | '$nsimilar' + | '$regex' + | '$iregex' + | '$nregex' + | '$niregex'; + +export type DataGridFilter = { + column: string; + op: DataGridFilterOperator; + value: string; + id: string; +}; + +type DataGridFilterContextProps = { + appliedFilters: DataGridFilter[]; + setAppliedFilters: (filters: DataGridFilter[]) => void; + filters: DataGridFilter[]; + setFilters: (filters: DataGridFilter[]) => void; + addFilter: (newFilter: DataGridFilter) => void; + removeFilter: (index: number) => void; + setValue: (index: number, newValue: string) => void; + setColumn: (index: number, newColumn: string) => void; + setOp: (index: number, newOp: DataGridFilterOperator) => void; +}; + +const DataGridFilterContext = createContext({ + appliedFilters: [] as DataGridFilter[], + setAppliedFilters: () => {}, + filters: [] as DataGridFilter[], + setFilters: () => {}, + addFilter: () => {}, + removeFilter: () => {}, + setValue: () => {}, + setColumn: () => {}, + setOp: () => {}, +}); + +function DataGridFilterProvider({ children }: PropsWithChildren) { + const tablePath = useTablePath(); + const [appliedFilters, _setAppliedFilters] = useState(() => + PersistenDataGrdiFilterStorage.getDataGridFilters(tablePath), + ); + const [filters, setFilters] = useState(() => + PersistenDataGrdiFilterStorage.getDataGridFilters(tablePath), + ); + // const [loadedFiltersTablePath, setLoadedFiltersTablePath] = useState( + // () => tablePath, + // ); + + // const test = useRef(null); + + function addFilter(newFilter: DataGridFilter) { + setFilters((oldFilters) => oldFilters.concat(newFilter)); + } + + function setAppliedFilters(newFilters: DataGridFilter[]) { + _setAppliedFilters(newFilters); + PersistenDataGrdiFilterStorage.saveDataGridFilters(tablePath, newFilters); + } + + useEffect(() => { + const filtersForTheTable = + PersistenDataGrdiFilterStorage.getDataGridFilters(tablePath); + setFilters(filtersForTheTable); + _setAppliedFilters(filtersForTheTable); + }, [tablePath]); + + const contextValue: DataGridFilterContextProps = useMemo( + () => ({ + filters, + setFilters, + appliedFilters, + setAppliedFilters, + addFilter, + removeFilter(index: number) { + setFilters((oldFilters) => { + const newFilters = oldFilters.filter((_, i) => index !== i); + PersistenDataGrdiFilterStorage.saveDataGridFilters( + tablePath, + newFilters, + ); + return newFilters; + }); + }, + + setColumn(index: number, newColumnValue: string) { + setFilters((oldFilters) => + updateFilter(oldFilters, index, 'column', newColumnValue), + ); + }, + + setValue(index: number, newValue: string) { + setFilters((oldFilters) => + updateFilter(oldFilters, index, 'value', newValue), + ); + }, + + setOp(index: number, newOp: DataGridFilterOperator) { + setFilters((oldFilters) => + updateFilter(oldFilters, index, 'op', newOp), + ); + }, + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [appliedFilters, filters], + ); + + return ( + + {children} + + ); +} + +export default DataGridFilterProvider; + +export function useDataGridFilter() { + const context = useContext(DataGridFilterContext); + + return context; +} diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider/index.ts b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider/index.ts new file mode 100644 index 000000000..26c867242 --- /dev/null +++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider/index.ts @@ -0,0 +1,3 @@ +export { default as DataGridFilterProvider } from './DataGridFilterProvider'; + +export * from './DataGridFilterProvider'; diff --git a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGridControls/DataBrowserGridControls.tsx b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGridControls/DataBrowserGridControls.tsx index 998a3f9f8..e05af3721 100644 --- a/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGridControls/DataBrowserGridControls.tsx +++ b/dashboard/src/features/orgs/projects/database/dataGrid/components/DataBrowserGridControls/DataBrowserGridControls.tsx @@ -2,6 +2,7 @@ import { useDialog } from '@/components/common/DialogProvider'; import { Badge } from '@/components/ui/v3/badge'; import { ButtonWithLoading as Button } from '@/components/ui/v3/button'; import { DataGridCustomizerControls } from '@/features/orgs/projects/common/components/DataGridCustomizerControls'; +import { DataGridFilters } from '@/features/orgs/projects/common/components/DataGridFilters'; import { useDeleteRecordMutation } from '@/features/orgs/projects/database/dataGrid/hooks/useDeleteRecordMutation'; import type { DataBrowserGridColumn } from '@/features/orgs/projects/database/dataGrid/types/dataBrowser'; import { useDataGridConfig } from '@/features/orgs/projects/storage/dataGrid/components/DataGridConfigProvider'; @@ -156,6 +157,7 @@ export default function DataBrowserGridControls({ {...restPaginationProps} /> )} +