Compare commits
1 Commits
main
...
feat/datab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4685acc977 |
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DataGridFilters } from './DataGridFilters';
|
||||
@@ -9,7 +9,6 @@ export default function useTablePath() {
|
||||
const {
|
||||
query: { dataSourceSlug, schemaSlug, tableSlug },
|
||||
} = useRouter();
|
||||
|
||||
if (!dataSourceSlug || !schemaSlug || !tableSlug) {
|
||||
return '';
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as DataGridFilterProvider } from './DataGridFilterProvider';
|
||||
|
||||
export * from './DataGridFilterProvider';
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default as useTableRows } from './useTableRows';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -67,6 +67,8 @@ export function getPreparedReadOnlyHasuraQuery(
|
||||
...args,
|
||||
);
|
||||
|
||||
// console.log({ preparedHasuraQuery });
|
||||
|
||||
return {
|
||||
...preparedHasuraQuery,
|
||||
args: {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user