diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index ea758d431..8dbd9a743 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -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": { diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 80981728c..c004b940d 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -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", diff --git a/superset-frontend/spec/helpers/ProviderWrapper.tsx b/superset-frontend/spec/helpers/ProviderWrapper.tsx new file mode 100644 index 000000000..f505a8114 --- /dev/null +++ b/superset-frontend/spec/helpers/ProviderWrapper.tsx @@ -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 ( + + + + {children} + + + + ); +} diff --git a/superset-frontend/spec/helpers/theming.ts b/superset-frontend/spec/helpers/theming.ts index dbb130a11..2ec019b70 100644 --- a/superset-frontend/spec/helpers/theming.ts +++ b/superset-frontend/spec/helpers/theming.ts @@ -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, diff --git a/superset-frontend/spec/javascripts/components/AlteredSliceTag_spec.jsx b/superset-frontend/spec/javascripts/components/AlteredSliceTag_spec.jsx index 41b4fe97a..78efb7884 100644 --- a/superset-frontend/spec/javascripts/components/AlteredSliceTag_spec.jsx +++ b/superset-frontend/spec/javascripts/components/AlteredSliceTag_spec.jsx @@ -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(); + wrapper = mount(); ({ controlsMap } = wrapper.instance().state); }); @@ -63,7 +63,7 @@ describe('AlteredSliceTag', () => { origFormData: props.origFormData, currentFormData: props.origFormData, }; - wrapper = shallow(); + wrapper = mount(); 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(); + wrapper = mount(); 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(
{wrapper.instance().renderTriggerNode()}
, ); expect(triggerNode.find(TooltipWrapper)).toHaveLength(1); @@ -107,14 +107,14 @@ describe('AlteredSliceTag', () => { describe('renderModalBody', () => { it('renders a Table', () => { - const modalBody = shallow( + const modalBody = mount(
{wrapper.instance().renderModalBody()}
, ); expect(modalBody.find(ListView)).toHaveLength(1); }); it('renders a thead', () => { - const modalBody = shallow( + const modalBody = mount(
{wrapper.instance().renderModalBody()}
, ); expect( @@ -123,7 +123,7 @@ describe('AlteredSliceTag', () => { }); it('renders th', () => { - const modalBody = shallow( + const modalBody = mount(
{wrapper.instance().renderModalBody()}
, ); 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(
{wrapper.instance().renderModalBody()}
, ); 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(
{wrapper.instance().renderModalBody()}
, ); 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(
{wrapper.instance().renderModalBody()}
, ); const rows = getTableWrapperFromModalBody(modalBody).find('tr'); expect(rows).toHaveLength(8); - const fakeRow = shallow(
{rows.get(1)}
); + const fakeRow = mount(
{rows.get(1)}
); expect(fakeRow.find('tr')).toHaveLength(1); expect(fakeRow.find('td')).toHaveLength(3); }); diff --git a/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx b/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx index f8bed6b13..1486eee4d 100644 --- a/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx +++ b/superset-frontend/spec/javascripts/components/ListView/ListView_spec.jsx @@ -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(, { + mount(, { wrappingComponent: ThemeProvider, wrappingComponentProps: { theme: supersetTheme }, }); diff --git a/superset-frontend/src/components/ListView/Filters.tsx b/superset-frontend/src/components/ListView/Filters.tsx index 3d610f2e1..2725c2130 100644 --- a/superset-frontend/src/components/ListView/Filters.tsx +++ b/superset-frontend/src/components/ListView/Filters.tsx @@ -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( diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx index 635a08c2b..5c08395ce 100644 --- a/superset-frontend/src/components/ListView/ListView.tsx +++ b/superset-frontend/src/components/ListView/ListView.tsx @@ -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 { columns: any[]; data: T[]; @@ -263,7 +263,8 @@ function ListView({ 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({ initialPageSize, initialSort, initialFilters: filters, + renderCard: Boolean(renderCard), + defaultViewMode, }); const filterable = Boolean(filters.length); if (filterable) { @@ -291,9 +294,6 @@ function ListView({ } const cardViewEnabled = Boolean(renderCard); - const [viewingMode, setViewingMode] = useState( - cardViewEnabled ? defaultViewMode : 'table', - ); useEffect(() => { // discard selections if bulk select is disabled @@ -306,7 +306,7 @@ function ListView({
{cardViewEnabled && ( - + )} {filterable && ( ({ )}
- {viewingMode === 'card' && cardSortSelectOptions && ( + {viewMode === 'card' && cardSortSelectOptions && ( ({ )} )} - {viewingMode === 'card' && ( + {viewMode === 'card' && ( ({ loading={loading} /> )} - {viewingMode === 'table' && ( + {viewMode === 'table' && ( ({ /> )} {!loading && rows.length === 0 && ( - + } description={emptyState.message || 'No Data'} diff --git a/superset-frontend/src/components/ListView/types.ts b/superset-frontend/src/components/ListView/types.ts index 82dcc519f..7038bd9f7 100644 --- a/superset-frontend/src/components/ListView/types.ts +++ b/superset-frontend/src/components/ListView/types.ts @@ -71,6 +71,8 @@ export interface Filter { export type Filters = Filter[]; +export type ViewModeType = 'card' | 'table'; + export interface FilterValue { id: string; operator?: string; diff --git a/superset-frontend/src/components/ListView/utils.ts b/superset-frontend/src/components/ListView/utils.ts index 727343764..a3f6426ed 100644 --- a/superset-frontend/src/components/ListView/utils.ts +++ b/superset-frontend/src/components/ListView/utils.ts @@ -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( + (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( - 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, }; } diff --git a/superset-frontend/src/views/App.tsx b/superset-frontend/src/views/App.tsx index b03496eff..60f9313e6 100644 --- a/superset-frontend/src/views/App.tsx +++ b/superset-frontend/src/views/App.tsx @@ -68,7 +68,10 @@ const App = () => ( - +