chore: Improves the native filters UI/UX - iteration 7 (#15017)

This commit is contained in:
Michael S. Molina 2021-06-10 09:24:00 -03:00 committed by GitHub
parent 8aaa6036d7
commit 1468026883
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 360 additions and 241 deletions

View File

@ -34,10 +34,6 @@ import { TimeFilterPlugin, SelectFilterPlugin } from 'src/filters/components';
import { DATE_FILTER_CONTROL_TEST_ID } from 'src/explore/components/controls/DateFilterControl/DateFilterLabel';
import fetchMock from 'fetch-mock';
import { waitFor } from '@testing-library/react';
import mockDatasource, {
id as datasourceId,
datasourceId as fullDatasourceId,
} from 'spec/fixtures/mockDatasource';
import FilterBar, { FILTER_BAR_TEST_ID } from '.';
import { FILTERS_CONFIG_MODAL_TEST_ID } from '../FiltersConfigModal/FiltersConfigModal';
@ -57,8 +53,25 @@ class MainPreset extends Preset {
}
}
fetchMock.get(`glob:*/api/v1/dataset/${datasourceId}`, {
result: [mockDatasource[fullDatasourceId]],
fetchMock.get(`glob:*/api/v1/dataset/1`, {
description_columns: {},
id: 1,
label_columns: {
columns: 'Columns',
table_name: 'Table Name',
},
result: {
metrics: [],
columns: [
{
column_name: 'Column A',
id: 1,
},
],
table_name: 'birth_names',
id: 1,
},
show_columns: ['id', 'table_name'],
});
const getTestId = testWithId<string>(FILTER_BAR_TEST_ID, true);

View File

@ -16,27 +16,22 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback } from 'react';
import React, { useCallback, useState } from 'react';
import { FormInstance } from 'antd/lib/form';
import { SupersetClient, t } from '@superset-ui/core';
import { useChangeEffect } from 'src/common/hooks/useChangeEffect';
import { AsyncSelect } from 'src/components/Select';
import { Select } from 'src/common/components';
import { useToasts } from 'src/messageToasts/enhancers/withToasts';
import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import { cacheWrapper } from 'src/utils/cacheWrapper';
import { NativeFiltersForm } from '../types';
type ColumnSelectValue = {
value: string;
label: string;
};
interface ColumnSelectProps {
form: FormInstance<NativeFiltersForm>;
filterId: string;
datasetId?: number | null | undefined;
value?: ColumnSelectValue | null;
onChange?: (value: ColumnSelectValue | null) => void;
datasetId?: number;
value?: string;
onChange?: (value: string) => void;
}
const localCache = new Map<string, any>();
@ -56,6 +51,7 @@ export function ColumnSelect({
value,
onChange,
}: ColumnSelectProps) {
const [options, setOptions] = useState();
const { addDangerToast } = useToasts();
const resetColumnField = useCallback(() => {
form.setFields([
@ -67,45 +63,40 @@ export function ColumnSelect({
if (previous != null) {
resetColumnField();
}
if (datasetId != null) {
cachedSupersetGet({
endpoint: `/api/v1/dataset/${datasetId}`,
}).then(
({ json: { result } }) => {
const columns = result.columns
.map((col: any) => col.column_name)
.sort((a: string, b: string) => a.localeCompare(b));
if (!columns.includes(value)) {
resetColumnField();
}
setOptions(
columns.map((column: any) => ({ label: column, value: column })),
);
},
async badResponse => {
const { error, message } = await getClientErrorObject(badResponse);
let errorText = message || error || t('An error has occurred');
if (message === 'Forbidden') {
errorText = t('You do not have permission to edit this dashboard');
}
addDangerToast(errorText);
},
);
}
});
function loadOptions() {
if (datasetId == null) return [];
return cachedSupersetGet({
endpoint: `/api/v1/dataset/${datasetId}`,
}).then(
({ json: { result } }) => {
const columns = result.columns
.map((col: any) => col.column_name)
.sort((a: string, b: string) => a.localeCompare(b));
if (!columns.includes(value)) {
resetColumnField();
}
return columns;
},
async badResponse => {
const { error, message } = await getClientErrorObject(badResponse);
let errorText = message || error || t('An error has occurred');
if (message === 'Forbidden') {
errorText = t('You do not have permission to edit this dashboard');
}
addDangerToast(errorText);
return [];
},
);
}
return (
<AsyncSelect
// "key" prop makes react render a new instance of the select whenever the dataset changes
key={datasetId == null ? '*no dataset*' : datasetId}
isDisabled={datasetId == null}
<Select
value={value}
onChange={onChange}
isMulti={false}
loadOptions={loadOptions}
defaultOptions // load options on render
cacheOptions
options={options}
placeholder={t('Select a column')}
showSearch
/>
);
}

View File

@ -62,7 +62,7 @@ const DefaultValue: FC<DefaultValueProps> = ({
) : (
<SuperChart
height={25}
width={250}
width={formFilter?.filterType === 'filter_time' ? 350 : 250}
appSection={AppSection.FILTER_CONFIG_MODAL}
behaviors={[Behavior.NATIVE_FILTER]}
formData={formData}

View File

@ -164,6 +164,8 @@ const StyledCollapse = styled(Collapse)`
const StyledTabs = styled(Tabs)`
.ant-tabs-nav {
position: sticky;
margin-left: ${({ theme }) => theme.gridUnit * -4}px;
margin-right: ${({ theme }) => theme.gridUnit * -4}px;
top: 0px;
background: white;
z-index: 1;
@ -180,8 +182,8 @@ const StyledTabs = styled(Tabs)`
const StyledAsterisk = styled.span`
color: ${({ theme }) => theme.colors.error.base};
font-family: SimSun, sans-serif;
margin-right: ${({ theme }) => theme.gridUnit - 1}px;
font-size: ${({ theme }) => theme.typography.sizes.s}px;
margin-left: ${({ theme }) => theme.gridUnit - 1}px;
&:before {
content: '*';
}
@ -458,6 +460,28 @@ const FiltersConfigForm = (
forceUpdate();
};
const validatePreFilter = () =>
setTimeout(
() =>
form.validateFields([
['filters', filterId, 'adhoc_filters'],
['filters', filterId, 'time_range'],
]),
0,
);
const hasTimeRange =
formFilter?.time_range && formFilter.time_range !== 'No filter';
const hasAdhoc = formFilter?.adhoc_filters?.length > 0;
const preFilterValidator = () => {
if (hasTimeRange || hasAdhoc) {
return Promise.resolve();
}
return Promise.reject(new Error(t('Pre-filter is required')));
};
let hasCheckedAdvancedControl = hasParentFilter || hasPreFilter || hasSorting;
if (!hasCheckedAdvancedControl) {
hasCheckedAdvancedControl = Object.keys(controlItems)
@ -567,7 +591,7 @@ const FiltersConfigForm = (
initialValue={initColumn}
label={<StyledLabel>{t('Column')}</StyledLabel>}
rules={[
{ required: !removed, message: t('Field is required') },
{ required: !removed, message: t('Column is required') },
]}
data-test="field-input"
>
@ -626,7 +650,7 @@ const FiltersConfigForm = (
},
{
validator: (rule, value) => {
const hasValue = !!value.filterState?.value;
const hasValue = !!value?.filterState?.value;
if (
hasValue ||
// TODO: do more generic
@ -730,14 +754,7 @@ const FiltersConfigForm = (
checked={hasPreFilter}
onChange={checked => {
if (checked) {
// execute after render
setTimeout(
() =>
form.validateFields([
['filters', filterId, 'adhoc_filters'],
]),
0,
);
validatePreFilter();
}
}}
>
@ -747,8 +764,7 @@ const FiltersConfigForm = (
required
rules={[
{
required: true,
message: t('Pre-filter is required'),
validator: preFilterValidator,
},
]}
>
@ -765,11 +781,12 @@ const FiltersConfigForm = (
adhoc_filters: filters,
});
forceUpdate();
validatePreFilter();
}}
label={
<span>
<StyledAsterisk />
<StyledLabel>{t('Pre-filter')}</StyledLabel>
{!hasTimeRange && <StyledAsterisk />}
</span>
}
/>
@ -778,6 +795,12 @@ const FiltersConfigForm = (
name={['filters', filterId, 'time_range']}
label={<StyledLabel>{t('Time range')}</StyledLabel>}
initialValue={filterToEdit?.time_range || 'No filter'}
required={!hasAdhoc}
rules={[
{
validator: preFilterValidator,
},
]}
>
<DateFilterControl
name="time_range"
@ -786,6 +809,7 @@ const FiltersConfigForm = (
time_range: timeRange,
});
forceUpdate();
validatePreFilter();
}}
/>
</StyledRowFormItem>

View File

@ -17,198 +17,289 @@
* under the License.
*/
import { Preset } from '@superset-ui/core';
import { SelectFilterPlugin, TimeFilterPlugin } from 'src/filters/components';
import { render, cleanup, screen } from 'spec/helpers/testing-library';
import { Provider } from 'react-redux';
import {
getMockStore,
mockStore,
stateWithoutNativeFilters,
} from 'spec/fixtures/mockStore';
SelectFilterPlugin,
RangeFilterPlugin,
TimeFilterPlugin,
TimeColumnFilterPlugin,
TimeGrainFilterPlugin,
} from 'src/filters/components';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import fetchMock from 'fetch-mock';
import React from 'react';
import userEvent from '@testing-library/user-event';
import { testWithId } from 'src/utils/testUtils';
import { waitFor } from '@testing-library/react';
import userEvent, { specialChars } from '@testing-library/user-event';
import {
FILTERS_CONFIG_MODAL_TEST_ID,
FiltersConfigModal,
FiltersConfigModalProps,
} from './FiltersConfigModal';
jest.useFakeTimers();
class MainPreset extends Preset {
constructor() {
super({
name: 'Legacy charts',
plugins: [
new TimeFilterPlugin().configure({ key: 'filter_time' }),
new SelectFilterPlugin().configure({ key: 'filter_select' }),
new RangeFilterPlugin().configure({ key: 'filter_range' }),
new TimeFilterPlugin().configure({ key: 'filter_time' }),
new TimeColumnFilterPlugin().configure({ key: 'filter_timecolumn' }),
new TimeGrainFilterPlugin().configure({ key: 'filter_timegrain' }),
],
});
}
}
const getTestId = testWithId<string>(FILTERS_CONFIG_MODAL_TEST_ID, true);
const initialStoreState = {
datasources: [{ id: 1, table_name: 'Datasource 1' }],
};
describe('FilterConfigModal', () => {
new MainPreset().register();
const onSave = jest.fn();
const newFilterProps = {
isOpen: true,
initialFilterId: undefined,
createNewOnOpen: true,
onSave,
onCancel: jest.fn(),
};
fetchMock.get(
'glob:*/api/v1/dataset/?q=*',
{
count: 1,
ids: [11],
label_columns: {
id: 'Id',
table_name: 'Table Name',
fetchMock.get('glob:*/api/v1/dataset/1', {
description_columns: {},
id: 1,
label_columns: {
columns: 'Columns',
table_name: 'Table Name',
},
result: {
metrics: [],
columns: [
{
column_name: 'Column A',
id: 1,
},
list_columns: ['id', 'table_name'],
order_columns: ['table_name'],
result: [
{
id: 11,
owners: [],
table_name: 'birth_names',
},
],
},
{ overwriteRoutes: true },
);
fetchMock.get(
'glob:*/api/v1/dataset/11',
{
description_columns: {},
id: 3,
label_columns: {
columns: 'Columns',
table_name: 'Table Name',
},
result: {
columns: [
{
column_name: 'name',
groupby: true,
id: 334,
},
],
table_name: 'birth_names',
},
show_columns: ['id', 'table_name'],
},
{ overwriteRoutes: true },
);
fetchMock.post(
'glob:*/api/v1/chart/data',
{
result: [
{
status: 'success',
data: [
{ name: 'Aaron', count: 453 },
{ name: 'Abigail', count: 228 },
{ name: 'Adam', count: 454 },
],
applied_filters: [{ column: 'name' }],
},
],
},
{ overwriteRoutes: true },
);
const renderWrapper = (
props = newFilterProps,
state: object = stateWithoutNativeFilters,
) =>
render(
<Provider
store={state ? getMockStore(stateWithoutNativeFilters) : mockStore}
>
<FiltersConfigModal {...props} />
</Provider>,
);
afterEach(() => {
cleanup();
jest.clearAllMocks();
});
// TODO: fix and unskip
it.skip('Create Select Filter (with datasource and columns) with specific filter scope', async () => {
renderWrapper();
const FILTER_NAME = 'Select Filter 1';
// fill name
userEvent.type(screen.getByTestId(getTestId('name-input')), FILTER_NAME);
// fill dataset
await waitFor(() =>
expect(screen.queryByText('Loading...')).not.toBeInTheDocument(),
);
userEvent.click(
screen
.getByTestId(getTestId('datasource-input'))
.querySelector('.Select__indicators')!,
);
userEvent.click(screen.getByText('birth_names'));
// fill column
userEvent.click(screen.getByText('Select...'));
await waitFor(() =>
expect(screen.queryByText('Loading...')).not.toBeInTheDocument(),
);
userEvent.click(screen.getByText('name'));
// fill controls
expect(screen.getByText('Multiple select').parentElement!).toHaveAttribute(
'class',
'ant-checkbox-wrapper ant-checkbox-wrapper-checked',
);
userEvent.click(screen.getByText('Multiple select'));
expect(
screen.getByText('Multiple select').parentElement!,
).not.toHaveAttribute(
'class',
'ant-checkbox-wrapper ant-checkbox-wrapper-checked',
);
// choose default value
userEvent.click(await screen.findByText('3 options'));
userEvent.click(screen.getByTitle('Abigail'));
// fill scoping
userEvent.click(screen.getByText('Apply to specific panels'));
userEvent.click(screen.getByText('CHART_ID'));
// saving
userEvent.click(screen.getByText('Save'));
await waitFor(() =>
expect(onSave.mock.calls[0][0][0]).toEqual(
expect.objectContaining({
cascadeParentIds: [],
controlValues: {
defaultToFirstItem: false,
enableEmptyFilter: false,
inverseSelection: false,
multiSelect: false,
sortAscending: true,
},
defaultValue: ['Abigail'],
filterType: 'filter_select',
isInstant: false,
name: 'Select Filter 1',
scope: { excluded: [], rootPath: [] },
targets: [{ column: { name: 'name' }, datasetId: 11 }],
}),
),
);
});
],
table_name: 'birth_names',
id: 1,
},
show_columns: ['id', 'table_name'],
});
fetchMock.post('glob:*/api/v1/chart/data', {
result: [
{
status: 'success',
data: [
{ name: 'Aaron', count: 453 },
{ name: 'Abigail', count: 228 },
{ name: 'Adam', count: 454 },
],
applied_filters: [{ column: 'name' }],
},
],
});
const FILTER_TYPE_REGEX = /^filter type$/i;
const FILTER_NAME_REGEX = /^filter name$/i;
const DATASET_REGEX = /^dataset$/i;
const COLUMN_REGEX = /^column$/i;
const VALUE_REGEX = /^value$/i;
const NUMERICAL_RANGE_REGEX = /^numerical range$/i;
const TIME_RANGE_REGEX = /^time range$/i;
const TIME_COLUMN_REGEX = /^time column$/i;
const TIME_GRAIN_REGEX = /^time grain$/i;
const ADVANCED_REGEX = /^advanced$/i;
const DEFAULT_VALUE_REGEX = /^filter has default value$/i;
const MULTIPLE_REGEX = /^multiple select$/i;
const REQUIRED_REGEX = /^required$/i;
const APPLY_INSTANTLY_REGEX = /^apply changes instantly$/i;
const HIERARCHICAL_REGEX = /^filter is hierarchical$/i;
const FIRST_ITEM_REGEX = /^default to first item$/i;
const INVERSE_SELECTION_REGEX = /^inverse selection$/i;
const SEARCH_ALL_REGEX = /^search all filter options$/i;
const PRE_FILTER_REGEX = /^pre-filter available values$/i;
const SORT_REGEX = /^sort filter values$/i;
const SAVE_REGEX = /^save$/i;
const NAME_REQUIRED_REGEX = /^name is required$/i;
const COLUMN_REQUIRED_REGEX = /^column is required$/i;
const DEFAULT_VALUE_REQUIRED_REGEX = /^default value is required$/i;
const PARENT_REQUIRED_REGEX = /^parent filter is required$/i;
const PRE_FILTER_REQUIRED_REGEX = /^pre-filter is required$/i;
const FILL_REQUIRED_FIELDS_REGEX = /fill all required fields to enable/;
const props: FiltersConfigModalProps = {
isOpen: true,
createNewOnOpen: true,
onSave: jest.fn(),
onCancel: jest.fn(),
};
beforeAll(() => {
new MainPreset().register();
});
function defaultRender(
overrides?: Partial<FiltersConfigModalProps>,
initialState?: {},
) {
return render(<FiltersConfigModal {...props} {...overrides} />, {
useRedux: true,
initialState,
});
}
function getCheckbox(name: RegExp) {
return screen.getByRole('checkbox', { name });
}
function queryCheckbox(name: RegExp) {
return screen.queryByRole('checkbox', { name });
}
test('renders a value filter type', () => {
defaultRender();
userEvent.click(screen.getByText(ADVANCED_REGEX));
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
expect(screen.getByText(DATASET_REGEX)).toBeInTheDocument();
expect(screen.getByText(COLUMN_REGEX)).toBeInTheDocument();
expect(getCheckbox(DEFAULT_VALUE_REGEX)).not.toBeChecked();
expect(getCheckbox(REQUIRED_REGEX)).not.toBeChecked();
expect(getCheckbox(APPLY_INSTANTLY_REGEX)).not.toBeChecked();
expect(getCheckbox(HIERARCHICAL_REGEX)).not.toBeChecked();
expect(getCheckbox(FIRST_ITEM_REGEX)).not.toBeChecked();
expect(getCheckbox(INVERSE_SELECTION_REGEX)).not.toBeChecked();
expect(getCheckbox(SEARCH_ALL_REGEX)).not.toBeChecked();
expect(getCheckbox(PRE_FILTER_REGEX)).not.toBeChecked();
expect(getCheckbox(SORT_REGEX)).not.toBeChecked();
expect(getCheckbox(MULTIPLE_REGEX)).toBeChecked();
});
test('renders a numerical range filter type', () => {
defaultRender();
userEvent.click(screen.getByText(VALUE_REGEX));
userEvent.click(screen.getByText(NUMERICAL_RANGE_REGEX));
userEvent.click(screen.getByText(ADVANCED_REGEX));
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
expect(screen.getByText(DATASET_REGEX)).toBeInTheDocument();
expect(screen.getByText(COLUMN_REGEX)).toBeInTheDocument();
expect(getCheckbox(DEFAULT_VALUE_REGEX)).not.toBeChecked();
expect(getCheckbox(APPLY_INSTANTLY_REGEX)).not.toBeChecked();
expect(getCheckbox(PRE_FILTER_REGEX)).not.toBeChecked();
expect(queryCheckbox(MULTIPLE_REGEX)).not.toBeInTheDocument();
expect(queryCheckbox(REQUIRED_REGEX)).not.toBeInTheDocument();
expect(queryCheckbox(HIERARCHICAL_REGEX)).not.toBeInTheDocument();
expect(queryCheckbox(FIRST_ITEM_REGEX)).not.toBeInTheDocument();
expect(queryCheckbox(INVERSE_SELECTION_REGEX)).not.toBeInTheDocument();
expect(queryCheckbox(SEARCH_ALL_REGEX)).not.toBeInTheDocument();
expect(queryCheckbox(SORT_REGEX)).not.toBeInTheDocument();
});
test('renders a time range filter type', () => {
defaultRender();
userEvent.click(screen.getByText(VALUE_REGEX));
userEvent.click(screen.getByText(TIME_RANGE_REGEX));
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
expect(screen.queryByText(DATASET_REGEX)).not.toBeInTheDocument();
expect(screen.queryByText(COLUMN_REGEX)).not.toBeInTheDocument();
expect(getCheckbox(DEFAULT_VALUE_REGEX)).not.toBeChecked();
expect(getCheckbox(APPLY_INSTANTLY_REGEX)).not.toBeChecked();
expect(screen.queryByText(ADVANCED_REGEX)).not.toBeInTheDocument();
});
test('renders a time column filter type', () => {
defaultRender();
userEvent.click(screen.getByText(VALUE_REGEX));
userEvent.click(screen.getByText(TIME_COLUMN_REGEX));
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
expect(screen.getByText(DATASET_REGEX)).toBeInTheDocument();
expect(screen.queryByText(COLUMN_REGEX)).not.toBeInTheDocument();
expect(getCheckbox(DEFAULT_VALUE_REGEX)).not.toBeChecked();
expect(getCheckbox(APPLY_INSTANTLY_REGEX)).not.toBeChecked();
expect(screen.queryByText(ADVANCED_REGEX)).not.toBeInTheDocument();
});
test('renders a time grain filter type', () => {
defaultRender();
userEvent.click(screen.getByText(VALUE_REGEX));
userEvent.click(screen.getByText(TIME_GRAIN_REGEX));
expect(screen.getByText(FILTER_TYPE_REGEX)).toBeInTheDocument();
expect(screen.getByText(FILTER_NAME_REGEX)).toBeInTheDocument();
expect(screen.getByText(DATASET_REGEX)).toBeInTheDocument();
expect(screen.queryByText(COLUMN_REGEX)).not.toBeInTheDocument();
expect(getCheckbox(DEFAULT_VALUE_REGEX)).not.toBeChecked();
expect(getCheckbox(APPLY_INSTANTLY_REGEX)).not.toBeChecked();
expect(screen.queryByText(ADVANCED_REGEX)).not.toBeInTheDocument();
});
test('validates the name', async () => {
defaultRender();
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
expect(await screen.findByText(NAME_REQUIRED_REGEX)).toBeInTheDocument();
});
test('validates the column', async () => {
defaultRender();
userEvent.click(screen.getByRole('button', { name: SAVE_REGEX }));
expect(await screen.findByText(COLUMN_REQUIRED_REGEX)).toBeInTheDocument();
});
// eslint-disable-next-line jest/no-disabled-tests
test.skip('validates the default value', async () => {
defaultRender(undefined, initialStoreState);
expect(await screen.findByText('birth_names')).toBeInTheDocument();
userEvent.type(screen.getByRole('combobox'), `Column A${specialChars.enter}`);
userEvent.click(getCheckbox(DEFAULT_VALUE_REGEX));
await waitFor(() => {
expect(
screen.queryByText(FILL_REQUIRED_FIELDS_REGEX),
).not.toBeInTheDocument();
});
expect(
await screen.findByText(DEFAULT_VALUE_REQUIRED_REGEX),
).toBeInTheDocument();
});
test('validates the hierarchical value', async () => {
defaultRender();
userEvent.click(screen.getByText(ADVANCED_REGEX));
userEvent.click(getCheckbox(HIERARCHICAL_REGEX));
expect(await screen.findByText(PARENT_REQUIRED_REGEX)).toBeInTheDocument();
});
test('validates the pre-filter value', async () => {
defaultRender();
userEvent.click(screen.getByText(ADVANCED_REGEX));
userEvent.click(getCheckbox(PRE_FILTER_REGEX));
expect(
await screen.findByText(PRE_FILTER_REQUIRED_REGEX),
).toBeInTheDocument();
});
/*
TODO
adds a new value filter type with all fields filled
adds a new numerical range filter type with all fields filled
adds a new time range filter type with all fields filled
adds a new time column filter type with all fields filled
adds a new time grain filter type with all fields filled
collapsible controls opens by default when it is checked
advanced section opens by default when it has an option checked
deletes a filter
disables the default value when default to first item is checked
changes the default value options when the column changes
switches to configuration tab when validation fails
displays cancel message when there are pending operations
do not displays cancel message when there are no pending operations
*/

View File

@ -24,7 +24,7 @@ import { Styles } from '../common';
import { NO_TIME_RANGE } from '../../../explore/constants';
const TimeFilterStyles = styled(Styles)`
overflow-x: scroll;
overflow-x: auto;
`;
const ControlContainer = styled.div`