Compare commits

...

1 Commits

Author SHA1 Message Date
robertkasza
4685acc977 feat(dashboard): Add data table filters 2025-11-18 16:39:29 +01:00
20 changed files with 821 additions and 96 deletions

View File

@@ -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 (
<div className="flex gap-2">
<DataGridFilterColumn
value={column}
onChange={(newColumn) => setColumn(index, newColumn)}
columns={columns}
/>
<DataGridFilterOperators value={op} onChange={handleOpChange} />
<div className="flex-1">
<Input
className={cn('h-8 p-2', {
'border-destructive': isNotEmptyValue(error),
})}
placeholder="Enter a value"
value={value}
onChange={(event) => setValue(index, event.target.value)}
/>
<span
className={`inline-flex h-[0.875rem] text-xs- text-destructive ${isNotEmptyValue(error) ? 'visible' : 'invisible'}`}
>
{error}
</span>
</div>
<Button
variant="outline"
size="icon"
className="flex-i h-8 w-8"
onClick={() => removeFilter(index)}
>
<X width={12} height={12} />
</Button>
</div>
);
}
export default DataGridFilter;

View File

@@ -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 (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="mp-2 h-8 max-w-[35%]">
<span className="!inline-block w-4/5 justify-start overflow-ellipsis text-left">
{value}
</span>
</SelectTrigger>
<SelectContent>
{columns.map((column) => (
<SelectItem key={column.id} value={column.id}>
{column.id}{' '}
<Badge className="rounded-sm+ bg-secondary p-1 text-[0.75rem] font-normal leading-[0.75]">
{/* TODO: Fix type */}
{(column as any).dataType}
</Badge>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default DataGrdiFitlerColumn;

View File

@@ -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 (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="h-8 w-[6rem] p-2">{value}</SelectTrigger>
<SelectContent>
{OPERATORS.map(({ op, label }) => (
<SelectItem key={op} value={op}>
<span>[{op}]</span> <span className="text-secondary">{label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
);
}
export default DataGridOperators;

View File

@@ -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<HTMLButtonElement>,
) {
const { appliedFilters } = useDataGridFilter();
const numberOfAppliedFilters = appliedFilters.length;
const { className, ...buttonProps } = props;
return (
<Button
ref={ref}
variant="outline"
size="icon"
className={cn('relative', className)}
{...buttonProps}
>
<Funnel />
{numberOfAppliedFilters > 0 && (
<span className="absolute bottom-[6px] right-[6px] w-[0.725rem] rounded-full bg-primary-text p-0 text-[0.725rem] leading-none text-paper">
{numberOfAppliedFilters}
</span>
)}
</Button>
);
}
export default forwardRef(DataBrowserCustomizerTrigger);

View File

@@ -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 (
<Popover onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<DataGridFilterTrigger />
</PopoverTrigger>
<PopoverContent align="end" className="flex w-[40rem] flex-col gap-6 p-0">
<div className="flex w-full flex-col gap-0 px-3 pb-0 pt-6">
{isNotEmptyValue(filters) &&
filters.map((filter, index) => (
<DataGridFilter
{...filter}
key={filter.id}
index={index}
columns={columns as any}
error={errors[`${filter.column}.${index}`]}
/>
))}
{isEmptyValue(filters) && (
<p>
<strong>No filters applied to this table</strong>
<br />
Add a filter below to filter the table
</p>
)}
</div>
<div className="flex items-center justify-between border-t-1 border-t-[#e2e8f0] p-3 dark:border-t-[#2f363d]">
<Button variant="outline" size="sm" onClick={handleAddFilter}>
Add filter
</Button>
<Button variant="outline" size="sm" onClick={handleApplyFilter}>
Apply filter
</Button>
</div>
</PopoverContent>
</Popover>
);
}
export default DataGridFilters;

View File

@@ -0,0 +1 @@
export { default as DataGridFilters } from './DataGridFilters';

View File

@@ -9,7 +9,6 @@ export default function useTablePath() {
const {
query: { dataSourceSlug, schemaSlug, tableSlug },
} = useRouter();
if (!dataSourceSlug || !schemaSlug || !tableSlug) {
return '';
}

View File

@@ -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<number>(
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({
<DataGrid
ref={dataGridRef}
columns={memoizedColumns}
data={memoizedData}
data={rows}
allowSelection
allowResize
allowSort
emptyStateMessage="No rows found."
emptyStateMessage={
tableRowsError ? (
<span className="text-destructive">Error: {tableRowsError}</span>
) : (
'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}
/>
);

View File

@@ -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<DataGridFilterContextProps>({
appliedFilters: [] as DataGridFilter[],
setAppliedFilters: () => {},
filters: [] as DataGridFilter[],
setFilters: () => {},
addFilter: () => {},
removeFilter: () => {},
setValue: () => {},
setColumn: () => {},
setOp: () => {},
});
function DataGridFilterProvider({ children }: PropsWithChildren) {
const tablePath = useTablePath();
const [appliedFilters, _setAppliedFilters] = useState<DataGridFilter[]>(() =>
PersistenDataGrdiFilterStorage.getDataGridFilters(tablePath),
);
const [filters, setFilters] = useState<DataGridFilter[]>(() =>
PersistenDataGrdiFilterStorage.getDataGridFilters(tablePath),
);
// const [loadedFiltersTablePath, setLoadedFiltersTablePath] = useState(
// () => tablePath,
// );
// const test = useRef<string | null>(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 (
<DataGridFilterContext.Provider value={contextValue}>
{children}
</DataGridFilterContext.Provider>
);
}
export default DataGridFilterProvider;
export function useDataGridFilter() {
const context = useContext(DataGridFilterContext);
return context;
}

View File

@@ -0,0 +1,3 @@
export { default as DataGridFilterProvider } from './DataGridFilterProvider';
export * from './DataGridFilterProvider';

View File

@@ -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}
/>
)}
<DataGridFilters />
<DataGridCustomizerControls />
<Button onClick={onInsertRowClick} size="sm">
<Plus className="h-4 w-4" /> Insert row

View File

@@ -1,3 +1,4 @@
import type { DataGridFilter } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider';
import type {
ForeignKeyRelation,
MutationOrQueryBaseOptions,
@@ -9,7 +10,6 @@ import type {
import { extractForeignKeyRelation } from '@/features/orgs/projects/database/dataGrid/utils/extractForeignKeyRelation';
import { getPreparedReadOnlyHasuraQuery } from '@/features/orgs/projects/database/dataGrid/utils/hasuraQueryHelpers';
import { POSTGRESQL_ERROR_CODES } from '@/features/orgs/projects/database/dataGrid/utils/postgresqlConstants';
import { formatWithArray } from 'node-pg-format';
export interface FetchTableOptions extends MutationOrQueryBaseOptions {
/**
@@ -30,6 +30,12 @@ export interface FetchTableOptions extends MutationOrQueryBaseOptions {
* Determines whether the query should fetch the rows or not.
*/
preventRowFetching?: boolean;
/**
* Filtering configuration.
*
* @default []
*/
filters?: DataGridFilter[];
}
export interface FetchTableReturnType {
@@ -37,18 +43,10 @@ export interface FetchTableReturnType {
* List of columns in the table.
*/
columns: NormalizedQueryDataRow[];
/**
* List of rows in the table.
*/
rows: NormalizedQueryDataRow[];
/**
* Foreign key relations in the table.
*/
foreignKeyRelations: ForeignKeyRelation[];
/**
* Total number of rows in the table.
*/
numberOfRows: number;
/**
* Response metadata that usually contains information about the schema and
* the table for which the query was run.
@@ -68,43 +66,7 @@ export default async function fetchTable({
table,
appUrl,
adminSecret,
limit,
offset,
orderBy,
preventRowFetching,
}: FetchTableOptions): Promise<FetchTableReturnType> {
let limitAndOffsetClause = '';
if (preventRowFetching) {
limitAndOffsetClause = `LIMIT 0`;
} else if (limit && offset) {
limitAndOffsetClause = `LIMIT ${limit} OFFSET ${offset}`;
} else if (limit) {
limitAndOffsetClause = `LIMIT ${limit}`;
}
let orderByClause = 'ORDER BY 1';
if (orderBy && orderBy.length > 0) {
// Note: This part will be added to the SQL template
const pgFormatTemplate = orderBy.map(() => '%I %s').join(' ');
// Note: We are flattening object values so that we can pass them to the
// formatter function as arguments
const flattenedOrderByValues = orderBy.reduce<OrderBy[]>(
(values, currentOrderBy) => {
const currentValues = Object.values(currentOrderBy) as OrderBy[];
return [...values, ...currentValues];
},
[],
);
orderByClause = formatWithArray(
`ORDER BY ${pgFormatTemplate}`,
flattenedOrderByValues,
);
}
const response = await fetch(`${appUrl}/v2/query`, {
method: 'POST',
headers: {
@@ -153,14 +115,6 @@ export default async function fetchTable({
schema,
table,
),
getPreparedReadOnlyHasuraQuery(
dataSource,
`SELECT ROW_TO_JSON(TABLE_DATA) FROM (SELECT * FROM %I.%I %s %s) TABLE_DATA`,
schema,
table,
orderByClause,
limitAndOffsetClause,
),
getPreparedReadOnlyHasuraQuery(
dataSource,
`SELECT ROW_TO_JSON(TABLE_DATA) FROM (\
@@ -178,12 +132,6 @@ export default async function fetchTable({
schema,
table,
),
getPreparedReadOnlyHasuraQuery(
dataSource,
`SELECT COUNT(*) FROM %I.%I`,
schema,
table,
),
],
type: 'bulk',
version: 1,
@@ -207,8 +155,6 @@ export default async function fetchTable({
if (schemaNotFound || tableNotFound) {
return {
columns: [],
rows: [],
numberOfRows: 0,
foreignKeyRelations: [],
metadata: { schema, table, schemaNotFound, tableNotFound },
};
@@ -220,8 +166,6 @@ export default async function fetchTable({
) {
return {
columns: [],
rows: [],
numberOfRows: 0,
foreignKeyRelations: [],
metadata: { schema, table, columnsNotFound: true },
};
@@ -237,9 +181,7 @@ export default async function fetchTable({
}
const [, ...rawColumns] = responseData[0].result;
const [, ...rawData] = responseData[1].result;
const [, ...rawConstraints] = responseData[2].result;
const [, ...[rawAggregate]] = responseData[3].result;
const [, ...rawConstraints] = responseData[1].result;
const foreignKeyRelationMap = new Map<string, string>();
const uniqueKeyConstraintMap = new Map<string, string[]>();
@@ -323,13 +265,8 @@ export default async function fetchTable({
} as NormalizedQueryDataRow;
})
.sort((a, b) => a.ordinal_position - b.ordinal_position);
return {
columns,
rows: rawData.map((rawRow) =>
JSON.parse(rawRow),
) as NormalizedQueryDataRow[],
foreignKeyRelations: flatForeignKeyRelations,
numberOfRows: rawAggregate ? parseInt(rawAggregate, 10) : 0,
};
}

View File

@@ -0,0 +1,147 @@
import type { DataGridFilter } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider';
import type {
MutationOrQueryBaseOptions,
NormalizedQueryDataRow,
OrderBy,
} from '@/features/orgs/projects/database/dataGrid/types/dataBrowser';
import { getPreparedReadOnlyHasuraQuery } from '@/features/orgs/projects/database/dataGrid/utils/hasuraQueryHelpers';
import { isNotEmptyValue } from '@/lib/utils';
export interface FetchTableRowsOptions extends MutationOrQueryBaseOptions {
/**
* Name of the columns to fetch
*/
columnNames: string[];
/**
* Limit of rows to fetch.
*/
limit: number;
/**
* Offset of rows to fetch.
*/
offset: number;
/**
* Ordering configuration.
*
* @default []
*/
orderBy: OrderBy[];
/**
* Filtering configuration.
*
* @default []
*/
filters: DataGridFilter[];
}
export type FetchTableRowsResult = {
error?: string | null;
rows: NormalizedQueryDataRow[];
numberOfRows: number;
};
function createRowQuery({
columnNames,
limit,
offset,
orderBy,
filters,
schema,
table,
dataSource,
}: FetchTableRowsOptions) {
return {
type: 'select',
args: {
source: dataSource,
table: { schema, name: table },
columns: columnNames,
// TODO: create function
where: {
$and: filters?.map(({ column, op, value }) => ({
[column]: {
[op]: op === '$in' || op === '$nin' ? JSON.parse(value) : value,
},
})),
},
offset,
limit,
order_by:
orderBy?.map((ob) => ({
column: ob.columnName,
type: ob.mode.toLocaleLowerCase(),
})) ?? [],
},
};
}
async function fetchTableRows({
columnNames,
limit,
offset,
orderBy,
filters,
adminSecret,
dataSource,
appUrl,
table,
schema,
}: FetchTableRowsOptions): Promise<FetchTableRowsResult> {
const body = {
type: 'bulk',
args: [
createRowQuery({
columnNames,
limit,
offset,
orderBy,
filters,
dataSource,
table,
schema,
appUrl,
adminSecret,
}),
getPreparedReadOnlyHasuraQuery(
dataSource,
`SELECT COUNT(*) FROM %I.%I`,
schema,
table,
),
],
};
const response = await fetch(`${appUrl}/v2/query`, {
method: 'POST',
headers: {
'x-hasura-admin-secret': adminSecret,
},
body: JSON.stringify(body),
});
const responseBody = await response.json();
if (isNotEmptyValue(responseBody.error)) {
return {
rows: [],
error: responseBody.error,
numberOfRows: 0,
};
}
const [
rows,
{
result: [, [maxNumberOfRows]],
},
] = responseBody;
return {
rows,
error: null,
numberOfRows: isNotEmptyValue(filters)
? rows.length
: Number(maxNumberOfRows),
};
}
export default fetchTableRows;

View File

@@ -0,0 +1 @@
export { default as useTableRows } from './useTableRows';

View File

@@ -0,0 +1,66 @@
import { generateAppServiceUrl } from '@/features/orgs/projects/common/utils/generateAppServiceUrl';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import { isNotEmptyValue } from '@/lib/utils';
import { getHasuraAdminSecret } from '@/utils/env';
import {
useQuery,
type QueryKey,
type UseQueryOptions,
} from '@tanstack/react-query';
import { useRouter } from 'next/router';
import type {
FetchTableRowsOptions,
FetchTableRowsResult,
} from './fetchTableRows';
import fetchTableRows from './fetchTableRows';
export interface UseTableRowsQueryOptions
extends Pick<
FetchTableRowsOptions,
'limit' | 'filters' | 'offset' | 'orderBy' | 'columnNames'
> {
/**
* Props passed to the underlying query hook.
*/
queryOptions?: UseQueryOptions;
}
function useTableRows(
queryKey: QueryKey,
{ queryOptions, ...options }: UseTableRowsQueryOptions,
) {
const { project } = useProject();
const {
query: { dataSourceSlug, schemaSlug, tableSlug },
isReady,
} = useRouter();
const dependenciesLoaded =
isNotEmptyValue(project) && isNotEmptyValue(options.columnNames) && isReady;
return useQuery<FetchTableRowsResult>(queryKey, {
queryFn: () => {
const appUrl = isNotEmptyValue(project)
? generateAppServiceUrl(project!.subdomain, project!.region, 'hasura')
: '';
return fetchTableRows({
appUrl,
dataSource: dataSourceSlug as string,
schema: schemaSlug as string,
table: tableSlug as string,
adminSecret:
process.env.NEXT_PUBLIC_ENV === 'dev'
? getHasuraAdminSecret()
: project!.config!.hasura.adminSecret,
...options,
});
},
retry: false,
keepPreviousData: true,
...(queryOptions && { queryOptions }),
enabled: isNotEmptyValue(queryOptions?.enabled)
? queryOptions.enabled && dependenciesLoaded
: dependenciesLoaded,
});
}
export default useTableRows;

View File

@@ -0,0 +1,37 @@
import type { DataGridFilter } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider';
import { isEmptyValue } from '@/lib/utils';
export const DATA_GRID_FILTER_STORAGE_KEY = 'nhost_data_grid_filter_storage';
class PersistenDataGrdiFilterStorage {
private static getAllStoredData(): Record<string, DataGridFilter[]> {
const storedData = localStorage.getItem(DATA_GRID_FILTER_STORAGE_KEY);
if (isEmptyValue(storedData)) {
return {};
}
const allStoredData = JSON.parse(storedData as string);
return allStoredData;
}
static getDataGridFilters(tablePath: string): DataGridFilter[] {
const allStoredData = PersistenDataGrdiFilterStorage.getAllStoredData();
return allStoredData[tablePath] ?? [];
}
static saveDataGridFilters(tablePath: string, filters: DataGridFilter[]) {
const allStoredData = PersistenDataGrdiFilterStorage.getAllStoredData();
const updatedAllStoredData: Record<string, DataGridFilter[]> = {
...allStoredData,
[tablePath]: filters,
};
localStorage.setItem(
DATA_GRID_FILTER_STORAGE_KEY,
JSON.stringify(updatedAllStoredData),
);
}
}
export default PersistenDataGrdiFilterStorage;

View File

@@ -67,6 +67,8 @@ export function getPreparedReadOnlyHasuraQuery(
...args,
);
// console.log({ preparedHasuraQuery });
return {
...preparedHasuraQuery,
args: {

View File

@@ -9,7 +9,7 @@ import type { DataGridHeaderProps } from '@/features/orgs/projects/storage/dataG
import { DataGridHeader } from '@/features/orgs/projects/storage/dataGrid/components/DataGridHeader';
import { DataTableDesignProvider } from '@/features/orgs/projects/storage/dataGrid/providers/DataTableDesignProvider';
import { cn } from '@/lib/utils';
import type { ForwardedRef } from 'react';
import type { ForwardedRef, ReactNode } from 'react';
import { forwardRef, useEffect, useRef } from 'react';
import { mergeRefs } from 'react-merge-refs';
import type { Column, Row, SortingRule, TableOptions } from 'react-table';
@@ -31,7 +31,7 @@ export interface DataGridProps<TColumnData extends object>
*
* @default null
*/
emptyStateMessage?: string;
emptyStateMessage?: ReactNode;
/**
* Additional configuration options for the `react-table` hook.
*/
@@ -71,6 +71,10 @@ export interface DataGridProps<TColumnData extends object>
* Determines whether the Grid is used for displaying files.
*/
isFileDataGrid?: boolean;
/**
* Determines whether rows are being fetched or not
*/
isFetching?: boolean;
}
function DataGrid<TColumnData extends object>(
@@ -89,6 +93,7 @@ function DataGrid<TColumnData extends object>(
loading,
className,
isFileDataGrid,
isFetching,
}: DataGridProps<TColumnData>,
ref: ForwardedRef<HTMLDivElement>,
) {
@@ -151,12 +156,19 @@ function DataGrid<TColumnData extends object>(
)}
>
<DataGridFrame>
<DataGridHeader {...headerProps} />
<DataGridBody
isFileDataGrid={isFileDataGrid}
emptyStateMessage={emptyStateMessage}
loading={loading}
/>
<div className="relative h-full">
<DataGridHeader {...headerProps} />
{isFetching && (
<div className="absolute top-0 z-50 flex h-full w-full justify-center bg-[rgba(0,0,0,.5)]">
<Spinner />
</div>
)}
<DataGridBody
isFileDataGrid={isFileDataGrid}
emptyStateMessage={emptyStateMessage}
loading={loading}
/>
</div>
</DataGridFrame>
</div>
)}

View File

@@ -22,9 +22,9 @@ class PersistenDataTableConfigurationStorage {
if (isEmptyValue(storedData)) {
return {};
}
const allHiddenColumns = JSON.parse(storedData as string);
const allStoredData = JSON.parse(storedData as string);
return allHiddenColumns;
return allStoredData;
}
static getHiddenColumns(tablePath: string): string[] {

View File

@@ -4,6 +4,7 @@ import { OrgLayout } from '@/features/orgs/layout/OrgLayout';
import { useIsPlatform } from '@/features/orgs/projects/common/hooks/useIsPlatform';
import { useTablePath } from '@/features/orgs/projects/database/common/hooks/useTablePath';
import { DataBrowserGrid } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserGrid';
import { DataGridFilterProvider } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserGrid/DataGridFilterProvider';
import { DataBrowserSidebar } from '@/features/orgs/projects/database/dataGrid/components/DataBrowserSidebar';
import { useProject } from '@/features/orgs/projects/hooks/useProject';
import type { ReactElement } from 'react';
@@ -31,7 +32,9 @@ export default function DataBrowserTableDetailsPage() {
return (
<RetryableErrorBoundary>
<DataBrowserGrid sortBy={sortBy} onSort={handleSortByChange} />
<DataGridFilterProvider>
<DataBrowserGrid sortBy={sortBy} onSort={handleSortByChange} />
</DataGridFilterProvider>
</RetryableErrorBoundary>
);
}