mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[App Search] Refactor out a shared MultiInputRows component (#96881)
* Add new reusable MultiInputRows component - basically the CurationQuery component, but with a generic values var & allows passing in custom text for every string * Update CurationQueries with MultiInputRows * Update MultiInputRows to support on change behavior - for upcoming Relevance Tuning usage * Update Relevance Tuning value boost form to use new component - relevance_tuning_form.test.tsx fix: was getting test errors with mount(), so I switched to shallow() * Change submitOnChange to onChange fn - more flexible - allows for either an onSubmit or onChange, or even potentially both * Convert MultiInputRowsLogic to keyed Kea logic - so that we can have multiple instances on the same page - primarily the value boosts use case * Update LogicMounter helper & tests to handle keyed logic w/ props * [Misc] LogicMounter helper - fix typing, perf - Use Kea's types instead of trying to rewrite my own LogicFile - Add an early return for tests that pass `{}` to values as well for performance * PR feedback: Change values prop to initialValues + bonus - add a fallback for initially empty components + add a test to check that the logic was mounted correctly * PR feedback: Remove useRef/on mount onChange catch for now - We don't currently need the extra catch for any live components, and it's confusing
This commit is contained in:
parent
3ba640403f
commit
2a281c99c6
28 changed files with 559 additions and 683 deletions
|
@ -84,13 +84,10 @@ export const setMockActions = (actions: object) => {
|
|||
* unmount();
|
||||
* });
|
||||
*/
|
||||
import { resetContext, Logic, LogicInput } from 'kea';
|
||||
import { resetContext, LogicWrapper } from 'kea';
|
||||
|
||||
type LogicFile = LogicWrapper<any>;
|
||||
|
||||
interface LogicFile {
|
||||
inputs: Array<LogicInput<Logic>>;
|
||||
build(props?: object): void;
|
||||
mount(): Function;
|
||||
}
|
||||
export class LogicMounter {
|
||||
private logicFile: LogicFile;
|
||||
private unmountFn!: Function;
|
||||
|
@ -100,24 +97,39 @@ export class LogicMounter {
|
|||
}
|
||||
|
||||
// Reset context with optional default value overrides
|
||||
public resetContext = (values?: object) => {
|
||||
if (!values) {
|
||||
public resetContext = (values?: object, props?: object) => {
|
||||
if (!values || !Object.keys(values).length) {
|
||||
resetContext({});
|
||||
} else {
|
||||
const path = this.logicFile.inputs[0].path as string[]; // example: ['x', 'y', 'z']
|
||||
const defaults = path.reduceRight((value: object, key: string) => ({ [key]: value }), values); // example: { x: { y: { z: values } } }
|
||||
let { path, key } = this.logicFile.inputs[0];
|
||||
|
||||
// For keyed logic files, both key and path should be functions
|
||||
if (this.logicFile._isKeaWithKey) {
|
||||
key = key(props);
|
||||
path = path(key);
|
||||
}
|
||||
|
||||
// Generate the correct nested defaults obj based on the file path
|
||||
// example path: ['x', 'y', 'z']
|
||||
// example defaults: { x: { y: { z: values } } }
|
||||
const defaults = path.reduceRight(
|
||||
(value: object, name: string) => ({ [name]: value }),
|
||||
values
|
||||
);
|
||||
resetContext({ defaults });
|
||||
}
|
||||
};
|
||||
|
||||
// Automatically reset context & mount the logic file
|
||||
public mount = (values?: object, props?: object) => {
|
||||
this.resetContext(values);
|
||||
if (props) this.logicFile.build(props);
|
||||
this.resetContext(values, props);
|
||||
|
||||
const unmount = this.logicFile.mount();
|
||||
this.unmountFn = unmount;
|
||||
return unmount; // Keep Kea behavior of returning an unmount fn from mount
|
||||
const logicWithProps = this.logicFile.build(props);
|
||||
this.unmountFn = logicWithProps.mount();
|
||||
|
||||
return logicWithProps;
|
||||
// NOTE: Unlike kea's mount(), this returns the current
|
||||
// built logic instance with props, NOT the unmount fn
|
||||
};
|
||||
|
||||
// Also add unmount as a class method that can be destructured on init without becoming stale later
|
||||
|
@ -146,7 +158,7 @@ export class LogicMounter {
|
|||
const { listeners } = this.logicFile.inputs[0];
|
||||
|
||||
return typeof listeners === 'function'
|
||||
? (listeners as Function)(listenersArgs) // e.g., listeners({ values, actions, props }) => ({ ... })
|
||||
? listeners(listenersArgs) // e.g., listeners({ values, actions, props }) => ({ ... })
|
||||
: listeners; // handles simpler logic files that just define listeners: { ... }
|
||||
};
|
||||
}
|
||||
|
|
|
@ -1,102 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { setMockActions, setMockValues } from '../../../../../__mocks__';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { CurationQuery } from './curation_query';
|
||||
|
||||
import { CurationQueries } from './';
|
||||
|
||||
describe('CurationQueries', () => {
|
||||
const props = {
|
||||
queries: ['a', 'b', 'c'],
|
||||
onSubmit: jest.fn(),
|
||||
};
|
||||
const values = {
|
||||
queries: ['a', 'b', 'c'],
|
||||
hasEmptyQueries: false,
|
||||
hasOnlyOneQuery: false,
|
||||
};
|
||||
const actions = {
|
||||
addQuery: jest.fn(),
|
||||
editQuery: jest.fn(),
|
||||
deleteQuery: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setMockValues(values);
|
||||
setMockActions(actions);
|
||||
});
|
||||
|
||||
it('renders a CurationQuery row for each query', () => {
|
||||
const wrapper = shallow(<CurationQueries {...props} />);
|
||||
|
||||
expect(wrapper.find(CurationQuery)).toHaveLength(3);
|
||||
expect(wrapper.find(CurationQuery).at(0).prop('queryValue')).toEqual('a');
|
||||
expect(wrapper.find(CurationQuery).at(1).prop('queryValue')).toEqual('b');
|
||||
expect(wrapper.find(CurationQuery).at(2).prop('queryValue')).toEqual('c');
|
||||
});
|
||||
|
||||
it('calls editQuery when the CurationQuery value changes', () => {
|
||||
const wrapper = shallow(<CurationQueries {...props} />);
|
||||
wrapper.find(CurationQuery).at(0).simulate('change', 'new query value');
|
||||
|
||||
expect(actions.editQuery).toHaveBeenCalledWith(0, 'new query value');
|
||||
});
|
||||
|
||||
it('calls deleteQuery when the CurationQuery calls onDelete', () => {
|
||||
const wrapper = shallow(<CurationQueries {...props} />);
|
||||
wrapper.find(CurationQuery).at(2).simulate('delete');
|
||||
|
||||
expect(actions.deleteQuery).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('calls addQuery when the Add Query button is clicked', () => {
|
||||
const wrapper = shallow(<CurationQueries {...props} />);
|
||||
wrapper.find('[data-test-subj="addCurationQueryButton"]').simulate('click');
|
||||
|
||||
expect(actions.addQuery).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables the add button if any query fields are empty', () => {
|
||||
setMockValues({
|
||||
...values,
|
||||
queries: ['a', '', 'c'],
|
||||
hasEmptyQueries: true,
|
||||
});
|
||||
const wrapper = shallow(<CurationQueries {...props} />);
|
||||
const button = wrapper.find('[data-test-subj="addCurationQueryButton"]');
|
||||
|
||||
expect(button.prop('isDisabled')).toEqual(true);
|
||||
});
|
||||
|
||||
it('calls the passed onSubmit callback when the submit button is clicked', () => {
|
||||
setMockValues({ ...values, queries: ['some query'] });
|
||||
const wrapper = shallow(<CurationQueries {...props} />);
|
||||
wrapper.find('[data-test-subj="submitCurationQueriesButton"]').simulate('click');
|
||||
|
||||
expect(props.onSubmit).toHaveBeenCalledWith(['some query']);
|
||||
});
|
||||
|
||||
it('disables the submit button if no query fields have been filled', () => {
|
||||
setMockValues({
|
||||
...values,
|
||||
queries: [''],
|
||||
hasOnlyOneQuery: true,
|
||||
hasEmptyQueries: true,
|
||||
});
|
||||
const wrapper = shallow(<CurationQueries {...props} />);
|
||||
const button = wrapper.find('[data-test-subj="submitCurationQueriesButton"]');
|
||||
|
||||
expect(button.prop('isDisabled')).toEqual(true);
|
||||
});
|
||||
});
|
|
@ -1,72 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { useValues, useActions } from 'kea';
|
||||
|
||||
import { EuiButton, EuiButtonEmpty, EuiSpacer } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { CONTINUE_BUTTON_LABEL } from '../../../../../shared/constants';
|
||||
|
||||
import { Curation } from '../../types';
|
||||
|
||||
import { CurationQueriesLogic } from './curation_queries_logic';
|
||||
import { CurationQuery } from './curation_query';
|
||||
import { filterEmptyQueries } from './utils';
|
||||
import './curation_queries.scss';
|
||||
|
||||
interface Props {
|
||||
queries: Curation['queries'];
|
||||
onSubmit(queries: Curation['queries']): void;
|
||||
submitButtonText?: string;
|
||||
}
|
||||
|
||||
export const CurationQueries: React.FC<Props> = ({
|
||||
queries: initialQueries,
|
||||
onSubmit,
|
||||
submitButtonText = CONTINUE_BUTTON_LABEL,
|
||||
}) => {
|
||||
const logic = CurationQueriesLogic({ queries: initialQueries });
|
||||
const { queries, hasEmptyQueries, hasOnlyOneQuery } = useValues(logic);
|
||||
const { addQuery, editQuery, deleteQuery } = useActions(logic);
|
||||
|
||||
return (
|
||||
<>
|
||||
{queries.map((query: string, index) => (
|
||||
<CurationQuery
|
||||
key={`query-${index}`}
|
||||
queryValue={query}
|
||||
onChange={(newValue) => editQuery(index, newValue)}
|
||||
onDelete={() => deleteQuery(index)}
|
||||
disableDelete={hasOnlyOneQuery}
|
||||
/>
|
||||
))}
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
iconType="plusInCircle"
|
||||
onClick={addQuery}
|
||||
isDisabled={hasEmptyQueries}
|
||||
data-test-subj="addCurationQueryButton"
|
||||
>
|
||||
{i18n.translate('xpack.enterpriseSearch.appSearch.engine.curations.addQueryButtonLabel', {
|
||||
defaultMessage: 'Add query',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
<EuiSpacer />
|
||||
<EuiButton
|
||||
fill
|
||||
isDisabled={hasOnlyOneQuery && hasEmptyQueries}
|
||||
onClick={() => onSubmit(filterEmptyQueries(queries))}
|
||||
data-test-subj="submitCurationQueriesButton"
|
||||
>
|
||||
{submitButtonText}
|
||||
</EuiButton>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -1,94 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { LogicMounter } from '../../../../../__mocks__';
|
||||
|
||||
import { CurationQueriesLogic } from './curation_queries_logic';
|
||||
|
||||
describe('CurationQueriesLogic', () => {
|
||||
const { mount } = new LogicMounter(CurationQueriesLogic);
|
||||
|
||||
const MOCK_QUERIES = ['a', 'b', 'c'];
|
||||
|
||||
const DEFAULT_PROPS = { queries: MOCK_QUERIES };
|
||||
const DEFAULT_VALUES = {
|
||||
queries: MOCK_QUERIES,
|
||||
hasEmptyQueries: false,
|
||||
hasOnlyOneQuery: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('has expected default values passed from props', () => {
|
||||
mount({}, DEFAULT_PROPS);
|
||||
expect(CurationQueriesLogic.values).toEqual(DEFAULT_VALUES);
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
afterEach(() => {
|
||||
// Should not mutate the original array
|
||||
expect(CurationQueriesLogic.values.queries).not.toBe(MOCK_QUERIES); // Would fail if we did not clone a new array
|
||||
});
|
||||
|
||||
describe('addQuery', () => {
|
||||
it('appends an empty string to the queries array', () => {
|
||||
mount(DEFAULT_VALUES);
|
||||
CurationQueriesLogic.actions.addQuery();
|
||||
|
||||
expect(CurationQueriesLogic.values).toEqual({
|
||||
...DEFAULT_VALUES,
|
||||
hasEmptyQueries: true,
|
||||
queries: ['a', 'b', 'c', ''],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteQuery', () => {
|
||||
it('deletes the query string at the specified array index', () => {
|
||||
mount(DEFAULT_VALUES);
|
||||
CurationQueriesLogic.actions.deleteQuery(1);
|
||||
|
||||
expect(CurationQueriesLogic.values).toEqual({
|
||||
...DEFAULT_VALUES,
|
||||
queries: ['a', 'c'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editQuery', () => {
|
||||
it('edits the query string at the specified array index', () => {
|
||||
mount(DEFAULT_VALUES);
|
||||
CurationQueriesLogic.actions.editQuery(2, 'z');
|
||||
|
||||
expect(CurationQueriesLogic.values).toEqual({
|
||||
...DEFAULT_VALUES,
|
||||
queries: ['a', 'b', 'z'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectors', () => {
|
||||
describe('hasEmptyQueries', () => {
|
||||
it('returns true if queries has any empty strings', () => {
|
||||
mount({}, { queries: ['', '', ''] });
|
||||
|
||||
expect(CurationQueriesLogic.values.hasEmptyQueries).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasOnlyOneQuery', () => {
|
||||
it('returns true if queries only has one item', () => {
|
||||
mount({}, { queries: ['test'] });
|
||||
|
||||
expect(CurationQueriesLogic.values.hasOnlyOneQuery).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1,53 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { kea, MakeLogicType } from 'kea';
|
||||
|
||||
interface CurationQueriesValues {
|
||||
queries: string[];
|
||||
hasEmptyQueries: boolean;
|
||||
hasOnlyOneQuery: boolean;
|
||||
}
|
||||
|
||||
interface CurationQueriesActions {
|
||||
addQuery(): void;
|
||||
deleteQuery(indexToDelete: number): { indexToDelete: number };
|
||||
editQuery(index: number, newQueryValue: string): { index: number; newQueryValue: string };
|
||||
}
|
||||
|
||||
export const CurationQueriesLogic = kea<
|
||||
MakeLogicType<CurationQueriesValues, CurationQueriesActions>
|
||||
>({
|
||||
path: ['enterprise_search', 'app_search', 'curation_queries_logic'],
|
||||
actions: () => ({
|
||||
addQuery: true,
|
||||
deleteQuery: (indexToDelete) => ({ indexToDelete }),
|
||||
editQuery: (index, newQueryValue) => ({ index, newQueryValue }),
|
||||
}),
|
||||
reducers: ({ props }) => ({
|
||||
queries: [
|
||||
props.queries,
|
||||
{
|
||||
addQuery: (state) => [...state, ''],
|
||||
deleteQuery: (state, { indexToDelete }) => {
|
||||
const newState = [...state];
|
||||
newState.splice(indexToDelete, 1);
|
||||
return newState;
|
||||
},
|
||||
editQuery: (state, { index, newQueryValue }) => {
|
||||
const newState = [...state];
|
||||
newState[index] = newQueryValue;
|
||||
return newState;
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
selectors: {
|
||||
hasEmptyQueries: [(selectors) => [selectors.queries], (queries) => queries.indexOf('') >= 0],
|
||||
hasOnlyOneQuery: [(selectors) => [selectors.queries], (queries) => queries.length <= 1],
|
||||
},
|
||||
});
|
|
@ -1,15 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { filterEmptyQueries } from './utils';
|
||||
|
||||
describe('filterEmptyQueries', () => {
|
||||
it('filters out all empty strings from a queries array', () => {
|
||||
const queries = ['', 'a', '', 'b', '', 'c', ''];
|
||||
expect(filterEmptyQueries(queries)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
|
@ -1,8 +0,0 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
export { CurationQueries } from './curation_queries';
|
|
@ -25,6 +25,15 @@ export const MANAGE_CURATION_TITLE = i18n.translate(
|
|||
{ defaultMessage: 'Manage curation' }
|
||||
);
|
||||
|
||||
export const QUERY_INPUTS_BUTTON = i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.engine.curations.addQueryButtonLabel',
|
||||
{ defaultMessage: 'Add query' }
|
||||
);
|
||||
export const QUERY_INPUTS_PLACEHOLDER = i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.engine.curations.queryPlaceholder',
|
||||
{ defaultMessage: 'Enter a query' }
|
||||
);
|
||||
|
||||
export const DELETE_MESSAGE = i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.engine.curations.deleteConfirmation',
|
||||
{ defaultMessage: 'Are you sure you want to remove this curation?' }
|
||||
|
|
|
@ -13,7 +13,7 @@ import { shallow, ShallowWrapper } from 'enzyme';
|
|||
|
||||
import { EuiButton, EuiModal } from '@elastic/eui';
|
||||
|
||||
import { CurationQueries } from '../../components';
|
||||
import { MultiInputRows } from '../../../multi_input_rows';
|
||||
|
||||
import { ManageQueriesModal } from './';
|
||||
|
||||
|
@ -66,13 +66,13 @@ describe('ManageQueriesModal', () => {
|
|||
expect(wrapper.find(EuiModal)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders the CurationQueries form component', () => {
|
||||
expect(wrapper.find(CurationQueries)).toHaveLength(1);
|
||||
expect(wrapper.find(CurationQueries).prop('queries')).toEqual(['hello', 'world']);
|
||||
it('renders the MultiInputRows component with curation queries', () => {
|
||||
expect(wrapper.find(MultiInputRows)).toHaveLength(1);
|
||||
expect(wrapper.find(MultiInputRows).prop('initialValues')).toEqual(['hello', 'world']);
|
||||
});
|
||||
|
||||
it('calls updateCuration and closes the modal on CurationQueries form submit', () => {
|
||||
wrapper.find(CurationQueries).simulate('submit', ['new', 'queries']);
|
||||
it('calls updateCuration and closes the modal on MultiInputRows form submit', () => {
|
||||
wrapper.find(MultiInputRows).simulate('submit', ['new', 'queries']);
|
||||
|
||||
expect(actions.updateQueries).toHaveBeenCalledWith(['new', 'queries']);
|
||||
expect(wrapper.find(EuiModal)).toHaveLength(0);
|
||||
|
|
|
@ -21,8 +21,9 @@ import {
|
|||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { SAVE_BUTTON_LABEL } from '../../../../../shared/constants';
|
||||
import { MultiInputRows } from '../../../multi_input_rows';
|
||||
|
||||
import { CurationQueries } from '../../components';
|
||||
import { QUERY_INPUTS_BUTTON, QUERY_INPUTS_PLACEHOLDER } from '../../constants';
|
||||
import { CurationLogic } from '../curation_logic';
|
||||
|
||||
export const ManageQueriesModal: React.FC = () => {
|
||||
|
@ -61,8 +62,11 @@ export const ManageQueriesModal: React.FC = () => {
|
|||
</p>
|
||||
</EuiText>
|
||||
<EuiSpacer />
|
||||
<CurationQueries
|
||||
queries={queries}
|
||||
<MultiInputRows
|
||||
id="manageCurationQueries"
|
||||
initialValues={queries}
|
||||
addRowText={QUERY_INPUTS_BUTTON}
|
||||
inputPlaceholder={QUERY_INPUTS_PLACEHOLDER}
|
||||
submitButtonText={SAVE_BUTTON_LABEL}
|
||||
onSubmit={(newQueries) => {
|
||||
updateQueries(newQueries);
|
||||
|
|
|
@ -11,7 +11,7 @@ import React from 'react';
|
|||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { CurationQueries } from '../components';
|
||||
import { MultiInputRows } from '../../multi_input_rows';
|
||||
|
||||
import { CurationCreation } from './curation_creation';
|
||||
|
||||
|
@ -28,12 +28,12 @@ describe('CurationCreation', () => {
|
|||
it('renders', () => {
|
||||
const wrapper = shallow(<CurationCreation />);
|
||||
|
||||
expect(wrapper.find(CurationQueries)).toHaveLength(1);
|
||||
expect(wrapper.find(MultiInputRows)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('calls createCuration on CurationQueries submit', () => {
|
||||
it('calls createCuration on submit', () => {
|
||||
const wrapper = shallow(<CurationCreation />);
|
||||
wrapper.find(CurationQueries).simulate('submit', ['some query']);
|
||||
wrapper.find(MultiInputRows).simulate('submit', ['some query']);
|
||||
|
||||
expect(actions.createCuration).toHaveBeenCalledWith(['some query']);
|
||||
});
|
||||
|
|
|
@ -13,9 +13,13 @@ import { EuiPageHeader, EuiPageContent, EuiTitle, EuiText, EuiSpacer } from '@el
|
|||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { FlashMessages } from '../../../../shared/flash_messages';
|
||||
import { MultiInputRows } from '../../multi_input_rows';
|
||||
|
||||
import { CurationQueries } from '../components';
|
||||
import { CREATE_NEW_CURATION_TITLE } from '../constants';
|
||||
import {
|
||||
CREATE_NEW_CURATION_TITLE,
|
||||
QUERY_INPUTS_BUTTON,
|
||||
QUERY_INPUTS_PLACEHOLDER,
|
||||
} from '../constants';
|
||||
import { CurationsLogic } from '../index';
|
||||
|
||||
export const CurationCreation: React.FC = () => {
|
||||
|
@ -46,7 +50,12 @@ export const CurationCreation: React.FC = () => {
|
|||
</p>
|
||||
</EuiText>
|
||||
<EuiSpacer />
|
||||
<CurationQueries queries={['']} onSubmit={(queries) => createCuration(queries)} />
|
||||
<MultiInputRows
|
||||
id="createNewCuration"
|
||||
addRowText={QUERY_INPUTS_BUTTON}
|
||||
inputPlaceholder={QUERY_INPUTS_PLACEHOLDER}
|
||||
onSubmit={(queries) => createCuration(queries)}
|
||||
/>
|
||||
</EuiPageContent>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
export const ADD_VALUE_BUTTON_LABEL = i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.multiInputRows.addValueButtonLabel',
|
||||
{ defaultMessage: 'Add value' }
|
||||
);
|
||||
|
||||
export const DELETE_VALUE_BUTTON_LABEL = i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.multiInputRows.removeValueButtonLabel',
|
||||
{ defaultMessage: 'Remove value' }
|
||||
);
|
||||
|
||||
export const INPUT_ROW_PLACEHOLDER = i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.multiInputRows.inputRowPlaceholder',
|
||||
{ defaultMessage: 'Enter a value' }
|
||||
);
|
|
@ -5,4 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { CurationQueries } from './curation_queries';
|
||||
export { MultiInputRows } from './multi_input_rows';
|
|
@ -1,3 +1,3 @@
|
|||
.curationQueryRow {
|
||||
.inputRow {
|
||||
margin-bottom: $euiSizeXS;
|
||||
}
|
|
@ -11,14 +11,16 @@ import { shallow } from 'enzyme';
|
|||
|
||||
import { EuiFieldText } from '@elastic/eui';
|
||||
|
||||
import { CurationQuery } from './curation_query';
|
||||
import { InputRow } from './input_row';
|
||||
|
||||
describe('CurationQuery', () => {
|
||||
describe('InputRow', () => {
|
||||
const props = {
|
||||
queryValue: 'some query',
|
||||
value: 'some value',
|
||||
placeholder: 'Enter a value',
|
||||
onChange: jest.fn(),
|
||||
onDelete: jest.fn(),
|
||||
disableDelete: false,
|
||||
deleteLabel: 'Delete value',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -26,29 +28,33 @@ describe('CurationQuery', () => {
|
|||
});
|
||||
|
||||
it('renders', () => {
|
||||
const wrapper = shallow(<CurationQuery {...props} />);
|
||||
const wrapper = shallow(<InputRow {...props} />);
|
||||
|
||||
expect(wrapper.find(EuiFieldText)).toHaveLength(1);
|
||||
expect(wrapper.find(EuiFieldText).prop('value')).toEqual('some query');
|
||||
expect(wrapper.find(EuiFieldText).prop('value')).toEqual('some value');
|
||||
expect(wrapper.find(EuiFieldText).prop('placeholder')).toEqual('Enter a value');
|
||||
expect(wrapper.find('[data-test-subj="deleteInputRowButton"]').prop('title')).toEqual(
|
||||
'Delete value'
|
||||
);
|
||||
});
|
||||
|
||||
it('calls onChange when the input value changes', () => {
|
||||
const wrapper = shallow(<CurationQuery {...props} />);
|
||||
wrapper.find(EuiFieldText).simulate('change', { target: { value: 'new query value' } });
|
||||
const wrapper = shallow(<InputRow {...props} />);
|
||||
wrapper.find(EuiFieldText).simulate('change', { target: { value: 'new value' } });
|
||||
|
||||
expect(props.onChange).toHaveBeenCalledWith('new query value');
|
||||
expect(props.onChange).toHaveBeenCalledWith('new value');
|
||||
});
|
||||
|
||||
it('calls onDelete when the delete button is clicked', () => {
|
||||
const wrapper = shallow(<CurationQuery {...props} />);
|
||||
wrapper.find('[data-test-subj="deleteCurationQueryButton"]').simulate('click');
|
||||
const wrapper = shallow(<InputRow {...props} />);
|
||||
wrapper.find('[data-test-subj="deleteInputRowButton"]').simulate('click');
|
||||
|
||||
expect(props.onDelete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables the delete button if disableDelete is passed', () => {
|
||||
const wrapper = shallow(<CurationQuery {...props} disableDelete />);
|
||||
const button = wrapper.find('[data-test-subj="deleteCurationQueryButton"]');
|
||||
const wrapper = shallow(<InputRow {...props} disableDelete />);
|
||||
const button = wrapper.find('[data-test-subj="deleteInputRowButton"]');
|
||||
|
||||
expect(button.prop('isDisabled')).toEqual(true);
|
||||
});
|
|
@ -8,33 +8,34 @@
|
|||
import React from 'react';
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiFieldText, EuiButtonIcon } from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { DELETE_BUTTON_LABEL } from '../../../../../shared/constants';
|
||||
|
||||
interface Props {
|
||||
queryValue: string;
|
||||
value: string;
|
||||
placeholder: string;
|
||||
onChange(newValue: string): void;
|
||||
onDelete(): void;
|
||||
disableDelete: boolean;
|
||||
deleteLabel: string;
|
||||
}
|
||||
|
||||
export const CurationQuery: React.FC<Props> = ({
|
||||
queryValue,
|
||||
import './input_row.scss';
|
||||
|
||||
export const InputRow: React.FC<Props> = ({
|
||||
value,
|
||||
placeholder,
|
||||
onChange,
|
||||
onDelete,
|
||||
disableDelete,
|
||||
deleteLabel,
|
||||
}) => (
|
||||
<EuiFlexGroup className="curationQueryRow" alignItems="center" responsive={false} gutterSize="s">
|
||||
<EuiFlexGroup className="inputRow" alignItems="center" responsive={false} gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<EuiFieldText
|
||||
fullWidth
|
||||
placeholder={i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.engine.curations.queryPlaceholder',
|
||||
{ defaultMessage: 'Enter a query' }
|
||||
)}
|
||||
value={queryValue}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
|
@ -43,8 +44,9 @@ export const CurationQuery: React.FC<Props> = ({
|
|||
color="danger"
|
||||
onClick={onDelete}
|
||||
isDisabled={disableDelete}
|
||||
aria-label={DELETE_BUTTON_LABEL}
|
||||
data-test-subj="deleteCurationQueryButton"
|
||||
aria-label={deleteLabel}
|
||||
title={deleteLabel}
|
||||
data-test-subj="deleteInputRowButton"
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
|
@ -0,0 +1,133 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { setMockActions, setMockValues, rerender } from '../../../__mocks__';
|
||||
import '../../../__mocks__/shallow_useeffect.mock';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { InputRow } from './input_row';
|
||||
|
||||
jest.mock('./multi_input_rows_logic', () => ({
|
||||
MultiInputRowsLogic: jest.fn(),
|
||||
}));
|
||||
import { MultiInputRowsLogic } from './multi_input_rows_logic';
|
||||
|
||||
import { MultiInputRows } from './';
|
||||
|
||||
describe('MultiInputRows', () => {
|
||||
const props = {
|
||||
id: 'test',
|
||||
};
|
||||
const values = {
|
||||
values: ['a', 'b', 'c'],
|
||||
hasEmptyValues: false,
|
||||
hasOnlyOneValue: false,
|
||||
};
|
||||
const actions = {
|
||||
addValue: jest.fn(),
|
||||
editValue: jest.fn(),
|
||||
deleteValue: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
setMockValues(values);
|
||||
setMockActions(actions);
|
||||
});
|
||||
|
||||
it('initializes MultiInputRowsLogic with a keyed ID and initialValues', () => {
|
||||
shallow(<MultiInputRows id="lorem" initialValues={['ipsum']} />);
|
||||
expect(MultiInputRowsLogic).toHaveBeenCalledWith({ id: 'lorem', values: ['ipsum'] });
|
||||
});
|
||||
|
||||
it('renders a InputRow row for each value', () => {
|
||||
const wrapper = shallow(<MultiInputRows {...props} />);
|
||||
|
||||
expect(wrapper.find(InputRow)).toHaveLength(3);
|
||||
expect(wrapper.find(InputRow).at(0).prop('value')).toEqual('a');
|
||||
expect(wrapper.find(InputRow).at(1).prop('value')).toEqual('b');
|
||||
expect(wrapper.find(InputRow).at(2).prop('value')).toEqual('c');
|
||||
});
|
||||
|
||||
it('calls editValue when the InputRow value changes', () => {
|
||||
const wrapper = shallow(<MultiInputRows {...props} />);
|
||||
wrapper.find(InputRow).at(0).simulate('change', 'new value');
|
||||
|
||||
expect(actions.editValue).toHaveBeenCalledWith(0, 'new value');
|
||||
});
|
||||
|
||||
it('calls deleteValue when the InputRow calls onDelete', () => {
|
||||
const wrapper = shallow(<MultiInputRows {...props} />);
|
||||
wrapper.find(InputRow).at(2).simulate('delete');
|
||||
|
||||
expect(actions.deleteValue).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('calls addValue when the Add Value button is clicked', () => {
|
||||
const wrapper = shallow(<MultiInputRows {...props} />);
|
||||
wrapper.find('[data-test-subj="addInputRowButton"]').simulate('click');
|
||||
|
||||
expect(actions.addValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables the add button if any value fields are empty', () => {
|
||||
setMockValues({
|
||||
...values,
|
||||
values: ['a', '', 'c'],
|
||||
hasEmptyValues: true,
|
||||
});
|
||||
const wrapper = shallow(<MultiInputRows {...props} />);
|
||||
const button = wrapper.find('[data-test-subj="addInputRowButton"]');
|
||||
|
||||
expect(button.prop('isDisabled')).toEqual(true);
|
||||
});
|
||||
|
||||
describe('onSubmit', () => {
|
||||
const onSubmit = jest.fn();
|
||||
|
||||
it('does not render the submit button if onSubmit is not passed', () => {
|
||||
const wrapper = shallow(<MultiInputRows {...props} />);
|
||||
expect(wrapper.find('[data-test-subj="submitInputValuesButton"]').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('calls the passed onSubmit callback when the submit button is clicked', () => {
|
||||
setMockValues({ ...values, values: ['some value'] });
|
||||
const wrapper = shallow(<MultiInputRows {...props} onSubmit={onSubmit} />);
|
||||
wrapper.find('[data-test-subj="submitInputValuesButton"]').simulate('click');
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(['some value']);
|
||||
});
|
||||
|
||||
it('disables the submit button if no value fields have been filled', () => {
|
||||
setMockValues({
|
||||
...values,
|
||||
values: [''],
|
||||
hasOnlyOneValue: true,
|
||||
hasEmptyValues: true,
|
||||
});
|
||||
const wrapper = shallow(<MultiInputRows {...props} onSubmit={onSubmit} />);
|
||||
const button = wrapper.find('[data-test-subj="submitInputValuesButton"]');
|
||||
|
||||
expect(button.prop('isDisabled')).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
it('returns the current values dynamically on change', () => {
|
||||
const wrapper = shallow(<MultiInputRows {...props} onChange={onChange} />);
|
||||
setMockValues({ ...values, values: ['updated'] });
|
||||
rerender(wrapper);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(['updated']);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { useValues, useActions } from 'kea';
|
||||
|
||||
import { EuiButton, EuiButtonEmpty, EuiSpacer } from '@elastic/eui';
|
||||
|
||||
import { CONTINUE_BUTTON_LABEL } from '../../../shared/constants';
|
||||
|
||||
import {
|
||||
ADD_VALUE_BUTTON_LABEL,
|
||||
DELETE_VALUE_BUTTON_LABEL,
|
||||
INPUT_ROW_PLACEHOLDER,
|
||||
} from './constants';
|
||||
import { InputRow } from './input_row';
|
||||
import { MultiInputRowsLogic } from './multi_input_rows_logic';
|
||||
import { filterEmptyValues } from './utils';
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
initialValues?: string[];
|
||||
onSubmit?(values: string[]): void;
|
||||
onChange?(values: string[]): void;
|
||||
submitButtonText?: string;
|
||||
addRowText?: string;
|
||||
deleteRowLabel?: string;
|
||||
inputPlaceholder?: string;
|
||||
}
|
||||
|
||||
export const MultiInputRows: React.FC<Props> = ({
|
||||
id,
|
||||
initialValues = [''],
|
||||
onSubmit,
|
||||
onChange,
|
||||
submitButtonText = CONTINUE_BUTTON_LABEL,
|
||||
addRowText = ADD_VALUE_BUTTON_LABEL,
|
||||
deleteRowLabel = DELETE_VALUE_BUTTON_LABEL,
|
||||
inputPlaceholder = INPUT_ROW_PLACEHOLDER,
|
||||
}) => {
|
||||
const logic = MultiInputRowsLogic({ id, values: initialValues });
|
||||
const { values, hasEmptyValues, hasOnlyOneValue } = useValues(logic);
|
||||
const { addValue, editValue, deleteValue } = useActions(logic);
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
onChange(filterEmptyValues(values));
|
||||
}
|
||||
}, [values]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{values.map((value: string, index: number) => (
|
||||
<InputRow
|
||||
key={`inputRow${index}`}
|
||||
value={value}
|
||||
placeholder={inputPlaceholder}
|
||||
onChange={(newValue) => editValue(index, newValue)}
|
||||
onDelete={() => deleteValue(index)}
|
||||
disableDelete={hasOnlyOneValue}
|
||||
deleteLabel={deleteRowLabel}
|
||||
/>
|
||||
))}
|
||||
<EuiButtonEmpty
|
||||
size="s"
|
||||
iconType="plusInCircle"
|
||||
onClick={addValue}
|
||||
isDisabled={hasEmptyValues}
|
||||
data-test-subj="addInputRowButton"
|
||||
>
|
||||
{addRowText}
|
||||
</EuiButtonEmpty>
|
||||
{onSubmit && (
|
||||
<>
|
||||
<EuiSpacer />
|
||||
<EuiButton
|
||||
fill
|
||||
isDisabled={hasOnlyOneValue && hasEmptyValues}
|
||||
onClick={() => onSubmit(filterEmptyValues(values))}
|
||||
data-test-subj="submitInputValuesButton"
|
||||
>
|
||||
{submitButtonText}
|
||||
</EuiButton>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,102 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { LogicMounter } from '../../../__mocks__';
|
||||
|
||||
import { Logic } from 'kea';
|
||||
|
||||
import { MultiInputRowsLogic } from './multi_input_rows_logic';
|
||||
|
||||
describe('MultiInputRowsLogic', () => {
|
||||
const { mount } = new LogicMounter(MultiInputRowsLogic);
|
||||
|
||||
const MOCK_VALUES = ['a', 'b', 'c'];
|
||||
|
||||
const DEFAULT_PROPS = {
|
||||
id: 'test',
|
||||
values: MOCK_VALUES,
|
||||
};
|
||||
const DEFAULT_VALUES = {
|
||||
values: MOCK_VALUES,
|
||||
hasEmptyValues: false,
|
||||
hasOnlyOneValue: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('has expected default values passed from props', () => {
|
||||
const logic = mount({}, DEFAULT_PROPS);
|
||||
expect(logic.values).toEqual(DEFAULT_VALUES);
|
||||
});
|
||||
|
||||
describe('actions', () => {
|
||||
let logic: Logic;
|
||||
|
||||
beforeEach(() => {
|
||||
logic = mount({}, DEFAULT_PROPS);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Should not mutate the original array
|
||||
expect(logic.values.values).not.toBe(MOCK_VALUES); // Would fail if we did not clone a new array
|
||||
});
|
||||
|
||||
describe('addValue', () => {
|
||||
it('appends an empty string to the values array', () => {
|
||||
logic.actions.addValue();
|
||||
|
||||
expect(logic.values).toEqual({
|
||||
...DEFAULT_VALUES,
|
||||
hasEmptyValues: true,
|
||||
values: ['a', 'b', 'c', ''],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteValue', () => {
|
||||
it('deletes the value at the specified array index', () => {
|
||||
logic.actions.deleteValue(1);
|
||||
|
||||
expect(logic.values).toEqual({
|
||||
...DEFAULT_VALUES,
|
||||
values: ['a', 'c'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('editValue', () => {
|
||||
it('edits the value at the specified array index', () => {
|
||||
logic.actions.editValue(2, 'z');
|
||||
|
||||
expect(logic.values).toEqual({
|
||||
...DEFAULT_VALUES,
|
||||
values: ['a', 'b', 'z'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectors', () => {
|
||||
describe('hasEmptyValues', () => {
|
||||
it('returns true if values has any empty strings', () => {
|
||||
const logic = mount({}, { ...DEFAULT_PROPS, values: ['', '', ''] });
|
||||
|
||||
expect(logic.values.hasEmptyValues).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasOnlyOneValue', () => {
|
||||
it('returns true if values only has one item', () => {
|
||||
const logic = mount({}, { ...DEFAULT_PROPS, values: ['test'] });
|
||||
|
||||
expect(logic.values.hasOnlyOneValue).toEqual(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { kea, MakeLogicType } from 'kea';
|
||||
|
||||
interface MultiInputRowsValues {
|
||||
values: string[];
|
||||
hasEmptyValues: boolean;
|
||||
hasOnlyOneValue: boolean;
|
||||
}
|
||||
|
||||
interface MultiInputRowsActions {
|
||||
addValue(): void;
|
||||
deleteValue(indexToDelete: number): { indexToDelete: number };
|
||||
editValue(index: number, newValueValue: string): { index: number; newValueValue: string };
|
||||
}
|
||||
|
||||
interface MultiInputRowsProps {
|
||||
values: string[];
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const MultiInputRowsLogic = kea<
|
||||
MakeLogicType<MultiInputRowsValues, MultiInputRowsActions, MultiInputRowsProps>
|
||||
>({
|
||||
path: (key: string) => ['enterprise_search', 'app_search', 'multi_input_rows_logic', key],
|
||||
key: (props) => props.id,
|
||||
actions: () => ({
|
||||
addValue: true,
|
||||
deleteValue: (indexToDelete) => ({ indexToDelete }),
|
||||
editValue: (index, newValueValue) => ({ index, newValueValue }),
|
||||
}),
|
||||
reducers: ({ props }) => ({
|
||||
values: [
|
||||
props.values,
|
||||
{
|
||||
addValue: (state) => [...state, ''],
|
||||
deleteValue: (state, { indexToDelete }) => {
|
||||
const newState = [...state];
|
||||
newState.splice(indexToDelete, 1);
|
||||
return newState;
|
||||
},
|
||||
editValue: (state, { index, newValueValue }) => {
|
||||
const newState = [...state];
|
||||
newState[index] = newValueValue;
|
||||
return newState;
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
selectors: {
|
||||
hasEmptyValues: [(selectors) => [selectors.values], (values) => values.indexOf('') >= 0],
|
||||
hasOnlyOneValue: [(selectors) => [selectors.values], (values) => values.length <= 1],
|
||||
},
|
||||
});
|
|
@ -0,0 +1,15 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0; you may not use this file except in compliance with the Elastic License
|
||||
* 2.0.
|
||||
*/
|
||||
|
||||
import { filterEmptyValues } from './utils';
|
||||
|
||||
describe('filterEmptyValues', () => {
|
||||
it('filters out all empty strings from the array', () => {
|
||||
const values = ['', 'a', '', 'b', '', 'c', ''];
|
||||
expect(filterEmptyValues(values)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
});
|
|
@ -5,6 +5,6 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export const filterEmptyQueries = (queries: string[]) => {
|
||||
return queries.filter((query) => query.length);
|
||||
export const filterEmptyValues = (values: string[]) => {
|
||||
return values.filter((value) => value.length);
|
||||
};
|
|
@ -9,9 +9,9 @@ import { setMockActions } from '../../../../../__mocks__/kea.mock';
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
import { shallow } from 'enzyme';
|
||||
|
||||
import { EuiButton, EuiButtonIcon, EuiFieldText } from '@elastic/eui';
|
||||
import { MultiInputRows } from '../../../multi_input_rows';
|
||||
|
||||
import { ValueBoost, BoostType } from '../../types';
|
||||
|
||||
|
@ -23,13 +23,11 @@ describe('ValueBoostForm', () => {
|
|||
function: undefined,
|
||||
factor: 2,
|
||||
type: 'value' as BoostType,
|
||||
value: ['bar', '', 'baz'],
|
||||
value: [],
|
||||
};
|
||||
|
||||
const actions = {
|
||||
removeBoostValue: jest.fn(),
|
||||
updateBoostValue: jest.fn(),
|
||||
addBoostValue: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -37,40 +35,15 @@ describe('ValueBoostForm', () => {
|
|||
setMockActions(actions);
|
||||
});
|
||||
|
||||
const valueInput = (wrapper: ShallowWrapper, index: number) =>
|
||||
wrapper.find(EuiFieldText).at(index);
|
||||
const removeButton = (wrapper: ShallowWrapper, index: number) =>
|
||||
wrapper.find(EuiButtonIcon).at(index);
|
||||
const addButton = (wrapper: ShallowWrapper) => wrapper.find(EuiButton);
|
||||
|
||||
it('renders a text input for each value from the boost', () => {
|
||||
it('renders', () => {
|
||||
const wrapper = shallow(<ValueBoostForm boost={boost} index={3} name="foo" />);
|
||||
expect(valueInput(wrapper, 0).prop('value')).toEqual('bar');
|
||||
expect(valueInput(wrapper, 1).prop('value')).toEqual('');
|
||||
expect(valueInput(wrapper, 2).prop('value')).toEqual('baz');
|
||||
expect(wrapper.find(MultiInputRows).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('updates the corresponding value in state whenever a user changes the value in a text input', () => {
|
||||
it('updates the boost value whenever the MultiInputRows form component updates', () => {
|
||||
const wrapper = shallow(<ValueBoostForm boost={boost} index={3} name="foo" />);
|
||||
wrapper.find(MultiInputRows).simulate('change', ['bar', 'baz']);
|
||||
|
||||
valueInput(wrapper, 2).simulate('change', { target: { value: 'new value' } });
|
||||
|
||||
expect(actions.updateBoostValue).toHaveBeenCalledWith('foo', 3, 2, 'new value');
|
||||
});
|
||||
|
||||
it('deletes a boost value when the Remove Value button is clicked', () => {
|
||||
const wrapper = shallow(<ValueBoostForm boost={boost} index={3} name="foo" />);
|
||||
|
||||
removeButton(wrapper, 2).simulate('click');
|
||||
|
||||
expect(actions.removeBoostValue).toHaveBeenCalledWith('foo', 3, 2);
|
||||
});
|
||||
|
||||
it('adds a new boost value when the Add Value is button clicked', () => {
|
||||
const wrapper = shallow(<ValueBoostForm boost={boost} index={3} name="foo" />);
|
||||
|
||||
addButton(wrapper).simulate('click');
|
||||
|
||||
expect(actions.addBoostValue).toHaveBeenCalledWith('foo', 3);
|
||||
expect(actions.updateBoostValue).toHaveBeenCalledWith('foo', 3, ['bar', 'baz']);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -9,17 +9,9 @@ import React from 'react';
|
|||
|
||||
import { useActions } from 'kea';
|
||||
|
||||
import {
|
||||
EuiButton,
|
||||
EuiButtonIcon,
|
||||
EuiFieldText,
|
||||
EuiFlexGroup,
|
||||
EuiFlexItem,
|
||||
EuiSpacer,
|
||||
} from '@elastic/eui';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { MultiInputRows } from '../../../multi_input_rows';
|
||||
|
||||
import { RelevanceTuningLogic } from '../..';
|
||||
import { RelevanceTuningLogic } from '../../index';
|
||||
import { ValueBoost } from '../../types';
|
||||
|
||||
interface Props {
|
||||
|
@ -29,51 +21,14 @@ interface Props {
|
|||
}
|
||||
|
||||
export const ValueBoostForm: React.FC<Props> = ({ boost, index, name }) => {
|
||||
const { updateBoostValue, removeBoostValue, addBoostValue } = useActions(RelevanceTuningLogic);
|
||||
const { updateBoostValue } = useActions(RelevanceTuningLogic);
|
||||
const values = boost.value;
|
||||
|
||||
return (
|
||||
<>
|
||||
{values.map((value, valueIndex) => (
|
||||
<EuiFlexGroup key={valueIndex} alignItems="center" gutterSize="s" responsive={false}>
|
||||
<EuiFlexItem>
|
||||
<EuiFieldText
|
||||
value={value}
|
||||
fullWidth
|
||||
onChange={(e) => updateBoostValue(name, index, valueIndex, e.target.value)}
|
||||
aria-label={i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.value.valueNameAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Value name',
|
||||
}
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiButtonIcon
|
||||
iconType="trash"
|
||||
color="danger"
|
||||
onClick={() => removeBoostValue(name, index, valueIndex)}
|
||||
aria-label={i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.value.removeValueAriaLabel',
|
||||
{
|
||||
defaultMessage: 'Remove value',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
))}
|
||||
<EuiSpacer size="s" />
|
||||
<EuiButton size="s" onClick={() => addBoostValue(name, index)}>
|
||||
{i18n.translate(
|
||||
'xpack.enterpriseSearch.appSearch.engine.relevanceTuning.boosts.value.addValueButtonLabel',
|
||||
{
|
||||
defaultMessage: 'Add value',
|
||||
}
|
||||
)}
|
||||
</EuiButton>
|
||||
</>
|
||||
<MultiInputRows
|
||||
initialValues={values}
|
||||
onChange={(updatedValues) => updateBoostValue(name, index, updatedValues)}
|
||||
id={`${name}BoostValue-${index}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -9,14 +9,14 @@ import { setMockValues, setMockActions } from '../../../../__mocks__/kea.mock';
|
|||
|
||||
import React from 'react';
|
||||
|
||||
import { shallow, mount, ReactWrapper, ShallowWrapper } from 'enzyme';
|
||||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
|
||||
import { EuiFieldSearch } from '@elastic/eui';
|
||||
|
||||
import { BoostType } from '../types';
|
||||
|
||||
import { RelevanceTuningForm } from './relevance_tuning_form';
|
||||
import { RelevanceTuningItem } from './relevance_tuning_item';
|
||||
import { RelevanceTuningItemContent } from './relevance_tuning_item_content';
|
||||
|
||||
describe('RelevanceTuningForm', () => {
|
||||
const values = {
|
||||
|
@ -55,14 +55,14 @@ describe('RelevanceTuningForm', () => {
|
|||
});
|
||||
|
||||
describe('fields', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
let wrapper: ShallowWrapper;
|
||||
let relevantTuningItems: any;
|
||||
|
||||
beforeAll(() => {
|
||||
setMockValues(values);
|
||||
|
||||
wrapper = mount(<RelevanceTuningForm />);
|
||||
relevantTuningItems = wrapper.find(RelevanceTuningItem);
|
||||
wrapper = shallow(<RelevanceTuningForm />);
|
||||
relevantTuningItems = wrapper.find(RelevanceTuningItemContent);
|
||||
});
|
||||
|
||||
it('renders a list of fields that may or may not have been filterd by user input', () => {
|
||||
|
@ -112,7 +112,7 @@ describe('RelevanceTuningForm', () => {
|
|||
filteredSchemaFieldsWithConflicts: ['fe', 'fi', 'fo'],
|
||||
});
|
||||
|
||||
const wrapper = mount(<RelevanceTuningForm />);
|
||||
const wrapper = shallow(<RelevanceTuningForm />);
|
||||
expect(wrapper.find('[data-test-subj="DisabledFieldsSection"]').exists()).toBe(true);
|
||||
expect(wrapper.find('[data-test-subj="DisabledField"]').map((f) => f.text())).toEqual([
|
||||
'fe',
|
||||
|
|
|
@ -891,7 +891,7 @@ describe('RelevanceTuningLogic', () => {
|
|||
});
|
||||
|
||||
describe('updateBoostValue', () => {
|
||||
it('will update the boost value and update search reuslts', () => {
|
||||
it('will update the boost value and update search results', () => {
|
||||
mount({
|
||||
searchSettings: searchSettingsWithBoost({
|
||||
factor: 1,
|
||||
|
@ -901,33 +901,13 @@ describe('RelevanceTuningLogic', () => {
|
|||
});
|
||||
jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings');
|
||||
|
||||
RelevanceTuningLogic.actions.updateBoostValue('foo', 1, 1, 'a');
|
||||
RelevanceTuningLogic.actions.updateBoostValue('foo', 1, ['x', 'y', 'z']);
|
||||
|
||||
expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith(
|
||||
searchSettingsWithBoost({
|
||||
factor: 1,
|
||||
type: BoostType.Functional,
|
||||
value: ['a', 'a', 'c'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('will create a new array if no array exists yet for value', () => {
|
||||
mount({
|
||||
searchSettings: searchSettingsWithBoost({
|
||||
factor: 1,
|
||||
type: BoostType.Functional,
|
||||
}),
|
||||
});
|
||||
jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings');
|
||||
|
||||
RelevanceTuningLogic.actions.updateBoostValue('foo', 1, 0, 'a');
|
||||
|
||||
expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith(
|
||||
searchSettingsWithBoost({
|
||||
factor: 1,
|
||||
type: BoostType.Functional,
|
||||
value: ['a'],
|
||||
value: ['x', 'y', 'z'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
@ -959,107 +939,6 @@ describe('RelevanceTuningLogic', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('addBoostValue', () => {
|
||||
it('will add an empty boost value', () => {
|
||||
mount({
|
||||
searchSettings: searchSettingsWithBoost({
|
||||
factor: 1,
|
||||
type: BoostType.Functional,
|
||||
value: ['a'],
|
||||
}),
|
||||
});
|
||||
jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings');
|
||||
|
||||
RelevanceTuningLogic.actions.addBoostValue('foo', 1);
|
||||
|
||||
expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith(
|
||||
searchSettingsWithBoost({
|
||||
factor: 1,
|
||||
type: BoostType.Functional,
|
||||
value: ['a', ''],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('will add two empty boost values if none exist yet', () => {
|
||||
mount({
|
||||
searchSettings: searchSettingsWithBoost({
|
||||
factor: 1,
|
||||
type: BoostType.Functional,
|
||||
}),
|
||||
});
|
||||
jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings');
|
||||
|
||||
RelevanceTuningLogic.actions.addBoostValue('foo', 1);
|
||||
|
||||
expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith(
|
||||
searchSettingsWithBoost({
|
||||
factor: 1,
|
||||
type: BoostType.Functional,
|
||||
value: ['', ''],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('will still work if the boost index is out of range', () => {
|
||||
mount({
|
||||
searchSettings: searchSettingsWithBoost({
|
||||
factor: 1,
|
||||
type: BoostType.Functional,
|
||||
value: ['a', ''],
|
||||
}),
|
||||
});
|
||||
jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings');
|
||||
|
||||
RelevanceTuningLogic.actions.addBoostValue('foo', 10);
|
||||
|
||||
expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith(
|
||||
searchSettingsWithBoost({
|
||||
factor: 1,
|
||||
type: BoostType.Functional,
|
||||
value: ['a', ''],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeBoostValue', () => {
|
||||
it('will remove a boost value', () => {
|
||||
mount({
|
||||
searchSettings: searchSettingsWithBoost({
|
||||
factor: 1,
|
||||
type: BoostType.Functional,
|
||||
value: ['a', 'b', 'c'],
|
||||
}),
|
||||
});
|
||||
jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings');
|
||||
|
||||
RelevanceTuningLogic.actions.removeBoostValue('foo', 1, 1);
|
||||
|
||||
expect(RelevanceTuningLogic.actions.setSearchSettings).toHaveBeenCalledWith(
|
||||
searchSettingsWithBoost({
|
||||
factor: 1,
|
||||
type: BoostType.Functional,
|
||||
value: ['a', 'c'],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('will do nothing if boost values do not exist', () => {
|
||||
mount({
|
||||
searchSettings: searchSettingsWithBoost({
|
||||
factor: 1,
|
||||
type: BoostType.Functional,
|
||||
}),
|
||||
});
|
||||
jest.spyOn(RelevanceTuningLogic.actions, 'setSearchSettings');
|
||||
|
||||
RelevanceTuningLogic.actions.removeBoostValue('foo', 1, 1);
|
||||
|
||||
expect(RelevanceTuningLogic.actions.setSearchSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBoostSelectOption', () => {
|
||||
it('will update the boost', () => {
|
||||
mount({
|
||||
|
|
|
@ -69,20 +69,13 @@ interface RelevanceTuningActions {
|
|||
updateBoostValue(
|
||||
name: string,
|
||||
boostIndex: number,
|
||||
valueIndex: number,
|
||||
value: string
|
||||
): { name: string; boostIndex: number; valueIndex: number; value: string };
|
||||
updatedValues: string[]
|
||||
): { name: string; boostIndex: number; updatedValues: string[] };
|
||||
updateBoostCenter(
|
||||
name: string,
|
||||
boostIndex: number,
|
||||
value: string | number
|
||||
): { name: string; boostIndex: number; value: string | number };
|
||||
addBoostValue(name: string, boostIndex: number): { name: string; boostIndex: number };
|
||||
removeBoostValue(
|
||||
name: string,
|
||||
boostIndex: number,
|
||||
valueIndex: number
|
||||
): { name: string; boostIndex: number; valueIndex: number };
|
||||
updateBoostSelectOption(
|
||||
name: string,
|
||||
boostIndex: number,
|
||||
|
@ -141,15 +134,8 @@ export const RelevanceTuningLogic = kea<
|
|||
addBoost: (name, type) => ({ name, type }),
|
||||
deleteBoost: (name, index) => ({ name, index }),
|
||||
updateBoostFactor: (name, index, factor) => ({ name, index, factor }),
|
||||
updateBoostValue: (name, boostIndex, valueIndex, value) => ({
|
||||
name,
|
||||
boostIndex,
|
||||
valueIndex,
|
||||
value,
|
||||
}),
|
||||
updateBoostValue: (name, boostIndex, updatedValues) => ({ name, boostIndex, updatedValues }),
|
||||
updateBoostCenter: (name, boostIndex, value) => ({ name, boostIndex, value }),
|
||||
addBoostValue: (name, boostIndex) => ({ name, boostIndex }),
|
||||
removeBoostValue: (name, boostIndex, valueIndex) => ({ name, boostIndex, valueIndex }),
|
||||
updateBoostSelectOption: (name, boostIndex, optionType, value) => ({
|
||||
name,
|
||||
boostIndex,
|
||||
|
@ -430,16 +416,11 @@ export const RelevanceTuningLogic = kea<
|
|||
},
|
||||
});
|
||||
},
|
||||
updateBoostValue: ({ name, boostIndex, valueIndex, value }) => {
|
||||
updateBoostValue: ({ name, boostIndex, updatedValues }) => {
|
||||
const { searchSettings } = values;
|
||||
const { boosts } = searchSettings;
|
||||
const updatedBoosts: Boost[] = cloneDeep(boosts[name]);
|
||||
const existingValue = updatedBoosts[boostIndex].value;
|
||||
if (existingValue === undefined) {
|
||||
updatedBoosts[boostIndex].value = [value];
|
||||
} else {
|
||||
existingValue[valueIndex] = value;
|
||||
}
|
||||
updatedBoosts[boostIndex].value = updatedValues;
|
||||
|
||||
actions.setSearchSettings({
|
||||
...searchSettings,
|
||||
|
@ -464,41 +445,6 @@ export const RelevanceTuningLogic = kea<
|
|||
},
|
||||
});
|
||||
},
|
||||
addBoostValue: ({ name, boostIndex }) => {
|
||||
const { searchSettings } = values;
|
||||
const { boosts } = searchSettings;
|
||||
const updatedBoosts = cloneDeep(boosts[name]);
|
||||
const updatedBoost = updatedBoosts[boostIndex];
|
||||
if (updatedBoost) {
|
||||
updatedBoost.value = Array.isArray(updatedBoost.value) ? updatedBoost.value : [''];
|
||||
updatedBoost.value.push('');
|
||||
}
|
||||
|
||||
actions.setSearchSettings({
|
||||
...searchSettings,
|
||||
boosts: {
|
||||
...boosts,
|
||||
[name]: updatedBoosts,
|
||||
},
|
||||
});
|
||||
},
|
||||
removeBoostValue: ({ name, boostIndex, valueIndex }) => {
|
||||
const { searchSettings } = values;
|
||||
const { boosts } = searchSettings;
|
||||
const updatedBoosts = cloneDeep(boosts[name]);
|
||||
const boostValue = updatedBoosts[boostIndex].value;
|
||||
|
||||
if (boostValue === undefined) return;
|
||||
|
||||
boostValue.splice(valueIndex, 1);
|
||||
actions.setSearchSettings({
|
||||
...searchSettings,
|
||||
boosts: {
|
||||
...boosts,
|
||||
[name]: updatedBoosts,
|
||||
},
|
||||
});
|
||||
},
|
||||
updateBoostSelectOption: ({ name, boostIndex, optionType, value }) => {
|
||||
const { searchSettings } = values;
|
||||
const { boosts } = searchSettings;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue