mirror of
https://github.com/apache/superset.git
synced 2024-09-06 22:07:34 -04:00
feat: sort card view by Alphabetical, Recently Modified, and Least Recently Modified (#10601)
This commit is contained in:
parent
acb00f509c
commit
03a62f15d8
@ -22,14 +22,17 @@ import { act } from 'react-dom/test-utils';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
||||
|
||||
import Button from 'src/components/Button';
|
||||
import CardCollection from 'src/components/ListView/CardCollection';
|
||||
import { CardSortSelect } from 'src/components/ListView/CardSortSelect';
|
||||
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
|
||||
import ListView from 'src/components/ListView/ListView';
|
||||
import ListViewFilters from 'src/components/ListView/Filters';
|
||||
import ListViewPagination from 'src/components/ListView/Pagination';
|
||||
import Pagination from 'src/components/Pagination';
|
||||
import Button from 'src/components/Button';
|
||||
import TableCollection from 'src/components/ListView/TableCollection';
|
||||
|
||||
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
|
||||
import IndeterminateCheckbox from 'src/components/IndeterminateCheckbox';
|
||||
|
||||
function makeMockLocation(query) {
|
||||
const queryStr = encodeURIComponent(query);
|
||||
@ -100,6 +103,14 @@ const mockedProps = {
|
||||
onSelect: jest.fn(),
|
||||
},
|
||||
],
|
||||
cardSortSelectOptions: [
|
||||
{
|
||||
desc: false,
|
||||
id: 'something',
|
||||
label: 'Alphabetical',
|
||||
value: 'alphabetical',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const factory = (props = mockedProps) =>
|
||||
@ -281,6 +292,24 @@ describe('ListView', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('disable card view based on prop', async () => {
|
||||
expect(wrapper.find(CardCollection).exists()).toBe(false);
|
||||
expect(wrapper.find(CardSortSelect).exists()).toBe(false);
|
||||
expect(wrapper.find(TableCollection).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('enable card view based on prop', async () => {
|
||||
const wrapper2 = factory({
|
||||
...mockedProps,
|
||||
renderCard: jest.fn(),
|
||||
initialSort: [{ id: 'something' }],
|
||||
});
|
||||
await waitForComponentToPaint(wrapper2);
|
||||
expect(wrapper2.find(CardCollection).exists()).toBe(true);
|
||||
expect(wrapper2.find(CardSortSelect).exists()).toBe(true);
|
||||
expect(wrapper2.find(TableCollection).exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('Throws an exception if filter missing in columns', () => {
|
||||
expect.assertions(1);
|
||||
const props = {
|
||||
@ -377,4 +406,24 @@ describe('ListView', () => {
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
it('calls fetchData on card view sort', async () => {
|
||||
const wrapper2 = factory({
|
||||
...mockedProps,
|
||||
renderCard: jest.fn(),
|
||||
initialSort: [{ id: 'something' }],
|
||||
});
|
||||
|
||||
act(() => {
|
||||
wrapper2.find('[data-test="card-sort-select"]').first().props().onChange({
|
||||
desc: false,
|
||||
id: 'something',
|
||||
label: 'Alphabetical',
|
||||
value: 'alphabetical',
|
||||
});
|
||||
});
|
||||
|
||||
wrapper2.update();
|
||||
expect(mockedProps.fetchData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
@ -24,6 +24,7 @@ import fetchMock from 'fetch-mock';
|
||||
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
||||
|
||||
import ChartList from 'src/views/CRUD/chart/ChartList';
|
||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||
import ListView from 'src/components/ListView';
|
||||
import PropertiesModal from 'src/explore/components/PropertiesModal';
|
||||
import ListViewCard from 'src/components/ListViewCard';
|
||||
@ -49,7 +50,7 @@ const mockCharts = [...new Array(3)].map((_, i) => ({
|
||||
}));
|
||||
|
||||
fetchMock.get(chartsInfoEndpoint, {
|
||||
permissions: ['can_list', 'can_edit'],
|
||||
permissions: ['can_list', 'can_edit', 'can_delete'],
|
||||
});
|
||||
fetchMock.get(chartssOwnersEndpoint, {
|
||||
result: [],
|
||||
@ -113,4 +114,9 @@ describe('ChartList', () => {
|
||||
wrapper.find('[data-test="pencil"]').first().simulate('click');
|
||||
expect(wrapper.find(PropertiesModal)).toExist();
|
||||
});
|
||||
|
||||
it('delete', () => {
|
||||
wrapper.find('[data-test="trash"]').first().simulate('click');
|
||||
expect(wrapper.find(ConfirmStatusChange)).toExist();
|
||||
});
|
||||
});
|
||||
|
@ -23,10 +23,11 @@ import configureStore from 'redux-mock-store';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { supersetTheme, ThemeProvider } from '@superset-ui/style';
|
||||
|
||||
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
|
||||
import DashboardList from 'src/views/CRUD/dashboard/DashboardList';
|
||||
import ListView from 'src/components/ListView';
|
||||
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
|
||||
import ListViewCard from 'src/components/ListViewCard';
|
||||
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
|
||||
|
||||
// store needed for withToasts(DashboardTable)
|
||||
const mockStore = configureStore([thunk]);
|
||||
@ -50,7 +51,7 @@ const mockDashboards = [...new Array(3)].map((_, i) => ({
|
||||
}));
|
||||
|
||||
fetchMock.get(dashboardsInfoEndpoint, {
|
||||
permissions: ['can_list', 'can_edit'],
|
||||
permissions: ['can_list', 'can_edit', 'can_delete'],
|
||||
});
|
||||
fetchMock.get(dashboardOwnersEndpoint, {
|
||||
result: [],
|
||||
@ -104,4 +105,19 @@ describe('DashboardList', () => {
|
||||
wrapper.find('[data-test="pencil"]').first().simulate('click');
|
||||
expect(wrapper.find(PropertiesModal)).toExist();
|
||||
});
|
||||
|
||||
it('card view edits', () => {
|
||||
wrapper.find('[data-test="pencil"]').last().simulate('click');
|
||||
expect(wrapper.find(PropertiesModal)).toExist();
|
||||
});
|
||||
|
||||
it('delete', () => {
|
||||
wrapper.find('[data-test="trash"]').first().simulate('click');
|
||||
expect(wrapper.find(ConfirmStatusChange)).toExist();
|
||||
});
|
||||
|
||||
it('card view delete', () => {
|
||||
wrapper.find('[data-test="trash"]').last().simulate('click');
|
||||
expect(wrapper.find(ConfirmStatusChange)).toExist();
|
||||
});
|
||||
});
|
||||
|
114
superset-frontend/src/components/ListView/CardSortSelect.tsx
Normal file
114
superset-frontend/src/components/ListView/CardSortSelect.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 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, { useState } from 'react';
|
||||
import { styled, withTheme, SupersetThemeProps } from '@superset-ui/style';
|
||||
import { PartialThemeConfig, Select } from 'src/components/Select';
|
||||
import { CardSortSelectOption, FetchDataConfig, SortColumn } from './types';
|
||||
import { filterSelectStyles } from './utils';
|
||||
|
||||
const SortTitle = styled.label`
|
||||
font-weight: bold;
|
||||
line-height: 27px;
|
||||
margin: 0 0.4em 0 0;
|
||||
`;
|
||||
|
||||
const SortContainer = styled.div`
|
||||
display: inline-flex;
|
||||
float: right;
|
||||
font-size: ${({ theme }) => theme.typography.sizes.s}px;
|
||||
padding: 24px 24px 0 0;
|
||||
position: relative;
|
||||
top: 8px;
|
||||
`;
|
||||
interface CardViewSelectSortProps {
|
||||
onChange: (conf: FetchDataConfig) => any;
|
||||
options: Array<CardSortSelectOption>;
|
||||
initialSort?: SortColumn[];
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
interface StyledSelectProps {
|
||||
onChange: (value: CardSortSelectOption) => void;
|
||||
options: CardSortSelectOption[];
|
||||
selectStyles: any;
|
||||
theme: SupersetThemeProps['theme'];
|
||||
value: CardSortSelectOption;
|
||||
}
|
||||
|
||||
function StyledSelect({
|
||||
onChange,
|
||||
options,
|
||||
selectStyles,
|
||||
theme,
|
||||
value,
|
||||
}: StyledSelectProps) {
|
||||
const filterSelectTheme: PartialThemeConfig = {
|
||||
spacing: {
|
||||
baseUnit: 1,
|
||||
fontSize: theme.typography.sizes.s,
|
||||
minWidth: '5em',
|
||||
},
|
||||
};
|
||||
return (
|
||||
<Select
|
||||
data-test="card-sort-select"
|
||||
clearable={false}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
stylesConfig={selectStyles}
|
||||
themeConfig={filterSelectTheme}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledCardSortSelect = withTheme(StyledSelect);
|
||||
|
||||
export const CardSortSelect = ({
|
||||
initialSort,
|
||||
onChange,
|
||||
options,
|
||||
pageIndex,
|
||||
pageSize,
|
||||
}: CardViewSelectSortProps) => {
|
||||
const defaultSort =
|
||||
initialSort && options.find(({ id }) => id === initialSort[0].id);
|
||||
const [selectedOption, setSelectedOption] = useState<CardSortSelectOption>(
|
||||
defaultSort || options[0],
|
||||
);
|
||||
|
||||
const handleOnChange = (selected: CardSortSelectOption) => {
|
||||
setSelectedOption(selected);
|
||||
const sortBy = [{ id: selected.id, desc: selected.desc }];
|
||||
onChange({ pageIndex, pageSize, sortBy, filters: [] });
|
||||
};
|
||||
|
||||
return (
|
||||
<SortContainer>
|
||||
<SortTitle>Sort:</SortTitle>
|
||||
<StyledCardSortSelect
|
||||
onChange={(value: CardSortSelectOption) => handleOnChange(value)}
|
||||
options={options}
|
||||
selectStyles={filterSelectStyles}
|
||||
value={selectedOption}
|
||||
/>
|
||||
</SortContainer>
|
||||
);
|
||||
};
|
@ -23,29 +23,29 @@ import {
|
||||
Select,
|
||||
PaginatedSelect,
|
||||
PartialThemeConfig,
|
||||
PartialStylesConfig,
|
||||
} from 'src/components/Select';
|
||||
|
||||
import SearchInput from 'src/components/SearchInput';
|
||||
import {
|
||||
Filter,
|
||||
Filters,
|
||||
FilterValue,
|
||||
Filters,
|
||||
InternalFilter,
|
||||
SelectOption,
|
||||
} from './types';
|
||||
import { filterSelectStyles } from './utils';
|
||||
|
||||
interface BaseFilter {
|
||||
Header: string;
|
||||
initialValue: any;
|
||||
}
|
||||
interface SelectFilterProps extends BaseFilter {
|
||||
name?: string;
|
||||
onSelect: (selected: any) => any;
|
||||
selects: Filter['selects'];
|
||||
emptyLabel?: string;
|
||||
fetchSelects?: Filter['fetchSelects'];
|
||||
name?: string;
|
||||
onSelect: (selected: any) => any;
|
||||
paginate?: boolean;
|
||||
selects: Filter['selects'];
|
||||
theme: SupersetThemeProps['theme'];
|
||||
}
|
||||
|
||||
@ -61,40 +61,23 @@ const FilterTitle = styled.label`
|
||||
margin: 0 0.4em 0 0;
|
||||
`;
|
||||
|
||||
const filterSelectStyles: PartialStylesConfig = {
|
||||
container: (provider, { getValue }) => ({
|
||||
...provider,
|
||||
// dynamic width based on label string length
|
||||
minWidth: `${Math.min(
|
||||
12,
|
||||
Math.max(5, 3 + getValue()[0].label.length / 2),
|
||||
)}em`,
|
||||
}),
|
||||
control: provider => ({
|
||||
...provider,
|
||||
borderWidth: 0,
|
||||
boxShadow: 'none',
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
};
|
||||
|
||||
const CLEAR_SELECT_FILTER_VALUE = 'CLEAR_SELECT_FILTER_VALUE';
|
||||
|
||||
function SelectFilter({
|
||||
Header,
|
||||
selects = [],
|
||||
emptyLabel = 'None',
|
||||
fetchSelects,
|
||||
initialValue,
|
||||
onSelect,
|
||||
fetchSelects,
|
||||
paginate = false,
|
||||
selects = [],
|
||||
theme,
|
||||
}: SelectFilterProps) {
|
||||
const filterSelectTheme: PartialThemeConfig = {
|
||||
spacing: {
|
||||
baseUnit: 2,
|
||||
minWidth: '5em',
|
||||
fontSize: theme.typography.sizes.s,
|
||||
minWidth: '5em',
|
||||
},
|
||||
};
|
||||
|
||||
@ -235,12 +218,12 @@ function UIFilters({
|
||||
(
|
||||
{
|
||||
Header,
|
||||
fetchSelects,
|
||||
id,
|
||||
input,
|
||||
paginate,
|
||||
selects,
|
||||
unfilteredLabel,
|
||||
fetchSelects,
|
||||
paginate,
|
||||
},
|
||||
index,
|
||||
) => {
|
||||
@ -249,24 +232,24 @@ function UIFilters({
|
||||
if (input === 'select') {
|
||||
return (
|
||||
<StyledSelectFilter
|
||||
Header={Header}
|
||||
emptyLabel={unfilteredLabel}
|
||||
fetchSelects={fetchSelects}
|
||||
initialValue={initialValue}
|
||||
key={id}
|
||||
name={id}
|
||||
Header={Header}
|
||||
selects={selects}
|
||||
emptyLabel={unfilteredLabel}
|
||||
initialValue={initialValue}
|
||||
fetchSelects={fetchSelects}
|
||||
paginate={paginate}
|
||||
onSelect={(value: any) => updateFilterValue(index, value)}
|
||||
paginate={paginate}
|
||||
selects={selects}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (input === 'search') {
|
||||
return (
|
||||
<SearchFilter
|
||||
key={id}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
key={id}
|
||||
onSubmit={(value: string) => updateFilterValue(index, value)}
|
||||
/>
|
||||
);
|
||||
|
@ -28,7 +28,13 @@ import TableCollection from './TableCollection';
|
||||
import CardCollection from './CardCollection';
|
||||
import Pagination from './Pagination';
|
||||
import FilterControls from './Filters';
|
||||
import { FetchDataConfig, Filters, SortColumn } from './types';
|
||||
import { CardSortSelect } from './CardSortSelect';
|
||||
import {
|
||||
FetchDataConfig,
|
||||
Filters,
|
||||
SortColumn,
|
||||
CardSortSelectOption,
|
||||
} from './types';
|
||||
import { ListViewError, useListViewState } from './utils';
|
||||
|
||||
const ListViewStyles = styled.div`
|
||||
@ -188,6 +194,7 @@ export interface ListViewProps<T = any> {
|
||||
disableBulkSelect?: () => void;
|
||||
renderBulkSelectCopy?: (selects: any[]) => React.ReactNode;
|
||||
renderCard?: (row: T) => React.ReactNode;
|
||||
cardSortSelectOptions?: Array<CardSortSelectOption>;
|
||||
}
|
||||
|
||||
const ListView: FunctionComponent<ListViewProps> = ({
|
||||
@ -205,6 +212,7 @@ const ListView: FunctionComponent<ListViewProps> = ({
|
||||
disableBulkSelect = () => {},
|
||||
renderBulkSelectCopy = selected => t('%s Selected', selected.length),
|
||||
renderCard,
|
||||
cardSortSelectOptions,
|
||||
}) => {
|
||||
const {
|
||||
getTableProps,
|
||||
@ -263,6 +271,15 @@ const ListView: FunctionComponent<ListViewProps> = ({
|
||||
updateFilterValue={applyFilterValue}
|
||||
/>
|
||||
)}
|
||||
{viewingMode === 'card' && cardSortSelectOptions && (
|
||||
<CardSortSelect
|
||||
initialSort={initialSort}
|
||||
onChange={fetchData}
|
||||
options={cardSortSelectOptions}
|
||||
pageIndex={pageIndex}
|
||||
pageSize={pageSize}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="body">
|
||||
{bulkSelectEnabled && (
|
||||
|
@ -28,6 +28,13 @@ export interface SelectOption {
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface CardSortSelectOption {
|
||||
desc: boolean;
|
||||
id: any;
|
||||
label: string;
|
||||
value: any;
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
Header: string;
|
||||
id: string;
|
||||
|
@ -34,7 +34,7 @@ import {
|
||||
} from 'use-query-params';
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import { PartialStylesConfig } from 'src/components/Select';
|
||||
import {
|
||||
FetchDataConfig,
|
||||
Filter,
|
||||
@ -255,3 +255,20 @@ export function useListViewState({
|
||||
applyFilterValue,
|
||||
};
|
||||
}
|
||||
|
||||
export const filterSelectStyles: PartialStylesConfig = {
|
||||
container: (provider, { getValue }) => ({
|
||||
...provider,
|
||||
// dynamic width based on label string length
|
||||
minWidth: `${Math.min(
|
||||
12,
|
||||
Math.max(5, 3 + getValue()[0].label.length / 2),
|
||||
)}em`,
|
||||
}),
|
||||
control: provider => ({
|
||||
...provider,
|
||||
borderWidth: 0,
|
||||
boxShadow: 'none',
|
||||
cursor: 'pointer',
|
||||
}),
|
||||
};
|
||||
|
@ -342,6 +342,27 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||
},
|
||||
];
|
||||
|
||||
sortTypes = [
|
||||
{
|
||||
desc: false,
|
||||
id: 'slice_name',
|
||||
label: 'Alphabetical',
|
||||
value: 'alphabetical',
|
||||
},
|
||||
{
|
||||
desc: true,
|
||||
id: 'changed_on_delta_humanized',
|
||||
label: 'Recently Modified',
|
||||
value: 'recently_modified',
|
||||
},
|
||||
{
|
||||
desc: false,
|
||||
id: 'changed_on_delta_humanized',
|
||||
label: 'Least Recently Modified',
|
||||
value: 'least_recently_modified',
|
||||
},
|
||||
];
|
||||
|
||||
hasPerm = (perm: string) => {
|
||||
if (!this.state.permissions.length) {
|
||||
return false;
|
||||
@ -592,6 +613,7 @@ class ChartList extends React.PureComponent<Props, State> {
|
||||
<ListView
|
||||
bulkActions={bulkActions}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
cardSortSelectOptions={this.sortTypes}
|
||||
className="chart-list-view"
|
||||
columns={this.columns}
|
||||
count={chartCount}
|
||||
|
@ -266,6 +266,27 @@ class DashboardList extends React.PureComponent<Props, State> {
|
||||
},
|
||||
];
|
||||
|
||||
sortTypes = [
|
||||
{
|
||||
desc: false,
|
||||
id: 'dashboard_title',
|
||||
label: 'Alphabetical',
|
||||
value: 'alphabetical',
|
||||
},
|
||||
{
|
||||
desc: true,
|
||||
id: 'changed_on_delta_humanized',
|
||||
label: 'Recently Modified',
|
||||
value: 'recently_modified',
|
||||
},
|
||||
{
|
||||
desc: false,
|
||||
id: 'changed_on_delta_humanized',
|
||||
label: 'Least Recently Modified',
|
||||
value: 'least_recently_modified',
|
||||
},
|
||||
];
|
||||
|
||||
hasPerm = (perm: string) => {
|
||||
if (!this.state.permissions.length) {
|
||||
return false;
|
||||
@ -601,6 +622,7 @@ class DashboardList extends React.PureComponent<Props, State> {
|
||||
<ListView
|
||||
bulkActions={bulkActions}
|
||||
bulkSelectEnabled={bulkSelectEnabled}
|
||||
cardSortSelectOptions={this.sortTypes}
|
||||
className="dashboard-list-view"
|
||||
columns={this.columns}
|
||||
count={dashboardCount}
|
||||
|
Loading…
Reference in New Issue
Block a user