chore: Select component refactoring - SelectControl - Iteration 5 (#16510)

* Refactor Select DatasourceEditor

* Fire onChange with allowNewOptions

* Clean up

* Refactor Select in AnnotationLayer

* Handle on clear

* Update tests

* Refactor Select in SpatialControl

* Show search

* Refactor Select in FilterBox

* Remove search where unnecessary

* Update SelectControl - WIP

* Refactor Controls

* Update SelectControl tests

* Clean up

* Test allowNewOptions false

* Use SelectControl AnnotationLayer

* Use SelectControl SpatialControl

* Clean up

* Render custom label

* Show search

* Implement filterOption

* Improve filterOption

* Update Cypress

* Update Cypress table test

* Use value for defaultValue

* Merge with latest changes

* Reconcile with latest Select changes

* Update superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.test.tsx

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>

* Update superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.test.tsx

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>

* Revert changes to test

* Call onPopoverClear when v value is undefined

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
This commit is contained in:
Geido 2021-10-04 18:24:41 +03:00 committed by GitHub
parent 1ab36c94f3
commit 3f0756f637
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 233 additions and 412 deletions

View File

@ -29,13 +29,13 @@ describe('Advanced analytics', () => {
cy.get('.ant-collapse-header').contains('Advanced Analytics').click(); cy.get('.ant-collapse-header').contains('Advanced Analytics').click();
cy.get('[data-test=time_compare]').find('.Select__control').click(); cy.get('[data-test=time_compare]').find('.ant-select').click();
cy.get('[data-test=time_compare]') cy.get('[data-test=time_compare]')
.find('input[type=text]') .find('input[type=search]')
.type('28 days{enter}'); .type('28 days{enter}');
cy.get('[data-test=time_compare]') cy.get('[data-test=time_compare]')
.find('input[type=text]') .find('input[type=search]')
.type('1 year{enter}'); .type('1 year{enter}');
cy.get('button[data-test="run-query-button"]').click(); cy.get('button[data-test="run-query-button"]').click();
@ -48,10 +48,10 @@ describe('Advanced analytics', () => {
cy.get('.ant-collapse-header').contains('Advanced Analytics').click(); cy.get('.ant-collapse-header').contains('Advanced Analytics').click();
cy.get('[data-test=time_compare]') cy.get('[data-test=time_compare]')
.find('.Select__multi-value__label') .find('.ant-select-selector')
.contains('28 days'); .contains('28 days');
cy.get('[data-test=time_compare]') cy.get('[data-test=time_compare]')
.find('.Select__multi-value__label') .find('.ant-select-selector')
.contains('1 year'); .contains('1 year');
}); });
}); });

View File

@ -235,8 +235,8 @@ describe('Groupby control', () => {
cy.verifySliceSuccess({ waitAlias: '@chartData' }); cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test=groupby]').within(() => { cy.get('[data-test=groupby]').within(() => {
cy.get('.Select__control').click(); cy.get('.ant-select').click();
cy.get('input[type=text]').type('state{enter}'); cy.get('input[type=search]').type('state{enter}');
}); });
cy.get('button[data-test="run-query-button"]').click(); cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'svg' }); cy.verifySliceSuccess({ waitAlias: '@chartData', chartSelector: 'svg' });

View File

