refactor: use rison for list view filters stateful urls (#11675)

This commit is contained in:
Moriah Kreeger 2020-11-24 11:19:48 -08:00 committed by GitHub
parent 91bcbc8350
commit 6019113bc6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 212 additions and 78 deletions

View File

@ -39161,12 +39161,13 @@
"dev": true
},
"query-string": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz",
"integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=",
"version": "6.13.7",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-6.13.7.tgz",
"integrity": "sha512-CsGs8ZYb39zu0WLkeOhe0NMePqgYdAuCqxOYKDR5LVCytDZYMGx3Bb+xypvQvPHVPijRXB0HZNFllCzHRe4gEA==",
"requires": {
"object-assign": "^4.1.0",
"strict-uri-encode": "^1.0.0"
"decode-uri-component": "^0.2.0",
"split-on-first": "^1.0.0",
"strict-uri-encode": "^2.0.0"
}
},
"querystring": {
@ -41196,6 +41197,22 @@
"is-retina": "^1.0.3",
"md5": "^2.1.0",
"query-string": "^4.2.2"
},
"dependencies": {
"query-string": {
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz",
"integrity": "sha1-u7aTucqRXCMlFbIosaArYJBD2+s=",
"requires": {
"object-assign": "^4.1.0",
"strict-uri-encode": "^1.0.0"
}
},
"strict-uri-encode": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
"integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM="
}
}
},
"react-helmet-async": {
@ -43494,24 +43511,9 @@
}
},
"serialize-query-params": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/serialize-query-params/-/serialize-query-params-0.1.4.tgz",
"integrity": "sha512-d3GHKPAOBULhCMg+jM687vRIMnTXMo8M0lHUOVeFxSGYvfmNlksiOpLyb0orhXPhhFCvZvt+SwC2iPRVIhKS/g==",
"requires": {
"query-string": "^5.0.0"
},
"dependencies": {
"query-string": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-5.1.1.tgz",
"integrity": "sha512-gjWOsm2SoGlgLEdAGt7a6slVOk9mGiXmPFMqrEhLQ68rhQuBnpfs3+EmlvqKyxnCo9/PPlF+9MtY02S1aFg+Jw==",
"requires": {
"decode-uri-component": "^0.2.0",
"object-assign": "^4.1.0",
"strict-uri-encode": "^1.0.0"
}
}
}
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/serialize-query-params/-/serialize-query-params-1.2.4.tgz",
"integrity": "sha512-m4hGkOY5y+ksPDSEkw12cNxt3HRUJv5G6oF9/4yq+GCw4LznudxC73qnz++VTHqXa0j1x1/iaBIpoiMBxr6w2w=="
},
"serve-favicon": {
"version": "2.5.0",
@ -44313,6 +44315,11 @@
"through": "2"
}
},
"split-on-first": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="
},
"split-string": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz",
@ -44582,9 +44589,9 @@
"dev": true
},
"strict-uri-encode": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz",
"integrity": "sha1-J5siXfHVgrH1TmWt3UNS4Y+qBxM="
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
"integrity": "sha1-ucczDHBChi9rFC3CdLvMWGbONUY="
},
"string-convert": {
"version": "0.2.1",
@ -46753,11 +46760,11 @@
}
},
"use-query-params": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/use-query-params/-/use-query-params-0.4.5.tgz",
"integrity": "sha512-HeSgLvEj26pkNRGeAIq+uTo6Z22iaAqDMosq+Be5lab4v57gwVIUKsS3iZ1BBgsUbLEKKpoqcVvqd9MUg+lkIw==",
"version": "1.1.9",
"resolved": "https://registry.npmjs.org/use-query-params/-/use-query-params-1.1.9.tgz",
"integrity": "sha512-WAJ1GrKbFWv1TBn1RQpHqAwC7yyJsLaJjBhIfefrbY/h6mFSngzBQKirJndYwCS1ry77EwhpR/tQi5iovXWvuw==",
"requires": {
"serialize-query-params": "^0.1.4"
"serialize-query-params": "^1.2.3"
}
},
"use-sidecar": {

View File

@ -127,6 +127,7 @@
"omnibar": "^2.1.1",
"polished": "^3.6.5",
"prop-types": "^15.7.2",
"query-string": "^6.13.7",
"re-resizable": "^6.6.1",
"react": "^16.13.1",
"react-ace": "^5.10.0",
@ -168,7 +169,7 @@
"rison": "^0.1.1",
"shortid": "^2.2.6",
"urijs": "^1.18.10",
"use-query-params": "^0.4.5"
"use-query-params": "^1.1.9"
},
"devDependencies": {
"@babel/cli": "^7.11.5",

View File

@ -0,0 +1,40 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { ThemeProvider } from '@superset-ui/core';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import { QueryParamProvider } from 'use-query-params';
export function ProviderWrapper(props: any) {
const { children, theme } = props;
return (
<ThemeProvider theme={theme}>
<Router>
<QueryParamProvider
ReactRouterRoute={Route}
stringifyOptions={{ encode: false }}
>
{children}
</QueryParamProvider>
</Router>
</ThemeProvider>
);
}

View File

@ -17,8 +17,9 @@
* under the License.
*/
import { shallow as enzymeShallow, mount as enzymeMount } from 'enzyme';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import { supersetTheme } from '@superset-ui/core';
import { ReactElement } from 'react';
import { ProviderWrapper } from './ProviderWrapper';
type optionsType = {
wrappingComponentProps?: any;
@ -32,7 +33,7 @@ export function styledMount(
) {
return enzymeMount(component, {
...options,
wrappingComponent: ThemeProvider,
wrappingComponent: ProviderWrapper,
wrappingComponentProps: {
theme: supersetTheme,
...options?.wrappingComponentProps,
@ -46,7 +47,7 @@ export function styledShallow(
) {
return enzymeShallow(component, {
...options,
wrappingComponent: ThemeProvider,
wrappingComponent: ProviderWrapper,
wrappingComponentProps: {
theme: supersetTheme,
...options?.wrappingComponentProps,

View File

@ -17,7 +17,7 @@
* under the License.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { styledMount as mount } from 'spec/helpers/theming';
import { getChartControlPanelRegistry } from '@superset-ui/core';
import AlteredSliceTag from 'src/components/AlteredSliceTag';
@ -34,7 +34,7 @@ import {
} from './fixtures/AlteredSliceTag';
const getTableWrapperFromModalBody = modalBody =>
modalBody.find(ListView).shallow().find(TableCollection).shallow();
modalBody.find(ListView).find(TableCollection);
describe('AlteredSliceTag', () => {
let wrapper;
@ -47,7 +47,7 @@ describe('AlteredSliceTag', () => {
fakePluginControls,
);
props = { ...defaultProps };
wrapper = shallow(<AlteredSliceTag {...props} />);
wrapper = mount(<AlteredSliceTag {...props} />);
({ controlsMap } = wrapper.instance().state);
});
@ -63,7 +63,7 @@ describe('AlteredSliceTag', () => {
origFormData: props.origFormData,
currentFormData: props.origFormData,
};
wrapper = shallow(<AlteredSliceTag {...props} />);
wrapper = mount(<AlteredSliceTag {...props} />);
expect(wrapper.instance().state.rows).toEqual([]);
expect(wrapper.instance().state.hasDiffs).toBe(false);
expect(wrapper.instance().render()).toBeNull();
@ -78,7 +78,7 @@ describe('AlteredSliceTag', () => {
currentFormData: { ...props.currentFormData },
origFormData: { ...props.origFormData },
};
wrapper = shallow(<AlteredSliceTag {...props} />);
wrapper = mount(<AlteredSliceTag {...props} />);
const wrapperInstance = wrapper.instance();
wrapperInstance.UNSAFE_componentWillReceiveProps(newProps);
expect(getRowsFromDiffsStub).toHaveBeenCalled();
@ -98,7 +98,7 @@ describe('AlteredSliceTag', () => {
describe('renderTriggerNode', () => {
it('renders a TooltipWrapper', () => {
const triggerNode = shallow(
const triggerNode = mount(
<div>{wrapper.instance().renderTriggerNode()}</div>,
);
expect(triggerNode.find(TooltipWrapper)).toHaveLength(1);
@ -107,14 +107,14 @@ describe('AlteredSliceTag', () => {
describe('renderModalBody', () => {
it('renders a Table', () => {
const modalBody = shallow(
const modalBody = mount(
<div>{wrapper.instance().renderModalBody()}</div>,
);
expect(modalBody.find(ListView)).toHaveLength(1);
});
it('renders a thead', () => {
const modalBody = shallow(
const modalBody = mount(
<div>{wrapper.instance().renderModalBody()}</div>,
);
expect(
@ -123,7 +123,7 @@ describe('AlteredSliceTag', () => {
});
it('renders th', () => {
const modalBody = shallow(
const modalBody = mount(
<div>{wrapper.instance().renderModalBody()}</div>,
);
const th = getTableWrapperFromModalBody(modalBody).find('th');
@ -134,7 +134,7 @@ describe('AlteredSliceTag', () => {
});
it('renders the correct number of Tr', () => {
const modalBody = shallow(
const modalBody = mount(
<div>{wrapper.instance().renderModalBody()}</div>,
);
const tr = getTableWrapperFromModalBody(modalBody).find('tr');
@ -142,7 +142,7 @@ describe('AlteredSliceTag', () => {
});
it('renders the correct number of td', () => {
const modalBody = shallow(
const modalBody = mount(
<div>{wrapper.instance().renderModalBody()}</div>,
);
const td = getTableWrapperFromModalBody(modalBody).find('td');
@ -155,12 +155,12 @@ describe('AlteredSliceTag', () => {
describe('renderRows', () => {
it('returns an array of rows with one tr and three td', () => {
const modalBody = shallow(
const modalBody = mount(
<div>{wrapper.instance().renderModalBody()}</div>,
);
const rows = getTableWrapperFromModalBody(modalBody).find('tr');
expect(rows).toHaveLength(8);
const fakeRow = shallow(<div>{rows.get(1)}</div>);
const fakeRow = mount(<div>{rows.get(1)}</div>);
expect(fakeRow.find('tr')).toHaveLength(1);
expect(fakeRow.find('td')).toHaveLength(3);
});

View File

@ -17,7 +17,7 @@
* under the License.
*/
import React from 'react';
import { mount, shallow } from 'enzyme';
import { styledMount as mount } from 'spec/helpers/theming';
import { act } from 'react-dom/test-utils';
import { QueryParamProvider } from 'use-query-params';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
@ -338,7 +338,7 @@ describe('ListView', () => {
filters: [...mockedProps.filters, { id: 'some_column' }],
};
expect(() => {
shallow(<ListView {...props} />, {
mount(<ListView {...props} />, {
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});

View File

@ -87,8 +87,18 @@ function SelectFilter({
};
const options = [clearFilterSelect, ...selects];
let initialOption = clearFilterSelect;
const [selectedOption, setSelectedOption] = useState(clearFilterSelect);
// Set initial value if not async
if (!fetchSelects) {
const matchingOption = options.find(x => x.value === initialValue);
if (matchingOption) {
initialOption = matchingOption;
}
}
const [selectedOption, setSelectedOption] = useState(initialOption);
const onChange = (selected: SelectOption | null) => {
if (selected === null) return;
onSelect(

View File

@ -17,7 +17,7 @@
* under the License.
*/
import { t, styled } from '@superset-ui/core';
import React, { useEffect, useState } from 'react';
import React, { useEffect } from 'react';
import { Alert } from 'react-bootstrap';
import { Empty } from 'src/common/components';
import { ReactComponent as EmptyImage } from 'images/empty.svg';
@ -34,6 +34,7 @@ import {
Filters,
SortColumn,
CardSortSelectOption,
ViewModeType,
} from './types';
import { ListViewError, useListViewState } from './utils';
@ -202,7 +203,6 @@ const ViewModeToggle = ({
);
};
type ViewModeType = 'card' | 'table';
export interface ListViewProps<T extends object = any> {
columns: any[];
data: T[];
@ -263,7 +263,8 @@ function ListView<T extends object = any>({
applyFilterValue,
selectedFlatRows,
toggleAllRowsSelected,
state: { pageIndex, pageSize, internalFilters },
setViewMode,
state: { pageIndex, pageSize, internalFilters, viewMode },
} = useListViewState({
bulkSelectColumnConfig,
bulkSelectMode: bulkSelectEnabled && Boolean(bulkActions.length),
@ -274,6 +275,8 @@ function ListView<T extends object = any>({
initialPageSize,
initialSort,
initialFilters: filters,
renderCard: Boolean(renderCard),
defaultViewMode,
});
const filterable = Boolean(filters.length);
if (filterable) {
@ -291,9 +294,6 @@ function ListView<T extends object = any>({
}
const cardViewEnabled = Boolean(renderCard);
const [viewingMode, setViewingMode] = useState<ViewModeType>(
cardViewEnabled ? defaultViewMode : 'table',
);
useEffect(() => {
// discard selections if bulk select is disabled
@ -306,7 +306,7 @@ function ListView<T extends object = any>({
<div className="header">
<div className="header-left">
{cardViewEnabled && (
<ViewModeToggle mode={viewingMode} setMode={setViewingMode} />
<ViewModeToggle mode={viewMode} setMode={setViewMode} />
)}
{filterable && (
<FilterControls
@ -317,7 +317,7 @@ function ListView<T extends object = any>({
)}
</div>
<div className="header-right">
{viewingMode === 'card' && cardSortSelectOptions && (
{viewMode === 'card' && cardSortSelectOptions && (
<CardSortSelect
initialSort={initialSort}
onChange={fetchData}
@ -367,7 +367,7 @@ function ListView<T extends object = any>({
)}
</BulkSelectWrapper>
)}
{viewingMode === 'card' && (
{viewMode === 'card' && (
<CardCollection
bulkSelectEnabled={bulkSelectEnabled}
prepareRow={prepareRow}
@ -376,7 +376,7 @@ function ListView<T extends object = any>({
loading={loading}
/>
)}
{viewingMode === 'table' && (
{viewMode === 'table' && (
<TableCollection
getTableProps={getTableProps}
getTableBodyProps={getTableBodyProps}
@ -389,7 +389,7 @@ function ListView<T extends object = any>({
/>
)}
{!loading && rows.length === 0 && (
<EmptyWrapper className={viewingMode}>
<EmptyWrapper className={viewMode}>
<Empty
image={<EmptyImage />}
description={emptyState.message || 'No Data'}

View File

@ -71,6 +71,8 @@ export interface Filter {
export type Filters = Filter[];
export type ViewModeType = 'card' | 'table';
export interface FilterValue {
id: string;
operator?: string;

View File

@ -26,13 +26,9 @@ import {
useTable,
} from 'react-table';
import {
JsonParam,
NumberParam,
StringParam,
useQueryParams,
} from 'use-query-params';
import { NumberParam, StringParam, useQueryParams } from 'use-query-params';
import rison from 'rison';
import { isEqual } from 'lodash';
import { PartialStylesConfig } from 'src/components/Select';
import {
@ -41,8 +37,17 @@ import {
FilterValue,
InternalFilter,
SortColumn,
ViewModeType,
} from './types';
// Define custom RisonParam for proper encoding/decoding
const RisonParam = {
encode: (data: any | null | undefined) =>
data === undefined ? undefined : rison.encode(data),
decode: (dataStr: string | undefined) =>
dataStr === undefined ? undefined : rison.decode(dataStr),
};
export class ListViewError extends Error {
name = 'ListViewError';
}
@ -63,11 +68,11 @@ function updateInList(list: any[], index: number, update: any): any[] {
];
}
function mergeCreateFilterValues(list: Filter[], updateList: FilterValue[]) {
function mergeCreateFilterValues(list: Filter[], updateObj: any) {
return list.map(({ id, operator }) => {
const update = updateList.find(obj => obj.id === id);
const update = updateObj[id];
return { id, operator, value: update?.value };
return { id, operator, value: update };
});
}
@ -78,6 +83,37 @@ export function convertFilters(fts: InternalFilter[]): FilterValue[] {
.map(({ value, operator, id }) => ({ value, operator, id }));
}
// convertFilters but to handle new decoded rison format
export function convertFiltersRison(
filterObj: any,
list: Filter[],
): FilterValue[] {
const filters: FilterValue[] = [];
const refs = {};
Object.keys(filterObj).forEach(id => {
const filter: FilterValue = {
id,
value: filterObj[id],
// operator: filterObj[id][1], // TODO: can probably get rid of this
};
refs[id] = filter;
filters.push(filter);
});
// Add operators from filter list
list.forEach(value => {
const filter = refs[value.id];
if (filter) {
filter.operator = value.operator;
}
});
return filters;
}
export function extractInputValue(inputType: Filter['input'], event: any) {
if (!inputType || inputType === 'text') {
return event.currentTarget.value;
@ -110,6 +146,8 @@ interface UseListViewConfig {
Header: (conf: any) => React.ReactNode;
Cell: (conf: any) => React.ReactNode;
};
renderCard?: boolean;
defaultViewMode?: ViewModeType;
}
export function useListViewState({
@ -122,12 +160,15 @@ export function useListViewState({
initialSort = [],
bulkSelectMode = false,
bulkSelectColumnConfig,
renderCard = false,
defaultViewMode = 'card',
}: UseListViewConfig) {
const [query, setQuery] = useQueryParams({
filters: JsonParam,
filters: RisonParam,
pageIndex: NumberParam,
sortColumn: StringParam,
sortOrder: StringParam,
viewMode: StringParam,
});
const initialSortBy = useMemo(
@ -139,12 +180,19 @@ export function useListViewState({
);
const initialState = {
filters: convertFilters(query.filters || []),
filters: query.filters
? convertFiltersRison(query.filters, initialFilters)
: [],
pageIndex: query.pageIndex || 0,
pageSize: initialPageSize,
sortBy: initialSortBy,
};
const [viewMode, setViewMode] = useState<ViewModeType>(
(query.viewMode as ViewModeType) ||
(renderCard ? defaultViewMode : 'table'),
);
const columnsWithSelect = useMemo(() => {
// add exact filter type so filters with falsey values are not filtered out
const columnsWithFilter = columns.map(f => ({ ...f, filter: 'exact' }));
@ -189,20 +237,37 @@ export function useListViewState({
);
const [internalFilters, setInternalFilters] = useState<InternalFilter[]>(
query.filters || [],
query.filters && initialFilters.length
? mergeCreateFilterValues(initialFilters, query.filters)
: [],
);
useEffect(() => {
if (initialFilters.length) {
setInternalFilters(
mergeCreateFilterValues(initialFilters, query.filters || []),
mergeCreateFilterValues(
initialFilters,
query.filters ? query.filters : {},
),
);
}
}, [initialFilters]);
useEffect(() => {
// From internalFilters, produce a simplified obj
const filterObj = {};
internalFilters.forEach(filter => {
if (
filter.value !== undefined &&
(typeof filter.value !== 'string' || filter.value.length > 0)
) {
filterObj[filter.id] = filter.value;
}
});
const queryParams: any = {
filters: internalFilters,
filters: Object.keys(filterObj).length ? filterObj : undefined,
pageIndex,
};
if (sortBy[0]) {
@ -210,6 +275,10 @@ export function useListViewState({
queryParams.sortOrder = sortBy[0].desc ? 'desc' : 'asc';
}
if (renderCard) {
queryParams.viewMode = viewMode;
}
const method =
typeof query.pageIndex !== 'undefined' &&
queryParams.pageIndex !== query.pageIndex
@ -218,7 +287,7 @@ export function useListViewState({
setQuery(queryParams, method);
fetchData({ pageIndex, pageSize, sortBy, filters });
}, [fetchData, pageIndex, pageSize, sortBy, filters]);
}, [fetchData, pageIndex, pageSize, sortBy, filters, viewMode]);
useEffect(() => {
if (!isEqual(initialState.pageIndex, pageIndex)) {
@ -256,9 +325,10 @@ export function useListViewState({
rows,
selectedFlatRows,
setAllFilters,
state: { pageIndex, pageSize, sortBy, filters, internalFilters },
state: { pageIndex, pageSize, sortBy, filters, internalFilters, viewMode },
toggleAllRowsSelected,
applyFilterValue,
setViewMode,
};
}

View File

@ -68,7 +68,10 @@ const App = () => (
<ThemeProvider theme={supersetTheme}>
<FlashProvider common={common}>
<Router>
<QueryParamProvider ReactRouterRoute={Route}>
<QueryParamProvider
ReactRouterRoute={Route}
stringifyOptions={{ encode: false }}
>
<Menu data={menu} />
<Switch>
<Route path="/superset/welcome/">