console: integrate redux into the new filters section component

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/5958
GitOrigin-RevId: 58f565ed293dcad71bb9239eb511531e44d63618
This commit is contained in:
Luca Restagno 2022-09-22 17:40:24 +02:00 committed by hasura-bot
parent dd37456949
commit 66a698cbef
20 changed files with 774 additions and 494 deletions

View File

@ -6,7 +6,6 @@ import {
downloadObjectAsJsonFile,
downloadObjectAsCsvFile,
getCurrTimeForFileName,
getConfirmation,
} from '../../../Common/utils/jsUtils';
const LOADING = 'ViewTable/FilterQuery/LOADING';
@ -121,59 +120,48 @@ const runQuery = tableSchema => {
const exportDataQuery = (tableSchema, type) => {
return (dispatch, getState) => {
const count = getState().tables.view.count;
const confirmed = getConfirmation(
`There ${
count === 1 ? 'is 1 row' : `are ${count} rows`
} selected for export.`
);
if (!confirmed) {
return;
}
const state = getState().tables.view.curFilter;
let finalWhereClauses = state.where.$and.filter(w => {
const colName = Object.keys(w)[0].trim();
const filteredWhereClauses = state.where.$and.filter(whereClause => {
const colName = Object.keys(whereClause)[0].trim();
if (colName === '') {
return false;
}
const opName = Object.keys(w[colName])[0].trim();
const opName = Object.keys(whereClause[colName])[0].trim();
if (opName === '') {
return false;
}
return true;
});
finalWhereClauses = finalWhereClauses.map(w => {
const colName = Object.keys(w)[0];
const opName = Object.keys(w[colName])[0];
const val = w[colName][opName];
const finalWhereClauses = filteredWhereClauses.map(whereClause => {
const colName = Object.keys(whereClause)[0];
const opName = Object.keys(whereClause[colName])[0];
const val = whereClause[colName][opName];
if (['$in', '$nin'].includes(opName)) {
w[colName][opName] = parseArray(val);
return w;
whereClause[colName][opName] = parseArray(val);
return whereClause;
}
const colType = tableSchema.columns.find(
c => c.column_name === colName
).data_type;
if (Integers.indexOf(colType) > 0) {
w[colName][opName] = parseInt(val, 10);
return w;
whereClause[colName][opName] = parseInt(val, 10);
return whereClause;
}
if (Reals.indexOf(colType) > 0) {
w[colName][opName] = parseFloat(val);
return w;
whereClause[colName][opName] = parseFloat(val);
return whereClause;
}
if (colType === 'boolean') {
if (val === 'true') {
w[colName][opName] = true;
whereClause[colName][opName] = true;
} else if (val === 'false') {
w[colName][opName] = false;
whereClause[colName][opName] = false;
}
}
return w;
return whereClause;
});
const newQuery = {
where: { $and: finalWhereClauses },
@ -190,10 +178,18 @@ const exportDataQuery = (tableSchema, type) => {
const fileName = `export_${table_schema}_${table_name}_${getCurrTimeForFileName()}`;
dispatch({ type: 'ViewTable/V_SET_QUERY_OPTS', queryStuff: newQuery });
dispatch(vMakeExportRequest()).then(d => {
if (d) {
if (type === 'JSON') downloadObjectAsJsonFile(fileName, d);
else if (type === 'CSV') downloadObjectAsCsvFile(fileName, d);
dispatch(vMakeExportRequest()).then(rows => {
if (!rows) {
return;
}
if (type === 'JSON') {
downloadObjectAsJsonFile(fileName, rows);
return;
}
if (type === 'CSV') {
downloadObjectAsCsvFile(fileName, rows);
}
});
};

View File

@ -1,347 +0,0 @@
/*
Use state exactly the way columns in create table do.
dispatch actions using a given function,
but don't listen to state.
derive everything through viewtable as much as possible.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { createHistory } from 'history';
import { FaTimes } from 'react-icons/fa';
import {
setFilterCol,
setFilterOp,
setFilterVal,
addFilter,
removeFilter,
} from './FilterActions.js';
import {
setOrderCol,
setOrderType,
addOrder,
removeOrder,
} from './FilterActions.js';
import {
setDefaultQuery,
runQuery,
exportDataQuery,
setOffset,
} from './FilterActions';
import { Button } from '@/new-components/Button';
import ReloadEnumValuesButton from '../Common/Components/ReloadEnumValuesButton';
import { getPersistedPageSize } from './tableUtils';
import { isEmpty } from '../../../Common/utils/jsUtils';
import ExportData from './ExportData';
import { dataSource, getTableCustomColumnName } from '../../../../dataSources';
import { inputStyles } from '../../Actions/constants.js';
const history = createHistory();
const renderCols = (
colName,
tableSchema,
onChange,
usage,
key,
skipColumns
) => {
let columns = tableSchema.columns.map(c => c.column_name);
if (skipColumns) {
columns = columns.filter(n => !skipColumns.includes(n) || n === colName);
}
return (
<select
className={inputStyles}
onChange={onChange}
value={colName.trim()}
data-test={
usage === 'sort' ? `sort-column-${key}` : `filter-column-${key}`
}
>
{colName.trim() === '' ? (
<option disabled value="">
-- column --
</option>
) : null}
{columns.map((c, i) => {
const col_name = getTableCustomColumnName(tableSchema, c) ?? c;
return (
<option key={i} value={c}>
{col_name}
</option>
);
})}
</select>
);
};
const renderOps = (opName, onChange, key) => (
<select
className={inputStyles}
onChange={onChange}
value={opName.trim()}
data-test={`filter-op-${key}`}
>
{opName.trim() === '' ? (
<option disabled value="">
-- op --
</option>
) : null}
{dataSource.operators.map((o, i) => (
<option key={i} value={o.value}>
{`[${o.graphqlOp}] ${o.name}`}
</option>
))}
</select>
);
const getDefaultValue = (possibleValue, opName) => {
if (possibleValue) {
if (Array.isArray(possibleValue)) return JSON.stringify(possibleValue);
return possibleValue;
}
const operator = dataSource.operators.find(op => op.value === opName);
return operator && operator.defaultValue ? operator.defaultValue : '';
};
const renderWheres = (whereAnd, tableSchema, dispatch) => {
return whereAnd.map((clause, i) => {
const colName = Object.keys(clause)[0];
const opName = Object.keys(clause[colName])[0];
const dSetFilterCol = e => {
dispatch(setFilterCol(e.target.value, i));
};
const dSetFilterOp = e => {
dispatch(setFilterOp(e.target.value, i));
};
let removeIcon = null;
if (i + 1 < whereAnd.length) {
removeIcon = (
<FaTimes
onClick={() => {
dispatch(removeFilter(i));
}}
data-test={`clear-filter-${i}`}
/>
);
}
return (
<div key={i} className="flex mb-xs">
<div className="w-4/12">
{renderCols(colName, tableSchema, dSetFilterCol, 'filter', i, [])}
</div>
<div className="w-3/12 ml-xs">{renderOps(opName, dSetFilterOp, i)}</div>
<div className="w-3/12 ml-xs">
<input
type="text"
placeholder="-- value --"
value={getDefaultValue(clause[colName][opName], opName)}
onChange={e => {
dispatch(setFilterVal(e.target.value, i));
if (i + 1 === whereAnd.length) {
dispatch(addFilter());
}
}}
data-test={`filter-value-${i}`}
className={inputStyles}
/>
</div>
<div className="w-1/12">{removeIcon}</div>
</div>
);
});
};
const renderSorts = (orderBy, tableSchema, dispatch) => {
const currentOrderBy = orderBy.map(o => o.column);
return orderBy.map((c, i) => {
const dSetOrderCol = e => {
dispatch(setOrderCol(e.target.value, i));
if (i + 1 === orderBy.length) {
dispatch(addOrder());
}
};
let removeIcon = null;
if (i + 1 < orderBy.length) {
removeIcon = (
<FaTimes
onClick={() => {
dispatch(removeOrder(i));
}}
data-test={`clear-sorts-${i}`}
/>
);
}
return (
<div key={i} className="flex mb-xs">
<div className="w-6/12 mr-xs">
{renderCols(
c.column,
tableSchema,
dSetOrderCol,
'sort',
i,
currentOrderBy
)}
</div>
<div className="w-6/12">
<select
value={c.column ? c.type : ''}
className={inputStyles}
onChange={e => {
dispatch(setOrderType(e.target.value, i));
}}
data-test={`sort-order-${i}`}
>
<option disabled value="">
--
</option>
<option value="asc">Asc</option>
<option value="desc">Desc</option>
</select>
</div>
<div className="">{removeIcon}</div>
</div>
);
});
};
class FilterQuery extends Component {
componentDidMount() {
const { dispatch, tableSchema, curQuery } = this.props;
const limit = getPersistedPageSize();
if (isEmpty(this.props.urlQuery)) {
dispatch(setDefaultQuery({ ...curQuery, limit }));
return;
}
let urlFilters = [];
if (typeof this.props.urlQuery.filter === 'string') {
urlFilters = [this.props.urlQuery.filter];
} else if (Array.isArray(this.props.urlQuery.filter)) {
urlFilters = this.props.urlQuery.filter;
}
const where = {
$and: urlFilters.map(filter => {
const parts = filter.split(';');
const col = parts[0];
const op = parts[1];
const value = parts[2];
return { [col]: { [op]: value } };
}),
};
let urlSorts = [];
if (typeof this.props.urlQuery.sort === 'string') {
urlSorts = [this.props.urlQuery.sort];
} else if (Array.isArray(this.props.urlQuery.sort)) {
urlSorts = this.props.urlQuery.sort;
}
const order_by = urlSorts.map(sort => {
const parts = sort.split(';');
const column = parts[0];
const type = parts[1];
const nulls = 'last';
return { column, type, nulls };
});
dispatch(setDefaultQuery({ where, order_by, limit }));
dispatch(runQuery(tableSchema));
}
setParams(query = { filters: [], sorts: [] }) {
const searchParams = new URLSearchParams();
query.filters.forEach(filter => searchParams.append('filter', filter));
query.sorts.forEach(sort => searchParams.append('sort', sort));
return searchParams.toString();
}
setUrlParams(whereAnd, orderBy) {
const sorts = orderBy
.filter(order => order.column)
.map(order => `${order.column};${order.type}`);
const filters = whereAnd
.filter(
where => Object.keys(where).length === 1 && Object.keys(where)[0] !== ''
)
.map(where => {
const col = Object.keys(where)[0];
const op = Object.keys(where[col])[0];
const value = where[col][op];
return `${col};${op};${value}`;
});
const url = this.setParams({ filters, sorts });
history.push({
pathname: history.getCurrentLocation().pathname,
search: `?${url}`,
});
}
render() {
const { dispatch, whereAnd, tableSchema, orderBy } = this.props; // eslint-disable-line no-unused-vars
const exportData = type => {
dispatch(exportDataQuery(tableSchema, type));
};
return (
<div className="mt-sm">
<form
onSubmit={e => {
e.preventDefault();
dispatch(setOffset(0));
this.setUrlParams(whereAnd, orderBy);
dispatch(runQuery(tableSchema));
}}
>
<div className="flex">
<div className="w-1/2 pl-0">
<div className="text-lg font-bold pb-md">Filter</div>
{renderWheres(whereAnd, tableSchema, dispatch)}
</div>
<div className="w-1/2 pl-0">
<div className="text-lg font-bold pb-md">Sort</div>
{renderSorts(orderBy, tableSchema, dispatch)}
</div>
</div>
<div className="pr-sm clear-both mt-sm">
<Button
type="submit"
mode="primary"
data-test="run-query"
className="mr-sm"
>
Run query
</Button>
<ExportData onExport={exportData} />
{tableSchema.is_enum ? (
<ReloadEnumValuesButton
dispatch={dispatch}
tooltipStyle="ml-sm"
/>
) : null}
</div>
</form>
</div>
);
}
}
FilterQuery.propTypes = {
curQuery: PropTypes.object.isRequired,
tableSchema: PropTypes.object.isRequired,
whereAnd: PropTypes.array.isRequired,
orderBy: PropTypes.array.isRequired,
limit: PropTypes.number.isRequired,
count: PropTypes.number,
tableName: PropTypes.string,
offset: PropTypes.number.isRequired,
dispatch: PropTypes.func.isRequired,
};
export default FilterQuery;

View File

@ -32,19 +32,20 @@ import {
import { Button } from '@/new-components/Button';
import { FilterSectionContainer } from '@/features/BrowseRows/FiltersSection/FiltersSectionContainer';
import { PaginationWithOnlyNavContainer } from '@/new-components/PaginationWithOnlyNav/PaginationWithOnlyNavContainer';
import {
setOrderCol,
setOrderType,
removeOrder,
runQuery,
setOffset,
setLimit,
addOrder,
} from './FilterActions';
import _push from '../push';
import { ordinalColSort } from '../utils';
import FilterQuery from './FilterQuery';
import Spinner from '../../../Common/Spinner/Spinner';
import { E_SET_EDITITEM } from './EditActions';
@ -66,10 +67,8 @@ import {
getPersistedCollapsedColumns,
persistColumnOrderChange,
getPersistedColumnsOrder,
persistPageSizeChange,
} from './tableUtils';
import { compareRows, isTableWithPK } from './utils';
import { PaginationWithOnlyNav } from '../../../../new-components/PaginationWithOnlyNav/PaginationWithOnlyNav';
const ViewRows = props => {
const {
@ -92,7 +91,6 @@ const ViewRows = props => {
count,
expandedRow,
manualTriggers = [],
location,
readOnlyMode,
shouldHidePagination,
currentSource,
@ -721,44 +719,6 @@ const ViewRows = props => {
disableBulkSelect
);
const getFilterQuery = () => {
let _filterQuery = null;
if (!isSingleRow) {
if (curRelName === activePath[curDepth] || curDepth === 0) {
// Rendering only if this is the activePath or this is the root
let wheres = [{ '': { '': '' } }];
if ('where' in curFilter && '$and' in curFilter.where) {
wheres = [...curFilter.where.$and];
}
let orderBy = [{ column: '', type: 'asc', nulls: 'last' }];
if ('order_by' in curFilter) {
orderBy = [...curFilter.order_by];
}
const offset = 'offset' in curFilter ? curFilter.offset : 0;
_filterQuery = (
<FilterQuery
curQuery={curQuery}
whereAnd={wheres}
tableSchema={tableSchema}
orderBy={orderBy}
dispatch={dispatch}
count={count}
tableName={curTableName}
offset={offset}
urlQuery={location && location.query}
/>
);
}
}
return _filterQuery;
};
const getSelectedRowsSection = () => {
const handleDeleteItems = () => {
const pkClauses = selectedRows.map(row =>
@ -873,6 +833,11 @@ const ViewRows = props => {
return _childComponent;
};
const [userQuery, setUserQuery] = useState({
where: { $and: [] },
order_by: [],
});
const renderTableBody = () => {
if (isProgressing) {
return (
@ -976,31 +941,28 @@ const ViewRows = props => {
const handlePageChange = page => {
if (curFilter.offset !== page * curFilter.limit) {
dispatch(setOffset(page * curFilter.limit));
dispatch(runQuery(tableSchema));
setSelectedRows([]);
}
};
const handlePageSizeChange = size => {
if (curFilter.size !== size) {
dispatch(setLimit(size));
dispatch(setOffset(0));
dispatch(runQuery(tableSchema));
setSelectedRows([]);
persistPageSizeChange(size);
}
};
const paginationProps = {};
if (useCustomPagination) {
paginationProps.PaginationComponent = () => (
<PaginationWithOnlyNav
offset={curFilter.offset}
<PaginationWithOnlyNavContainer
limit={curFilter.limit}
changePage={handlePageChange}
changePageSize={handlePageSizeChange}
offset={curFilter.offset}
onChangePage={handlePageChange}
onChangePageSize={handlePageSizeChange}
pageSize={curFilter.size}
rows={curRows}
tableSchema={tableSchema}
userQuery={userQuery}
/>
);
}
@ -1045,9 +1007,18 @@ const ViewRows = props => {
isVisible = true;
}
const isFilterSectionVisible =
!isSingleRow && (curRelName === activePath[curDepth] || curDepth === 0);
return (
<div className={isVisible ? '' : 'hide '}>
{getFilterQuery()}
{isFilterSectionVisible && (
<FilterSectionContainer
dataSourceName={currentSource}
table={{ schema: tableSchema.table_schema, name: curTableName }}
onRunQuery={newUserQuery => setUserQuery(newUserQuery)}
/>
)}
<div className="w-fit ml-0 mt-md">
{getSelectedRowsSection()}
<div>

View File

@ -1,9 +1,9 @@
import { MetadataDataSource } from '../../../../metadata/types';
import { ReduxState } from './../../../../types';
type TableSchema = {
export type TableSchema = {
primary_key?: { columns: string[] };
columns: Array<{ column_name: string }>;
columns: Array<{ column_name: string; data_type: string }>;
};
type TableSchemaWithPK = {

View File

@ -120,7 +120,12 @@ export interface DataSourcesAPI {
TIME?: string;
TIMETZ?: string;
};
operators: Array<{ name: string; value: string; graphqlOp: string }>;
operators: Array<{
name: string;
value: string;
graphqlOp: string;
defaultValue?: string;
}>;
getFetchTablesListQuery: (options: {
schemas: string[];
tables?: QualifiedTable[];

View File

@ -3,11 +3,11 @@ import { SelectItem } from '@/components/Common/SelectInputSplitField/SelectInpu
import { Button } from '@/new-components/Button';
import { FieldArrayWithId } from 'react-hook-form';
import { FilterRow, OperatorItem } from './FilterRow';
import { FormValues } from './types';
import { FiltersAndSortFormValues } from './types';
export type FilterRowsProps = {
columns: SelectItem[];
fields: FieldArrayWithId<FormValues, 'filter', 'id'>[];
fields: FieldArrayWithId<FiltersAndSortFormValues, 'filter', 'id'>[];
onAdd: () => void;
onRemove: (index: number) => void;
operators: OperatorItem[];

View File

@ -1,10 +1,9 @@
import React from 'react';
import { SelectItem } from '@/components/Common/SelectInputSplitField/SelectInputSplitField';
import ExportData, {
ExportDataProps,
} from '@/components/Services/Data/TableBrowseRows/ExportData';
import { Button } from '@/new-components/Button';
import { DropdownMenu } from '@/new-components/DropdownMenu';
import { FormProvider, useForm } from 'react-hook-form';
import { FaFileExport } from 'react-icons/fa';
import { OperatorItem } from './FilterRow';
import { FilterRows } from './FilterRows';
import { SortItem } from './SortRow';
@ -13,24 +12,38 @@ import {
defaultColumn,
defaultOperator,
defaultOrder,
FormValues,
FiltersAndSortFormValues,
} from './types';
import { useFilterRows } from './hooks/useFilterRows';
import { useSortRows } from './hooks/useSortRows';
/*
NOTE:
Component created out of from FilterQuery.
We'll need to delete FilterQuery in the future, when the integration of Redux is complete
and we'll integrate FiltersSection in the Browse Rows tab.
*/
export const sortPlaceholder = '--';
export const sortOptions: SortItem[] = [
{
label: sortPlaceholder,
value: sortPlaceholder,
disabled: true,
},
{
label: 'Asc',
value: 'asc',
},
{
label: 'Desc',
value: 'desc',
},
];
type FiltersSectionProps = {
columns: SelectItem[];
operators: OperatorItem[];
orders: SortItem[];
onExport: ExportDataProps['onExport'];
onSubmit: (values: FormValues) => void;
onExport: (
type: 'CSV' | 'JSON',
formValues: FiltersAndSortFormValues
) => void;
onSubmit: (values: FiltersAndSortFormValues) => void;
};
export const FiltersSection = ({
@ -40,7 +53,7 @@ export const FiltersSection = ({
onExport,
onSubmit,
}: FiltersSectionProps) => {
const methods = useForm<FormValues>({
const methods = useForm<FiltersAndSortFormValues>({
defaultValues: {
filter: [{ column: defaultColumn, operator: defaultOperator, value: '' }],
sort: [{ column: defaultColumn, order: defaultOrder }],
@ -59,6 +72,11 @@ export const FiltersSection = ({
const { onRemoveSortRow, onAddSortRow, sortFields, showFirstRemoveOnSort } =
useSortRows({ methods });
const exportItems = [
[<div onClick={handleSubmit(arg => onExport('CSV', arg))}>CSV</div>],
[<div onClick={handleSubmit(arg => onExport('JSON', arg))}>JSON</div>],
];
return (
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)}>
@ -90,8 +108,11 @@ export const FiltersSection = ({
Run query
</Button>
<div>
{/* TODO: fix the dropdown */}
<ExportData onExport={onExport} />
<DropdownMenu items={exportItems}>
<Button icon={<FaFileExport />} iconPosition="start">
Export data
</Button>
</DropdownMenu>
</div>
</div>
</div>

View File

@ -0,0 +1,126 @@
import { dataSource } from '@/dataSources';
import { useAppDispatch, useAppSelector } from '@/store';
import React, { useEffect, useState } from 'react';
import {
downloadObjectAsCsvFile,
downloadObjectAsJsonFile,
getCurrTimeForFileName,
} from '@/components/Common/utils/jsUtils';
import { vMakeExportRequest } from '../../../components/Services/Data/TableBrowseRows/ViewActions';
import { setOffset } from '../../../components/Services/Data/TableBrowseRows/FilterActions';
import { OperatorItem } from './FilterRow';
import {
adaptFormValuesToQuery,
filterValidUserQuery,
getColumns,
runFilterQuery,
setUrlParams,
} from './FiltersSectionContainer.utils';
import { FiltersSection, sortOptions } from './FiltersSection';
import { FiltersAndSortFormValues, UserQuery } from './types';
import { Table, useTableSchema } from './hooks/useTableSchema';
import { useTableColumns } from './hooks/useTableColumns';
import { useTableName } from './hooks/useTableName';
type FilterSectionContainerProps = {
onRunQuery: (userQuery: UserQuery) => void | null;
dataSourceName: string;
table: Table;
};
const replaceAllDotsWithUnderscore = (text: string) => text.replace(/\./g, '_');
const getFileName = (tableName: string) => {
const replacedTableName = replaceAllDotsWithUnderscore(tableName);
const currentTime = getCurrTimeForFileName();
return `export_${replacedTableName}_${currentTime}`;
};
export const FilterSectionContainer = ({
onRunQuery,
dataSourceName,
table,
}: FilterSectionContainerProps) => {
const dispatch = useAppDispatch();
const query = useAppSelector(state => state.tables.view.query);
const curFilter = useAppSelector(state => state.tables.view.curFilter);
const limit = curFilter.limit;
const offset = curFilter.offset;
const tableSchema = useTableSchema(table);
const tableColumns = useTableColumns({ dataSourceName, table });
const columns = getColumns(query.columns);
const [operators, setOperators] = useState<OperatorItem[]>([]);
useEffect(() => {
const operatorItems: OperatorItem[] = dataSource.operators.map(op => {
return {
label: `[${op.graphqlOp}] ${op.name}`,
value: op.value,
defaultValue: op?.defaultValue,
};
});
setOperators(operatorItems);
}, []);
const onSubmit = (userQuery: UserQuery) => {
dispatch(setOffset(0));
setUrlParams(userQuery.where.$and, userQuery.order_by);
dispatch(
runFilterQuery({
tableSchema,
whereAnd: userQuery.where.$and,
orderBy: userQuery.order_by,
limit,
offset,
})
);
};
const tableName = useTableName({ dataSourceName, table });
const onExportData = (
type: 'CSV' | 'JSON',
formValues: FiltersAndSortFormValues
) => {
const userQuery = filterValidUserQuery(
adaptFormValuesToQuery(formValues, tableColumns)
);
const fileName = getFileName(tableName);
dispatch({ type: 'ViewTable/V_SET_QUERY_OPTS', queryStuff: userQuery });
dispatch(vMakeExportRequest()).then((rows: Record<string, unknown>[]) => {
if (!rows || rows.length === 0) {
return;
}
switch (type) {
case 'JSON':
downloadObjectAsJsonFile(fileName, rows);
break;
case 'CSV':
downloadObjectAsCsvFile(fileName, rows);
break;
default:
}
});
};
return (
<FiltersSection
columns={columns}
operators={operators}
orders={sortOptions}
onExport={onExportData}
onSubmit={(values: FiltersAndSortFormValues) => {
const userQuery = filterValidUserQuery(
adaptFormValuesToQuery(values, tableColumns)
);
if (onRunQuery) {
onRunQuery(userQuery);
}
onSubmit(userQuery);
}}
/>
);
};

View File

@ -0,0 +1,77 @@
import { TableColumn } from '../../DataSource';
import { adaptFormValuesToQuery } from './FiltersSectionContainer.utils';
import { FiltersAndSortFormValues, UserQuery } from './types';
describe('adaptFormValuesToQuery', () => {
it('adapts the form values into a query', () => {
const input: FiltersAndSortFormValues = {
filter: [
{ column: 'text', operator: '$eq', value: 'aaaa' },
{ column: 'id', operator: '$eq', value: '1' },
],
sort: [{ column: 'id', order: 'asc' }],
};
const expected: UserQuery = {
where: {
$and: [
{
text: {
$eq: 'aaaa',
},
},
{
id: {
$eq: 1,
},
},
],
},
order_by: [
{
column: 'id',
type: 'asc',
nulls: 'last',
},
],
};
const columnDataTypes: TableColumn[] = [
{
name: 'id',
dataType: 'integer',
},
{
name: 'text',
dataType: 'text',
},
];
expect(adaptFormValuesToQuery(input, columnDataTypes)).toEqual(expected);
});
it('converts the $in values into array', () => {
const input: FiltersAndSortFormValues = {
filter: [{ column: 'id', operator: '$in', value: '[1, 2]' }],
sort: [],
};
const expected: UserQuery = {
where: {
$and: [
{
id: {
$in: [1, 2],
},
},
],
},
order_by: [],
};
const columnDataTypes: TableColumn[] = [
{
name: 'id',
dataType: 'integer',
},
];
expect(adaptFormValuesToQuery(input, columnDataTypes)).toEqual(expected);
});
});

View File

@ -0,0 +1,229 @@
import { SelectItem } from '@/components/Common/SelectInputSplitField/SelectInputSplitField';
import { TableColumn } from '@/features/DataSource';
import { createHistory } from 'history';
import { ThunkDispatch } from 'redux-thunk';
import { AnyAction } from 'redux';
import { ReduxState } from '@/types';
import { TableSchema } from '@/components/Services/Data/TableBrowseRows/utils';
import { Integers, Reals } from '../../../components/Services/Data/constants';
import { vMakeTableRequests } from '../../../components/Services/Data/TableBrowseRows/ViewActions';
import { sortPlaceholder } from './FiltersSection';
import { FiltersAndSortFormValues, OrderCondition, UserQuery } from './types';
export const columnPlaceholder = '-- column --';
const convertArray = (arrayString: string): number[] | string[] =>
JSON.parse(arrayString);
const convertValue = (
value: string | string[] | number[],
tableColumnType: string
) => {
if (Array.isArray(value)) {
return value;
}
if (tableColumnType === 'integer') {
return parseInt(value, 10);
}
if (tableColumnType === 'boolean') {
return Boolean(value);
}
return value;
};
export const adaptFormValuesToQuery = (
formValues: FiltersAndSortFormValues,
columnDataTypes: TableColumn[]
): UserQuery => {
const where =
formValues?.filter?.map(filter => {
const columnDataType = columnDataTypes.find(
col => col.name === filter.column
);
let partialValue: string | string[] | number[] = filter.value;
if (filter.operator === '$in' || filter.operator === '$nin') {
partialValue = convertArray(filter.value);
}
const value = !columnDataType
? filter.value
: convertValue(partialValue, columnDataType.dataType);
return {
[filter.column]: {
[filter.operator]: value,
},
};
}) ?? [];
const orderBy =
formValues?.sort?.map(order => {
const orderCondition: OrderCondition = {
column: order.column,
type: order.order,
nulls: 'last',
};
return orderCondition;
}) ?? [];
return {
where: {
$and: where,
},
order_by: orderBy,
};
};
type SetParamsArgs = {
filters: string[];
sorts: string[];
};
const setParams = (query: SetParamsArgs = { filters: [], sorts: [] }) => {
const searchParams = new URLSearchParams();
query.filters.forEach(filter => searchParams.append('filter', filter));
query.sorts.forEach(sort => searchParams.append('sort', sort));
return searchParams.toString();
};
export const setUrlParams = (
whereAnd: UserQuery['where']['$and'],
orderBy: UserQuery['order_by']
) => {
const sorts = orderBy
.filter(order => order.column)
.map(order => `${order.column};${order.type}`);
const filters = whereAnd
.filter(
where => Object.keys(where).length === 1 && Object.keys(where)[0] !== ''
)
.map(where => {
const col = Object.keys(where)[0];
const op = Object.keys(where[col])[0];
const value = where[col][op];
return `${col};${op};${value}`;
});
const url = setParams({ filters, sorts });
const history = createHistory();
history.push({
pathname: history.getCurrentLocation().pathname,
search: `?${url}`,
});
};
const parseArray = (val: string | number | boolean | string[] | number[]) => {
if (Array.isArray(val)) return val;
if (typeof val === 'string') {
try {
return JSON.parse(val);
} catch (err) {
return '';
}
}
return val;
};
const defaultColumns: SelectItem[] = [
{
label: columnPlaceholder,
value: columnPlaceholder,
disabled: true,
},
];
export const getColumns = (columns: string[]) => {
const columnsSelectItems: SelectItem[] = columns.map((columnName: string) => {
return {
label: columnName,
value: columnName,
};
});
return defaultColumns.concat(columnsSelectItems);
};
export const filterValidUserQuery = (userQuery: UserQuery): UserQuery => {
const filteredWhere = userQuery.where.$and.filter(
condition => Object.keys(condition)[0] !== columnPlaceholder
);
const filteredOrderBy = userQuery.order_by.filter(
order => order.type !== sortPlaceholder
);
return {
...userQuery,
where: { $and: filteredWhere },
order_by: filteredOrderBy,
};
};
type RunFilterQuery = {
tableSchema: TableSchema;
whereAnd: UserQuery['where']['$and'];
orderBy: UserQuery['order_by'];
limit: number;
offset: number;
};
export const runFilterQuery =
({ tableSchema, whereAnd, orderBy, limit, offset }: RunFilterQuery) =>
(dispatch: ThunkDispatch<ReduxState, unknown, AnyAction>) => {
const whereClauses = whereAnd.filter(w => {
const colName = Object.keys(w)[0].trim();
if (colName === '') {
return false;
}
const opName = Object.keys(w[colName])[0].trim();
if (opName === '') {
return false;
}
return true;
});
const finalWhereClauses = whereClauses.map(whereClause => {
const colName = Object.keys(whereClause)[0];
const opName = Object.keys(whereClause[colName])[0];
const val = whereClause[colName][opName];
if (['$in', '$nin'].includes(opName)) {
whereClause[colName][opName] = parseArray(val);
return whereClause;
}
const colType =
tableSchema?.columns?.find(column => column.column_name === colName)
?.data_type || '';
if (Integers.indexOf(colType) > 0 && typeof val === 'string') {
whereClause[colName][opName] = parseInt(val, 10);
return whereClause;
}
if (Reals.indexOf(colType) > 0 && typeof val === 'string') {
whereClause[colName][opName] = parseFloat(val);
return whereClause;
}
if (colType === 'boolean') {
if (val === 'true') {
whereClause[colName][opName] = true;
return whereClause;
}
if (val === 'false') {
whereClause[colName][opName] = false;
return whereClause;
}
}
return whereClause;
});
const newQuery = {
where: { $and: finalWhereClauses },
limit,
offset,
order_by: orderBy.filter(w => w.column.trim() !== ''),
};
dispatch({ type: 'ViewTable/V_SET_QUERY_OPTS', queryStuff: newQuery });
dispatch(vMakeTableRequests());
};

View File

@ -3,11 +3,11 @@ import { SelectItem } from '@/components/Common/SelectInputSplitField/SelectInpu
import { Button } from '@/new-components/Button';
import { FieldArrayWithId } from 'react-hook-form';
import { SortItem, SortRow } from './SortRow';
import { FormValues } from './types';
import { FiltersAndSortFormValues } from './types';
export type SortRowsProps = {
columns: SelectItem[];
fields: FieldArrayWithId<FormValues, 'sort', 'id'>[];
fields: FieldArrayWithId<FiltersAndSortFormValues, 'sort', 'id'>[];
onAdd: () => void;
onRemove: (index: number) => void;
orders: SortItem[];

View File

@ -1,10 +1,14 @@
import { useState, useEffect } from 'react';
import { useFieldArray, UseFormReturn } from 'react-hook-form';
import { OperatorItem } from '../FilterRow';
import { defaultColumn, defaultOperator, FormValues } from '../types';
import {
defaultColumn,
defaultOperator,
FiltersAndSortFormValues,
} from '../types';
type UseFilterRowsProps = {
methods: UseFormReturn<FormValues, any>;
methods: UseFormReturn<FiltersAndSortFormValues, any>;
operators: OperatorItem[];
};
@ -22,41 +26,38 @@ export const useFilterRows = ({ methods, operators }: UseFilterRowsProps) => {
const [showFirstRemove, setShowFirstRemove] = useState(false);
useEffect(() => {
const subscription = watch(
(formValues, { name: fieldName, type: eventType }) => {
console.log(formValues, fieldName, eventType);
const rowId = getRowIndex(fieldName || '');
const subscription = watch((formValues, { name: fieldName }) => {
const rowId = getRowIndex(fieldName || '');
if (
rowId === 0 &&
fieldName?.endsWith('.column') &&
getValues(fieldName) !== defaultColumn
) {
setShowFirstRemove(true);
}
if (
rowId === 0 &&
fieldName?.endsWith('.value') &&
getValues(fieldName) !== ''
) {
setShowFirstRemove(true);
}
if (fieldName?.endsWith('.operator')) {
const operatorValue = getValues(fieldName);
const operatorDefinition = operators.find(
op => op.value === operatorValue
);
setValue(
`filter.${rowId}.value`,
operatorDefinition?.defaultValue || ''
);
}
if (
rowId === 0 &&
fieldName?.endsWith('.column') &&
getValues(fieldName) !== defaultColumn
) {
setShowFirstRemove(true);
}
);
if (
rowId === 0 &&
fieldName?.endsWith('.value') &&
getValues(fieldName) !== ''
) {
setShowFirstRemove(true);
}
if (fieldName?.endsWith('.operator')) {
const operatorValue = getValues(fieldName);
const operatorDefinition = operators.find(
op => op.value === operatorValue
);
setValue(
`filter.${rowId}.value`,
operatorDefinition?.defaultValue || ''
);
}
});
return () => subscription.unsubscribe();
}, [watch]);
}, [watch, operators]);
const onAdd = () =>
append({

View File

@ -1,9 +1,13 @@
import { useState, useEffect } from 'react';
import { UseFormReturn, useFieldArray } from 'react-hook-form';
import { defaultColumn, defaultOrder, FormValues } from '../types';
import {
defaultColumn,
defaultOrder,
FiltersAndSortFormValues,
} from '../types';
type UseSortRowsProps = {
methods: UseFormReturn<FormValues, any>;
methods: UseFormReturn<FiltersAndSortFormValues, any>;
};
export const useSortRows = ({ methods }: UseSortRowsProps) => {
@ -41,7 +45,7 @@ export const useSortRows = ({ methods }: UseSortRowsProps) => {
}
});
return () => subscription.unsubscribe();
}, [watch]);
}, [watch, getValues]);
const onRemove = (index: number) => {
if (index > 0) {

View File

@ -0,0 +1,37 @@
import { useState, useEffect } from 'react';
import { DataSource, TableColumn } from '@/features/DataSource';
import { useHttpClient } from '@/features/Network';
import { useIsUnmounted } from '@/components/Services/Data';
type UseTableColumnsProps = {
dataSourceName: string;
table: unknown;
};
export const useTableColumns = ({
dataSourceName,
table,
}: UseTableColumnsProps) => {
const httpClient = useHttpClient();
const [tableColumns, setTableColumns] = useState<TableColumn[]>([]);
const isUnMounted = useIsUnmounted();
useEffect(() => {
async function fetchTableColumns() {
const tableColumnDefinitions = await DataSource(
httpClient
).getTableColumns({
dataSourceName,
table,
});
if (isUnMounted()) {
return;
}
setTableColumns(tableColumnDefinitions);
}
fetchTableColumns();
}, [dataSourceName, httpClient, table, isUnMounted]);
return tableColumns;
};

View File

@ -0,0 +1,34 @@
import { useIsUnmounted } from '@/components/Services/Data';
import { getTableName } from '@/features/Data';
import { DataSource } from '@/features/DataSource';
import { useHttpClient } from '@/features/Network';
import { useState, useEffect } from 'react';
type UseTableNameProps = {
dataSourceName: string;
table: unknown;
};
export const useTableName = ({ dataSourceName, table }: UseTableNameProps) => {
const httpClient = useHttpClient();
const [tableName, setTableName] = useState('');
const isUnMounted = useIsUnmounted();
useEffect(() => {
async function fetchTableHierarchy() {
const databaseHierarchy = await DataSource(
httpClient
).getDatabaseHierarchy({ dataSourceName });
if (isUnMounted()) {
return;
}
const aTableName = getTableName(table, databaseHierarchy);
setTableName(aTableName);
}
fetchTableHierarchy();
}, [dataSourceName, table, httpClient, isUnMounted]);
return tableName;
};

View File

@ -0,0 +1,18 @@
import { NormalizedTable } from '@/dataSources/types';
import { useAppSelector } from '@/store';
// NOTE: temporary type, to be replaced when GDC is ready
export type Table = { schema: string; name: string };
export const useTableSchema = (table: Table) => {
const tableSchema = useAppSelector(state =>
state.tables.allSchemas.find((schema: NormalizedTable) => {
return (
schema.table_schema === table?.schema &&
schema.table_name === table?.name
);
})
);
return tableSchema;
};

View File

@ -1,4 +1,4 @@
export type FormValues = {
export type FiltersAndSortFormValues = {
filter: {
column: string;
operator: string;
@ -6,10 +6,31 @@ export type FormValues = {
}[];
sort: {
column: string;
order: string;
order: 'asc' | 'desc' | '--';
}[];
};
export const defaultColumn = '-- column --';
export const defaultOperator = '$eq';
export const defaultOrder = '--';
type ColumnName = string;
type OperatorType = string; // TODO: find the list of possible operators
type OperatorCondition = Record<
OperatorType,
string | number | number[] | string[] | boolean
>;
export type WhereCondition = Record<ColumnName, OperatorCondition>;
export type OrderCondition = {
column: string;
type: 'desc' | 'asc' | '--';
nulls: 'last';
};
export type UserQuery = {
where: Record<'$and', WhereCondition[]>;
order_by: OrderCondition[];
};

View File

@ -0,0 +1,3 @@
export { runFilterQuery } from './FiltersSection/FiltersSectionContainer.utils';
export type { UserQuery } from './FiltersSection/types';

View File

@ -1,3 +1,4 @@
export * from './ManageContainer';
export * from './components';
export * from './hooks';
export { getTableName } from './TrackTables/hooks/useTables';

View File

@ -0,0 +1,83 @@
import React from 'react';
import { persistPageSizeChange } from '@/components/Services/Data/TableBrowseRows/tableUtils';
import { runFilterQuery } from '@/features/BrowseRows';
import type { UserQuery } from '@/features/BrowseRows';
import { useAppDispatch } from '@/store';
import { TableSchema } from '@/components/Services/Data/TableBrowseRows/utils';
import {
setLimit,
setOffset,
} from '../../components/Services/Data/TableBrowseRows/FilterActions';
import {
PaginationWithOnlyNav,
PaginationWithOnlyNavProps,
} from './PaginationWithOnlyNav';
type PaginationWithOnlyNavContainerProps = {
limit: PaginationWithOnlyNavProps['limit'];
offset: PaginationWithOnlyNavProps['offset'];
onChangePage: PaginationWithOnlyNavProps['changePage'];
onChangePageSize: PaginationWithOnlyNavProps['changePageSize'];
pageSize: number;
rows: PaginationWithOnlyNavProps['rows'];
tableSchema: TableSchema;
userQuery: UserQuery;
};
export const PaginationWithOnlyNavContainer = ({
limit,
offset,
onChangePage,
onChangePageSize,
pageSize,
rows,
tableSchema,
userQuery,
}: PaginationWithOnlyNavContainerProps) => {
const dispatch = useAppDispatch();
const changePageHandler = (newPage: number) => {
if (offset !== newPage * limit) {
const newOffset = newPage * limit;
dispatch(setOffset(newPage * limit));
dispatch(
runFilterQuery({
tableSchema,
whereAnd: userQuery.where.$and,
orderBy: userQuery.order_by,
limit,
offset: newOffset,
})
);
onChangePage(newPage);
}
};
const changePageSizeHandler = (newPageSize: number) => {
if (pageSize !== newPageSize) {
dispatch(setLimit(newPageSize));
dispatch(setOffset(0));
dispatch(
runFilterQuery({
tableSchema,
whereAnd: userQuery.where.$and,
orderBy: userQuery.order_by,
limit: newPageSize,
offset: 0,
})
);
persistPageSizeChange(newPageSize);
onChangePageSize(newPageSize);
}
};
return (
<PaginationWithOnlyNav
offset={offset}
limit={limit}
changePage={changePageHandler}
changePageSize={changePageSizeHandler}
rows={rows}
/>
);
};