@ -54,7 +54,7 @@ describe('Visualization > Table', () => {
granularity_sqla: undefined, granularity_sqla: undefined,
metrics: ['count'], metrics: ['count'],
}); });
cy.get('input[name="select-granularity_sqla"]').should('have.value', 'ds'); cy.get('[data-test=granularity_sqla] .column-option-label').contains('ds');
}); });
it('Format non-numeric metrics correctly', () => { it('Format non-numeric metrics correctly', () => {

View File

@ -20,8 +20,7 @@
import React from 'react'; import React from 'react';
import sinon from 'sinon'; import sinon from 'sinon';
import { shallow } from 'enzyme'; import { shallow } from 'enzyme';
import { Select, CreatableSelect } from 'src/components/Select'; import { Select as SelectComponent } from 'src/components';
import OnPasteSelect from 'src/components/Select/OnPasteSelect';
import SelectControl from 'src/explore/components/controls/SelectControl'; import SelectControl from 'src/explore/components/controls/SelectControl';
import { styledMount as mount } from 'spec/helpers/theming'; import { styledMount as mount } from 'spec/helpers/theming';
@ -48,59 +47,35 @@ describe('SelectControl', () => {
wrapper = shallow(<SelectControl {...defaultProps} />); wrapper = shallow(<SelectControl {...defaultProps} />);
}); });
it('uses Select in onPasteSelect when freeForm=false', () => {
wrapper = shallow(<SelectControl {...defaultProps} multi />);
const select = wrapper.find(OnPasteSelect);
expect(select.props().selectWrap).toBe(Select);
});
it('uses Creatable in onPasteSelect when freeForm=true', () => {
wrapper = shallow(<SelectControl {...defaultProps} multi freeForm />);
const select = wrapper.find(OnPasteSelect);
expect(select.props().selectWrap).toBe(CreatableSelect);
});
it('calls props.onChange when select', () => { it('calls props.onChange when select', () => {
const select = wrapper.instance(); const select = wrapper.instance();
select.onChange({ value: 50 }); select.onChange(50);
expect(defaultProps.onChange.calledWith(50)).toBe(true); expect(defaultProps.onChange.calledWith(50)).toBe(true);
}); });
it('returns all options on select all', () => {
const expectedValues = ['one', 'two'];
const selectAllProps = {
multi: true,
allowAll: true,
choices: expectedValues,
name: 'row_limit',
label: 'Row Limit',
valueKey: 'value',
onChange: sinon.spy(),
};
wrapper.setProps(selectAllProps);
wrapper.instance().onChange([{ meta: true, value: 'Select all' }]);
expect(selectAllProps.onChange.calledWith(expectedValues)).toBe(true);
});
describe('render', () => { describe('render', () => {
it('renders with Select by default', () => { it('renders with Select by default', () => {
expect(wrapper.find(OnPasteSelect)).not.toExist(); expect(wrapper.find(SelectComponent)).toExist();
expect(wrapper.findWhere(x => x.type() === Select)).toHaveLength(1);
}); });
it('renders with OnPasteSelect when multi', () => { it('renders as mode multiple', () => {
wrapper.setProps({ multi: true }); wrapper.setProps({ multi: true });
expect(wrapper.find(OnPasteSelect)).toExist(); expect(wrapper.find(SelectComponent)).toExist();
expect(wrapper.findWhere(x => x.type() === Select)).toHaveLength(0); expect(wrapper.find(SelectComponent).prop('mode')).toBe('multiple');
}); });
it('renders with Creatable when freeForm', () => { it('renders with allowNewOptions when freeForm', () => {
wrapper.setProps({ freeForm: true }); wrapper.setProps({ freeForm: true });
expect(wrapper.find(OnPasteSelect)).not.toExist(); expect(wrapper.find(SelectComponent)).toExist();
expect(wrapper.findWhere(x => x.type() === CreatableSelect)).toHaveLength( expect(wrapper.find(SelectComponent).prop('allowNewOptions')).toBe(true);
1,
);
}); });
it('renders with allowNewOptions=false when freeForm=false', () => {
wrapper.setProps({ freeForm: false });
expect(wrapper.find(SelectComponent)).toExist();
expect(wrapper.find(SelectComponent).prop('allowNewOptions')).toBe(false);
});
describe('empty placeholder', () => { describe('empty placeholder', () => {
describe('withMulti', () => { describe('withMulti', () => {
it('does not show a placeholder if there are no choices', () => { it('does not show a placeholder if there are no choices', () => {
@ -161,16 +136,6 @@ describe('SelectControl', () => {
); );
expect(wrapper.html()).not.toContain('add something'); expect(wrapper.html()).not.toContain('add something');
}); });
it('shows numbers of options as a placeholder by default', () => {
wrapper = mount(<SelectControl {...defaultProps} multi />);
expect(wrapper.html()).toContain('2 option(s');
});
it('reduces the number of options in the placeholder by the value length', () => {
wrapper = mount(
<SelectControl {...defaultProps} multi value={['today']} />,
);
expect(wrapper.html()).toContain('1 option(s');
});
}); });
describe('when select is single', () => { describe('when select is single', () => {
it('does not render the placeholder when a selection has been made', () => { it('does not render the placeholder when a selection has been made', () => {
@ -186,82 +151,12 @@ describe('SelectControl', () => {
}); });
}); });
describe('optionsRemaining', () => {
describe('isMulti', () => {
it('returns the options minus selected values', () => {
const wrapper = mount(
<SelectControl {...defaultProps} multi value={['today']} />,
);
expect(wrapper.instance().optionsRemaining()).toEqual(1);
});
});
describe('is not multi', () => {
it('returns the length of all options', () => {
wrapper = mount(
<SelectControl
{...defaultProps}
value={50}
placeholder="add something"
/>,
);
expect(wrapper.instance().optionsRemaining()).toEqual(2);
});
});
describe('with Select all', () => {
it('does not count it', () => {
const props = { ...defaultProps, multi: true, allowAll: true };
const wrapper = mount(<SelectControl {...props} />);
expect(wrapper.instance().getOptions(props).length).toEqual(3);
expect(wrapper.instance().optionsRemaining()).toEqual(2);
});
});
});
describe('getOptions', () => { describe('getOptions', () => {
it('returns the correct options', () => { it('returns the correct options', () => {
wrapper.setProps(defaultProps); wrapper.setProps(defaultProps);
expect(wrapper.instance().getOptions(defaultProps)).toEqual(options); expect(wrapper.instance().getOptions(defaultProps)).toEqual(options);
}); });
it('shows Select-All when enabled', () => {
const selectAllProps = {
choices: ['one', 'two'],
name: 'name',
freeForm: true,
allowAll: true,
multi: true,
valueKey: 'value',
};
wrapper.setProps(selectAllProps);
expect(wrapper.instance().getOptions(selectAllProps)).toContainEqual({
label: 'Select all',
meta: true,
value: 'Select all',
});
});
it('returns the correct options when freeform is set to true', () => {
const freeFormProps = {
choices: [],
freeForm: true,
value: ['one', 'two'],
name: 'row_limit',
label: 'Row Limit',
valueKey: 'custom_value_key',
onChange: sinon.spy(),
};
// the last added option is at the top
const expectedNewOptions = [
{ custom_value_key: 'two', label: 'two' },
{ custom_value_key: 'one', label: 'one' },
];
wrapper.setProps(freeFormProps);
expect(wrapper.instance().getOptions(freeFormProps)).toEqual(
expectedNewOptions,
);
});
}); });
describe('UNSAFE_componentWillReceiveProps', () => { describe('UNSAFE_componentWillReceiveProps', () => {
it('sets state.options if props.choices has changed', () => { it('sets state.options if props.choices has changed', () => {
const updatedOptions = [ const updatedOptions = [

View File

@ -31,7 +31,7 @@ const defaultProps = {
onChange: sinon.spy(), onChange: sinon.spy(),
}; };
describe('SelectControl', () => { describe('TextArea', () => {
let wrapper; let wrapper;
beforeEach(() => { beforeEach(() => {
wrapper = shallow(<TextAreaControl {...defaultProps} />); wrapper = shallow(<TextAreaControl {...defaultProps} />);

View File

@ -93,13 +93,10 @@ export const controlPanelSectionsChartOptionsTable: ControlPanelSectionConfig[]
default: [], default: [],
description: t('Columns to display'), description: t('Columns to display'),
optionRenderer: c => <ColumnOption column={c} showType />, optionRenderer: c => <ColumnOption column={c} showType />,
valueRenderer: c => <ColumnOption column={c} />,
valueKey: 'column_name', valueKey: 'column_name',
allowAll: true,
mapStateToProps: stateRef => ({ mapStateToProps: stateRef => ({
options: stateRef.datasource ? stateRef.datasource.columns : [], options: stateRef.datasource ? stateRef.datasource.columns : [],
}), }),
commaChoosesOption: false,
freeForm: true, freeForm: true,
} as ControlConfig<'SelectControl', ColumnMeta>, } as ControlConfig<'SelectControl', ColumnMeta>,
}, },

View File

@ -40,7 +40,7 @@ import { getClientErrorObject } from 'src/utils/getClientErrorObject';
import CheckboxControl from 'src/explore/components/controls/CheckboxControl'; import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
import TextControl from 'src/explore/components/controls/TextControl'; import TextControl from 'src/explore/components/controls/TextControl';
import SelectControl from 'src/explore/components/controls/SelectControl'; import { Select } from 'src/components';
import TextAreaControl from 'src/explore/components/controls/TextAreaControl'; import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
import SelectAsyncControl from 'src/explore/components/controls/SelectAsyncControl'; import SelectAsyncControl from 'src/explore/components/controls/SelectAsyncControl';
import SpatialControl from 'src/explore/components/controls/SpatialControl'; import SpatialControl from 'src/explore/components/controls/SpatialControl';
@ -121,7 +121,12 @@ const StyledLabelWrapper = styled.div`
const checkboxGenerator = (d, onChange) => ( const checkboxGenerator = (d, onChange) => (
<CheckboxControl value={d} onChange={onChange} /> <CheckboxControl value={d} onChange={onChange} />
); );
const DATA_TYPES = ['STRING', 'NUMERIC', 'DATETIME', 'BOOLEAN']; const DATA_TYPES = [
{ value: 'STRING', label: 'STRING' },
{ value: 'NUMERIC', label: 'NUMERIC' },
{ value: 'DATETIME', label: 'DATETIME' },
{ value: 'BOOLEAN', label: 'BOOLEAN' },
];
const DATASOURCE_TYPES_ARR = [ const DATASOURCE_TYPES_ARR = [
{ key: 'physical', label: t('Physical (table or view)') }, { key: 'physical', label: t('Physical (table or view)') },
@ -207,7 +212,13 @@ function ColumnCollectionTable({
fieldKey="type" fieldKey="type"
label={t('Data type')} label={t('Data type')}
control={ control={
<SelectControl choices={DATA_TYPES} name="type" freeForm /> <Select
ariaLabel={t('Data type')}
options={DATA_TYPES}
name="type"
allowNewOptions
allowClear
/>
} }
/> />
)} )}

View File

@ -70,8 +70,6 @@ const propTypes = {
addAnnotationLayer: PropTypes.func, addAnnotationLayer: PropTypes.func,
removeAnnotationLayer: PropTypes.func, removeAnnotationLayer: PropTypes.func,
close: PropTypes.func, close: PropTypes.func,
onPopoverClear: PropTypes.func,
}; };
const defaultProps = { const defaultProps = {
@ -95,7 +93,6 @@ const defaultProps = {
addAnnotationLayer: () => {}, addAnnotationLayer: () => {},
removeAnnotationLayer: () => {}, removeAnnotationLayer: () => {},
close: () => {}, close: () => {},
onPopoverClear: () => {},
}; };
export default class AnnotationLayer extends React.PureComponent { export default class AnnotationLayer extends React.PureComponent {
@ -172,7 +169,6 @@ export default class AnnotationLayer extends React.PureComponent {
); );
this.handleValue = this.handleValue.bind(this); this.handleValue = this.handleValue.bind(this);
this.isValidForm = this.isValidForm.bind(this); this.isValidForm = this.isValidForm.bind(this);
this.popoverClearWrapper = this.popoverClearWrapper.bind(this);
} }
componentDidMount() { componentDidMount() {
@ -238,15 +234,6 @@ export default class AnnotationLayer extends React.PureComponent {
return !errors.filter(x => x).length; return !errors.filter(x => x).length;
} }
popoverClearWrapper(value, actionMeta, callback) {
if (callback) {
callback(value);
}
if (actionMeta?.action === 'clear') {
this.props.onPopoverClear(true);
}
}
handleAnnotationType(annotationType) { handleAnnotationType(annotationType) {
this.setState({ this.setState({
annotationType, annotationType,
@ -266,7 +253,7 @@ export default class AnnotationLayer extends React.PureComponent {
handleValue(value) { handleValue(value) {
this.setState({ this.setState({
value, value,
descriptionColumns: null, descriptionColumns: [],
intervalEndColumn: null, intervalEndColumn: null,
timeColumn: null, timeColumn: null,
titleColumn: null, titleColumn: null,
@ -409,6 +396,7 @@ export default class AnnotationLayer extends React.PureComponent {
if (requiresQuery(sourceType)) { if (requiresQuery(sourceType)) {
return ( return (
<SelectControl <SelectControl
ariaLabel={t('Annotation layer value')}
name="annotation-layer-value" name="annotation-layer-value"
showHeader showHeader
hovered hovered
@ -418,9 +406,7 @@ export default class AnnotationLayer extends React.PureComponent {
options={valueOptions} options={valueOptions}
isLoading={isLoadingOptions} isLoading={isLoadingOptions}
value={value} value={value}
onChange={(value, _, actionMeta) => onChange={this.handleValue}
this.popoverClearWrapper(value, actionMeta, this.handleValue)
}
validationErrors={!value ? ['Mandatory'] : []} validationErrors={!value ? ['Mandatory'] : []}
optionRenderer={this.renderOption} optionRenderer={this.renderOption}
/> />
@ -479,14 +465,17 @@ export default class AnnotationLayer extends React.PureComponent {
{(annotationType === ANNOTATION_TYPES.EVENT || {(annotationType === ANNOTATION_TYPES.EVENT ||
annotationType === ANNOTATION_TYPES.INTERVAL) && ( annotationType === ANNOTATION_TYPES.INTERVAL) && (
<SelectControl <SelectControl
ariaLabel={t('Annotation layer time column')}
hovered hovered
name="annotation-layer-time-column" name="annotation-layer-time-column"
label={ label={
annotationType === ANNOTATION_TYPES.INTERVAL annotationType === ANNOTATION_TYPES.INTERVAL
? 'Interval start column' ? t('Interval start column')
: 'Event time column' : t('Event time column')
} }
description="This column must contain date/time information." description={t(
'This column must contain date/time information.',
)}
validationErrors={!timeColumn ? ['Mandatory'] : []} validationErrors={!timeColumn ? ['Mandatory'] : []}
clearable={false} clearable={false}
options={timeColumnOptions} options={timeColumnOptions}
@ -496,48 +485,42 @@ export default class AnnotationLayer extends React.PureComponent {
)} )}
{annotationType === ANNOTATION_TYPES.INTERVAL && ( {annotationType === ANNOTATION_TYPES.INTERVAL && (
<SelectControl <SelectControl
ariaLabel={t('Annotation layer interval end')}
hovered hovered
name="annotation-layer-intervalEnd" name="annotation-layer-intervalEnd"
label="Interval End column" label={t('Interval End column')}
description="This column must contain date/time information." description={t(
'This column must contain date/time information.',
)}
validationErrors={!intervalEndColumn ? ['Mandatory'] : []} validationErrors={!intervalEndColumn ? ['Mandatory'] : []}
options={columns} options={columns}
value={intervalEndColumn} value={intervalEndColumn}
onChange={(value, _, actionMeta) => onChange={value => this.setState({ intervalEndColumn: value })}
this.popoverClearWrapper(value, actionMeta, v =>
this.setState({ intervalEndColumn: v }),
)
}
/> />
)} )}
<SelectControl <SelectControl
ariaLabel={t('Annotation layer title column')}
hovered hovered
name="annotation-layer-title" name="annotation-layer-title"
label="Title Column" label={t('Title Column')}
description="Pick a title for you annotation." description={t('Pick a title for you annotation.')}
options={[{ value: '', label: 'None' }].concat(columns)} options={[{ value: '', label: 'None' }].concat(columns)}
value={titleColumn} value={titleColumn}
onChange={(value, _, actionMeta) => onChange={value => this.setState({ titleColumn: value })}
this.popoverClearWrapper(value, actionMeta, v =>
this.setState({ titleColumn: v }),
)
}
/> />
{annotationType !== ANNOTATION_TYPES.TIME_SERIES && ( {annotationType !== ANNOTATION_TYPES.TIME_SERIES && (
<SelectControl <SelectControl
ariaLabel={t('Annotation layer description columns')}
hovered hovered
name="annotation-layer-title" name="annotation-layer-title"
label="Description Columns" label={t('Description Columns')}
description={`Pick one or more columns that should be shown in the description={t(
annotation. If you don't select a column all of them will be shown.`} "Pick one or more columns that should be shown in the annotation. If you don't select a column all of them will be shown.",
)}
multi multi
options={columns} options={columns}
value={descriptionColumns} value={descriptionColumns}
onChange={(value, _, actionMeta) => onChange={value => this.setState({ descriptionColumns: value })}
this.popoverClearWrapper(value, actionMeta, v =>
this.setState({ descriptionColumns: v }),
)
}
/> />
)} )}
<div style={{ marginTop: '1rem' }}> <div style={{ marginTop: '1rem' }}>
@ -629,6 +612,7 @@ export default class AnnotationLayer extends React.PureComponent {
info={t('Configure your how you overlay is displayed here.')} info={t('Configure your how you overlay is displayed here.')}
> >
<SelectControl <SelectControl
ariaLabel={t('Annotation layer stroke')}
name="annotation-layer-stroke" name="annotation-layer-stroke"
label={t('Style')} label={t('Style')}
// see '../../../visualizations/nvd3_vis.css' // see '../../../visualizations/nvd3_vis.css'
@ -643,6 +627,7 @@ export default class AnnotationLayer extends React.PureComponent {
onChange={v => this.setState({ style: v })} onChange={v => this.setState({ style: v })}
/> />
<SelectControl <SelectControl
ariaLabel={t('Annotation layer opacity')}
name="annotation-layer-opacity" name="annotation-layer-opacity"
label={t('Opacity')} label={t('Opacity')}
// see '../../../visualizations/nvd3_vis.css' // see '../../../visualizations/nvd3_vis.css'
@ -653,11 +638,7 @@ export default class AnnotationLayer extends React.PureComponent {
{ value: 'opacityHigh', label: '0.8' }, { value: 'opacityHigh', label: '0.8' },
]} ]}
value={opacity} value={opacity}
onChange={(value, _, actionMeta) => onChange={value => this.setState({ opacity: value })}
this.popoverClearWrapper(value, actionMeta, v =>
this.setState({ opacity: v }),
)
}
/> />
<div> <div>
<ControlHeader label={t('Color')} /> <ControlHeader label={t('Color')} />
@ -746,6 +727,7 @@ export default class AnnotationLayer extends React.PureComponent {
onChange={v => this.setState({ show: !v })} onChange={v => this.setState({ show: !v })}
/> />
<SelectControl <SelectControl
ariaLabel={t('Annotation layer type')}
hovered hovered
description={t('Choose the annotation layer type')} description={t('Choose the annotation layer type')}
label={t('Annotation layer type')} label={t('Annotation layer type')}
@ -757,19 +739,14 @@ export default class AnnotationLayer extends React.PureComponent {
/> />
{supportedSourceTypes.length > 0 && ( {supportedSourceTypes.length > 0 && (
<SelectControl <SelectControl
ariaLabel={t('Annotation source type')}
hovered hovered
description="Choose the source of your annotations" description={t('Choose the source of your annotations')}
label="Annotation Source" label={t('Annotation Source')}
name="annotation-source-type" name="annotation-source-type"
options={supportedSourceTypes} options={supportedSourceTypes}
value={sourceType} value={sourceType}
onChange={(value, _, actionMeta) => onChange={this.handleAnnotationSourceType}
this.popoverClearWrapper(
value,
actionMeta,
this.handleAnnotationSourceType,
)
}
validationErrors={!sourceType ? [t('Mandatory')] : []} validationErrors={!sourceType ? [t('Mandatory')] : []}
/> />
)} )}

View File

@ -84,10 +84,22 @@ test('renders extra checkboxes when type is time series', async () => {
}); });
test('enables apply and ok buttons', async () => { test('enables apply and ok buttons', async () => {
await waitForRender(); const { container } = render(<AnnotationLayer {...defaultProps} />);
userEvent.type(screen.getByLabelText('Name'), 'Test');
userEvent.type(screen.getByLabelText('Formula'), '2x'); waitFor(() => {
await waitFor(() => { expect(container).toBeInTheDocument();
});
const nameInput = screen.getByRole('textbox', { name: 'Name' });
const formulaInput = screen.getByRole('textbox', { name: 'Formula' });
expect(nameInput).toBeInTheDocument();
expect(formulaInput).toBeInTheDocument();
userEvent.type(nameInput, 'Name');
userEvent.type(formulaInput, '2x');
waitFor(() => {
expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
expect(screen.getByRole('button', { name: 'OK' })).toBeEnabled(); expect(screen.getByRole('button', { name: 'OK' })).toBeEnabled();
}); });
@ -134,12 +146,17 @@ test('renders chart options', async () => {
await waitForRender({ await waitForRender({
annotationType: ANNOTATION_TYPES_METADATA.EVENT.value, annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
}); });
userEvent.click(screen.getByText('2 option(s)')); userEvent.click(
userEvent.click(screen.getByText('Superset annotation')); screen.getByRole('combobox', { name: 'Annotation source type' }),
expect(await screen.findByLabelText('Annotation layer')).toBeInTheDocument(); );
userEvent.click(screen.getByText('Superset annotation')); userEvent.click(screen.getByText('Superset annotation'));
expect(screen.getByText('Annotation layer')).toBeInTheDocument();
userEvent.click(
screen.getByRole('combobox', { name: 'Annotation source type' }),
);
userEvent.click(screen.getByText('Table')); userEvent.click(screen.getByText('Table'));
expect(await screen.findByLabelText('Chart')).toBeInTheDocument(); expect(screen.getByText('Chart')).toBeInTheDocument();
}); });
test('keeps apply disabled when missing required fields', async () => { test('keeps apply disabled when missing required fields', async () => {
@ -147,18 +164,28 @@ test('keeps apply disabled when missing required fields', async () => {
annotationType: ANNOTATION_TYPES_METADATA.EVENT.value, annotationType: ANNOTATION_TYPES_METADATA.EVENT.value,
sourceType: 'Table', sourceType: 'Table',
}); });
userEvent.click(screen.getByText('1 option(s)')); userEvent.click(
await waitFor(() => userEvent.click(screen.getByText('Chart A'))); screen.getByRole('combobox', { name: 'Annotation layer value' }),
);
userEvent.click(await screen.findByText('Chart A'));
expect( expect(
screen.getByText('Annotation Slice Configuration'), screen.getByText('Annotation Slice Configuration'),
).toBeInTheDocument(); ).toBeInTheDocument();
userEvent.click(screen.getByRole('button', { name: 'Automatic Color' })); userEvent.click(screen.getByRole('button', { name: 'Automatic Color' }));
userEvent.click(screen.getByLabelText('Title Column')); userEvent.click(
screen.getByRole('combobox', { name: 'Annotation layer title column' }),
);
userEvent.click(screen.getByText('None')); userEvent.click(screen.getByText('None'));
userEvent.click(screen.getByLabelText('Style')); userEvent.click(screen.getByText('Style'));
userEvent.click(
screen.getByRole('combobox', { name: 'Annotation layer stroke' }),
);
userEvent.click(screen.getByText('Dashed')); userEvent.click(screen.getByText('Dashed'));
userEvent.click(screen.getByLabelText('Opacity')); userEvent.click(screen.getByText('Opacity'));
userEvent.click(
screen.getByRole('combobox', { name: 'Annotation layer opacity' }),
);
userEvent.click(screen.getByText('0.5')); userEvent.click(screen.getByText('0.5'));
const checkboxes = screen.getAllByRole('checkbox'); const checkboxes = screen.getAllByRole('checkbox');

View File

@ -62,12 +62,10 @@ class AnnotationLayerControl extends React.PureComponent {
this.state = { this.state = {
popoverVisible: {}, popoverVisible: {},
addedAnnotationIndex: null, addedAnnotationIndex: null,
popoverClear: false,
}; };
this.addAnnotationLayer = this.addAnnotationLayer.bind(this); this.addAnnotationLayer = this.addAnnotationLayer.bind(this);
this.removeAnnotationLayer = this.removeAnnotationLayer.bind(this); this.removeAnnotationLayer = this.removeAnnotationLayer.bind(this);
this.handleVisibleChange = this.handleVisibleChange.bind(this); this.handleVisibleChange = this.handleVisibleChange.bind(this);
this.handlePopoverClear = this.handlePopoverClear.bind(this);
} }
componentDidMount() { componentDidMount() {
@ -105,19 +103,9 @@ class AnnotationLayerControl extends React.PureComponent {
} }
handleVisibleChange(visible, popoverKey) { handleVisibleChange(visible, popoverKey) {
if (!this.state.popoverClear) { this.setState(prevState => ({
this.setState(prevState => ({ popoverVisible: { ...prevState.popoverVisible, [popoverKey]: visible },
popoverVisible: { ...prevState.popoverVisible, [popoverKey]: visible }, }));
}));
} else {
this.handlePopoverClear(false);
}
}
handlePopoverClear(popoverClear) {
this.setState({
popoverClear,
});
} }
removeAnnotationLayer(annotation) { removeAnnotationLayer(annotation) {
@ -143,7 +131,6 @@ class AnnotationLayerControl extends React.PureComponent {
this.handleVisibleChange(false, popoverKey); this.handleVisibleChange(false, popoverKey);
this.setState({ addedAnnotationIndex: null }); this.setState({ addedAnnotationIndex: null });
}} }}
onPopoverClear={this.handlePopoverClear}
/> />
</div> </div>
); );

View File

@ -23,7 +23,7 @@ import { InfoTooltipWithTrigger } from '@superset-ui/chart-controls';
import Popover from 'src/components/Popover'; import Popover from 'src/components/Popover';
import FormRow from 'src/components/FormRow'; import FormRow from 'src/components/FormRow';
import SelectControl from 'src/explore/components/controls/SelectControl'; import { Select } from 'src/components';
import CheckboxControl from 'src/explore/components/controls/CheckboxControl'; import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
import TextControl from 'src/explore/components/controls/TextControl'; import TextControl from 'src/explore/components/controls/TextControl';
import { FILTER_CONFIG_ATTRIBUTES } from 'src/explore/constants'; import { FILTER_CONFIG_ATTRIBUTES } from 'src/explore/constants';
@ -136,12 +136,12 @@ export default class FilterBoxItemControl extends React.Component {
<FormRow <FormRow
label={t('Column')} label={t('Column')}
control={ control={
<SelectControl <Select
ariaLabel={t('Column')}
value={this.state.column} value={this.state.column}
name="column" name="column"
clearable={false}
options={this.props.datasource.columns options={this.props.datasource.columns
.filter(col => col !== this.state.column) .filter(col => col.column_name !== this.state.column)
.map(col => ({ .map(col => ({
value: col.column_name, value: col.column_name,
label: col.column_name, label: col.column_name,
@ -184,11 +184,12 @@ export default class FilterBoxItemControl extends React.Component {
label={t('Sort metric')} label={t('Sort metric')}
tooltip={t('Metric to sort the results by')} tooltip={t('Metric to sort the results by')}
control={ control={
<SelectControl <Select
ariaLabel={t('Sort metric')}
value={this.state.metric} value={this.state.metric}
name="column" name="column"
options={this.props.datasource.metrics options={this.props.datasource.metrics
.filter(metric => metric !== this.state.metric) .filter(m => m.metric_name !== this.state.metric)
.map(m => ({ .map(m => ({
value: m.metric_name, value: m.metric_name,
label: m.metric_name, label: m.metric_name,

View File

@ -18,11 +18,12 @@
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { t, css } from '@superset-ui/core'; import { css, t } from '@superset-ui/core';
import { Select, CreatableSelect, OnPasteSelect } from 'src/components/Select'; import { Select } from 'src/components';
import ControlHeader from 'src/explore/components/ControlHeader'; import ControlHeader from 'src/explore/components/ControlHeader';
const propTypes = { const propTypes = {
ariaLabel: PropTypes.string,
autoFocus: PropTypes.bool, autoFocus: PropTypes.bool,
choices: PropTypes.array, choices: PropTypes.array,
clearable: PropTypes.bool, clearable: PropTypes.bool,
@ -30,10 +31,8 @@ const propTypes = {
disabled: PropTypes.bool, disabled: PropTypes.bool,
freeForm: PropTypes.bool, freeForm: PropTypes.bool,
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
label: PropTypes.string,
multi: PropTypes.bool, multi: PropTypes.bool,
isMulti: PropTypes.bool, isMulti: PropTypes.bool,
allowAll: PropTypes.bool,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
onChange: PropTypes.func, onChange: PropTypes.func,
onFocus: PropTypes.func, onFocus: PropTypes.func,
@ -42,21 +41,29 @@ const propTypes = {
PropTypes.number, PropTypes.number,
PropTypes.array, PropTypes.array,
]), ]),
default: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.array,
]),
showHeader: PropTypes.bool, showHeader: PropTypes.bool,
optionRenderer: PropTypes.func, optionRenderer: PropTypes.func,
valueRenderer: PropTypes.func,
valueKey: PropTypes.string, valueKey: PropTypes.string,
options: PropTypes.array, options: PropTypes.array,
placeholder: PropTypes.string, placeholder: PropTypes.string,
noResultsText: PropTypes.string,
selectRef: PropTypes.func,
filterOption: PropTypes.func, filterOption: PropTypes.func,
promptTextCreator: PropTypes.func,
commaChoosesOption: PropTypes.bool, // ControlHeader props
menuPortalTarget: PropTypes.element, label: PropTypes.string,
menuPosition: PropTypes.string, renderTrigger: PropTypes.bool,
menuPlacement: PropTypes.string, validationErrors: PropTypes.array,
forceOverflow: PropTypes.bool, rightNode: PropTypes.node,
leftNode: PropTypes.node,
onClick: PropTypes.func,
hovered: PropTypes.bool,
tooltipOnClick: PropTypes.func,
warning: PropTypes.string,
danger: PropTypes.string,
}; };
const defaultProps = { const defaultProps = {
@ -73,10 +80,6 @@ const defaultProps = {
onFocus: () => {}, onFocus: () => {},
showHeader: true, showHeader: true,
valueKey: 'value', valueKey: 'value',
noResultsText: t('No results found'),
promptTextCreator: label => `Create Option ${label}`,
commaChoosesOption: true,
allowAll: false,
}; };
export default class SelectControl extends React.PureComponent { export default class SelectControl extends React.PureComponent {
@ -84,13 +87,9 @@ export default class SelectControl extends React.PureComponent {
super(props); super(props);
this.state = { this.state = {
options: this.getOptions(props), options: this.getOptions(props),
value: this.props.value,
}; };
this.onChange = this.onChange.bind(this); this.onChange = this.onChange.bind(this);
this.createMetaSelectAllOption = this.createMetaSelectAllOption.bind(this); this.handleFilterOptions = this.handleFilterOptions.bind(this);
this.select = null; // pointer to the react-select instance
this.getSelectRef = this.getSelectRef.bind(this);
this.handleKeyDownForCreate = this.handleKeyDownForCreate.bind(this);
} }
UNSAFE_componentWillReceiveProps(nextProps) { UNSAFE_componentWillReceiveProps(nextProps) {
@ -105,194 +104,130 @@ export default class SelectControl extends React.PureComponent {
// Beware: This is acting like an on-click instead of an on-change // Beware: This is acting like an on-click instead of an on-change
// (firing every time user chooses vs firing only if a new option is chosen). // (firing every time user chooses vs firing only if a new option is chosen).
onChange(opt, actionMeta) { onChange(val) {
let optionValue = this.props.multi ? [] : null;
if (opt) {
if (this.props.multi) {
opt.forEach(o => {
// select all options
if (o.meta === true) {
optionValue = this.getOptions(this.props)
.filter(x => !x.meta)
.map(x => x[this.props.valueKey]);
return;
}
optionValue.push(o[this.props.valueKey] || o);
});
} else if (opt.meta === true) {
return;
} else {
optionValue = opt[this.props.valueKey];
}
}
// will eventually call `exploreReducer`: SET_FIELD_VALUE // will eventually call `exploreReducer`: SET_FIELD_VALUE
this.props.onChange(optionValue, [], actionMeta); const { valueKey } = this.props;
} let onChangeVal = val;
getSelectRef(instance) { if (Array.isArray(val)) {
this.select = instance; const values = val.map(v => v?.[valueKey] || v);
if (this.props.selectRef) { onChangeVal = values;
this.props.selectRef(instance);
} }
if (typeof val === 'object' && val?.[valueKey]) {
onChangeVal = val[valueKey];
}
this.props.onChange(onChangeVal, []);
} }
getOptions(props) { getOptions(props) {
const { choices, optionRenderer, valueKey } = props;
let options = []; let options = [];
if (props.options) { if (props.options) {
options = props.options.map(x => x); options = props.options.map(o => ({
} else if (props.choices) { ...o,
value: o[valueKey],
label: o.label || o[valueKey],
customLabel: optionRenderer ? optionRenderer(o) : undefined,
}));
} else if (choices) {
// Accepts different formats of input // Accepts different formats of input
options = props.choices.map(c => { options = choices.map(c => {
if (Array.isArray(c)) { if (Array.isArray(c)) {
const [value, label] = c.length > 1 ? c : [c[0], c[0]]; const [value, label] = c.length > 1 ? c : [c[0], c[0]];
return { label, [props.valueKey]: value }; return {
value,
label,
};
} }
if (Object.is(c)) { if (Object.is(c)) {
return c; return {
...c,
value: c[valueKey],
label: c.label || c[valueKey],
};
} }
return { label: c, [props.valueKey]: c }; return { value: c, label: c };
}); });
} }
// For FreeFormSelect, insert newly created values into options
if (props.freeForm && props.value) {
const existingOptionValues = new Set(options.map(c => c[props.valueKey]));
const selectedValues = Array.isArray(props.value)
? props.value
: [props.value];
selectedValues.forEach(v => {
if (!existingOptionValues.has(v)) {
// place the newly created options at the top
options.unshift({ label: v, [props.valueKey]: v });
}
});
}
if (props.allowAll === true && props.multi === true) {
if (!this.optionsIncludesSelectAll(options)) {
options.unshift(this.createMetaSelectAllOption());
}
} else {
options = options.filter(o => !this.isMetaSelectAllOption(o));
}
return options; return options;
} }
handleKeyDownForCreate(event) { handleFilterOptions(text, option) {
const { key } = event; const { filterOption } = this.props;
if (key === 'Tab' || (this.props.commaChoosesOption && key === ',')) { return filterOption({ data: option }, text);
// simulate an Enter event
if (this.select) {
this.select.onKeyDown({ ...event, key: 'Enter' });
}
}
}
isMetaSelectAllOption(o) {
return o.meta && o.meta === true && o.label === 'Select all';
}
optionsIncludesSelectAll(o) {
return o.findIndex(o => this.isMetaSelectAllOption(o)) >= 0;
}
optionsRemaining() {
const { options } = this.state;
const { value } = this.props;
// if select is multi/value is array, we show the options not selected
let remainingOptions = Array.isArray(value)
? options.length - value.length
: options.length;
if (this.optionsIncludesSelectAll(options)) {
remainingOptions -= 1;
}
return remainingOptions < 0 ? 0 : remainingOptions;
}
createMetaSelectAllOption() {
const option = { label: 'Select all', meta: true };
option[this.props.valueKey] = 'Select all';
return option;
} }
render() { render() {
// Tab, comma or Enter will trigger a new option created for FreeFormSelect
const { const {
ariaLabel,
autoFocus, autoFocus,
clearable, clearable,
disabled, disabled,
filterOption, filterOption,
isLoading, freeForm,
label,
menuPlacement,
name,
noResultsText,
onFocus,
optionRenderer,
promptTextCreator,
value,
valueKey,
valueRenderer,
forceOverflow,
menuPortalTarget,
menuPosition,
} = this.props;
const optionsRemaining = this.optionsRemaining();
const optionRemaingText = optionsRemaining
? t('%s option(s)', optionsRemaining)
: '';
const placeholder = this.props.placeholder || optionRemaingText;
const isMulti = this.props.isMulti || this.props.multi;
let assistiveText;
if (
isMulti &&
optionsRemaining &&
Array.isArray(this.state.value) &&
Array.isArray(value) &&
!!value.length
) {
assistiveText = optionRemaingText;
}
const selectProps = {
autoFocus,
'aria-label': label,
clearable,
disabled,
filterOption,
ignoreAccents: false,
isLoading, isLoading,
isMulti, isMulti,
labelKey: 'label', label,
menuPlacement, multi,
forceOverflow, name,
menuPortalTarget, placeholder,
menuPosition, onFocus,
optionRenderer,
showHeader,
value,
// ControlHeader props
description,
renderTrigger,
rightNode,
leftNode,
validationErrors,
onClick,
hovered,
tooltipOnClick,
warning,
danger,
} = this.props;
const headerProps = {
name,
label,
description,
renderTrigger,
rightNode,
leftNode,
validationErrors,
onClick,
hovered,
tooltipOnClick,
warning,
danger,
};
const selectProps = {
allowNewOptions: freeForm,
autoFocus,
ariaLabel:
ariaLabel || (typeof label === 'string' ? label : t('Select ...')),
allowClear: clearable,
disabled,
filterOption:
filterOption && typeof filterOption === 'function'
? this.handleFilterOptions
: true,
header: showHeader && <ControlHeader {...headerProps} />,
loading: isLoading,
mode: isMulti || multi ? 'multiple' : 'single',
name: `select-${name}`, name: `select-${name}`,
noResultsText,
onChange: this.onChange, onChange: this.onChange,
onFocus, onFocus,
optionRenderer, optionRenderer,
value,
options: this.state.options, options: this.state.options,
placeholder, placeholder,
assistiveText, value:
promptTextCreator, value ||
selectRef: this.getSelectRef, (this.props.default !== undefined ? this.props.default : undefined),
valueKey,
valueRenderer,
}; };
let SelectComponent;
if (this.props.freeForm) {
SelectComponent = CreatableSelect;
// Don't create functions in `render` because React relies on shallow
// compare to decide weathere to rerender child components.
selectProps.onKeyDown = this.handleKeyDownForCreate;
} else {
SelectComponent = Select;
}
return ( return (
<div <div
css={theme => css` css={theme => css`
@ -307,12 +242,7 @@ export default class SelectControl extends React.PureComponent {
} }
`} `}
> >
{this.props.showHeader && <ControlHeader {...this.props} />} <Select {...selectProps} />
{isMulti ? (
<OnPasteSelect {...selectProps} selectWrap={SelectComponent} />
) : (
<SelectComponent {...selectProps} />
)}
</div> </div>
); );
} }

View File

@ -134,6 +134,7 @@ export default class SpatialControl extends React.Component {
renderSelect(name, type) { renderSelect(name, type) {
return ( return (
<SelectControl <SelectControl
ariaLabel={name}
name={name} name={name}
choices={this.props.choices} choices={this.props.choices}
value={this.state[name]} value={this.state[name]}

View File

@ -125,15 +125,12 @@ const groupByControl = {
includeTime: false, includeTime: false,
description: t('One or many controls to group by'), description: t('One or many controls to group by'),
optionRenderer: c => <StyledColumnOption column={c} showType />, optionRenderer: c => <StyledColumnOption column={c} showType />,
valueRenderer: c => <StyledColumnOption column={c} />,
valueKey: 'column_name', valueKey: 'column_name',
allowAll: true,
filterOption: ({ data: opt }, text) => filterOption: ({ data: opt }, text) =>
(opt.column_name && (opt.column_name &&
opt.column_name.toLowerCase().indexOf(text.toLowerCase()) >= 0) || opt.column_name.toLowerCase().indexOf(text.toLowerCase()) >= 0) ||
(opt.verbose_name && (opt.verbose_name &&
opt.verbose_name.toLowerCase().indexOf(text.toLowerCase()) >= 0), opt.verbose_name.toLowerCase().indexOf(text.toLowerCase()) >= 0),
promptTextCreator: label => label,
mapStateToProps: (state, control) => { mapStateToProps: (state, control) => {
const newState = {}; const newState = {};
if (state.datasource) { if (state.datasource) {
@ -144,7 +141,6 @@ const groupByControl = {
} }
return newState; return newState;
}, },
commaChoosesOption: false,
}; };
const metrics = { const metrics = {
@ -266,7 +262,7 @@ export const controls = {
type: 'SelectControl', type: 'SelectControl',
freeForm: true, freeForm: true,
label: TIME_FILTER_LABELS.granularity, label: TIME_FILTER_LABELS.granularity,
default: 'one day', default: 'P1D',
choices: [ choices: [
[null, 'all'], [null, 'all'],
['PT5S', '5 seconds'], ['PT5S', '5 seconds'],
@ -304,7 +300,6 @@ export const controls = {
), ),
clearable: false, clearable: false,
optionRenderer: c => <StyledColumnOption column={c} showType />, optionRenderer: c => <StyledColumnOption column={c} showType />,
valueRenderer: c => <StyledColumnOption column={c} />,
valueKey: 'column_name', valueKey: 'column_name',
mapStateToProps: state => { mapStateToProps: state => {
const props = {}; const props = {};