diff --git a/examples/controls_example/public/app/react_control_example.tsx b/examples/controls_example/public/app/react_control_example.tsx index 82ae768898ba1..5dbf72cd02813 100644 --- a/examples/controls_example/public/app/react_control_example.tsx +++ b/examples/controls_example/public/app/react_control_example.tsx @@ -20,7 +20,11 @@ import { EuiSuperDatePicker, OnTimeChangeProps, } from '@elastic/eui'; -import { CONTROL_GROUP_TYPE } from '@kbn/controls-plugin/common'; +import { + CONTROL_GROUP_TYPE, + DEFAULT_CONTROL_GROW, + DEFAULT_CONTROL_WIDTH, +} from '@kbn/controls-plugin/common'; import { CoreStart } from '@kbn/core/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; import { ReactEmbeddableRenderer, ViewMode } from '@kbn/embeddable-plugin/public'; @@ -39,6 +43,7 @@ import { ControlGroupApi } from '../react_controls/control_group/types'; import { RANGE_SLIDER_CONTROL_TYPE } from '../react_controls/data_controls/range_slider/types'; import { SEARCH_CONTROL_TYPE } from '../react_controls/data_controls/search_control/types'; import { TIMESLIDER_CONTROL_TYPE } from '../react_controls/timeslider_control/types'; +import { openDataControlEditor } from '../react_controls/data_controls/open_data_control_editor'; const toggleViewButtons = [ { @@ -167,6 +172,7 @@ export const ReactControlExample = ({ addNewPanel: () => { return Promise.resolve(undefined); }, + lastUsedDataViewId: new BehaviorSubject(WEB_LOGS_DATA_VIEW_ID), }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -282,6 +288,35 @@ export const ReactControlExample = ({ Serialize control group + {controlGroupApi && ( + + { + openDataControlEditor({ + initialState: { + grow: DEFAULT_CONTROL_GROW, + width: DEFAULT_CONTROL_WIDTH, + dataViewId: dashboardApi.lastUsedDataViewId.getValue(), + }, + onSave: ({ type: controlType, state: initialState }) => { + controlGroupApi.addNewPanel({ + panelType: controlType, + initialState, + }); + }, + controlGroupApi, + services: { + core, + dataViews: dataViewsService, + }, + }); + }} + size="s" + > + Add new data control + + + )} + {api?.CustomPrependComponent ? ( ) : usingTwoLineLayout ? null : ( - - {panelTitle || defaultPanelTitle} - + + + {panelTitle || defaultPanelTitle} + + )} } diff --git a/examples/controls_example/public/react_controls/data_controls/data_control_editor.test.tsx b/examples/controls_example/public/react_controls/data_controls/data_control_editor.test.tsx new file mode 100644 index 0000000000000..d90e5349f36d8 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/data_control_editor.test.tsx @@ -0,0 +1,241 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; +import { BehaviorSubject } from 'rxjs'; + +import { createStubDataView } from '@kbn/data-views-plugin/common/data_view.stub'; +import { stubFieldSpecMap } from '@kbn/data-views-plugin/common/field.stub'; +import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { TimeRange } from '@kbn/es-query'; +import { I18nProvider } from '@kbn/i18n-react'; +import { act, fireEvent, render, RenderResult, waitFor } from '@testing-library/react'; + +import { getAllControlTypes, getControlFactory } from '../control_factory_registry'; +jest.mock('../control_factory_registry', () => ({ + ...jest.requireActual('../control_factory_registry'), + getAllControlTypes: jest.fn(), + getControlFactory: jest.fn(), +})); +import { DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '@kbn/controls-plugin/common'; +import { ControlGroupApi } from '../control_group/types'; +import { DataControlEditor } from './data_control_editor'; +import { DataControlEditorState } from './open_data_control_editor'; +import { + getMockedOptionsListControlFactory, + getMockedRangeSliderControlFactory, + getMockedSearchControlFactory, +} from './mocks/data_control_mocks'; +import { ControlFactory } from '../types'; +import { DataControlApi, DefaultDataControlState } from './types'; + +const mockDataViews = dataViewPluginMocks.createStartContract(); +const mockDataView = createStubDataView({ + spec: { + id: 'logstash-*', + fields: { + ...stubFieldSpecMap, + 'machine.os.raw': { + name: 'machine.os.raw', + customLabel: 'OS', + type: 'string', + esTypes: ['keyword'], + aggregatable: true, + searchable: true, + }, + }, + title: 'logstash-*', + timeFieldName: '@timestamp', + }, +}); +mockDataViews.get = jest.fn().mockResolvedValue(mockDataView); + +const dashboardApi = { + timeRange$: new BehaviorSubject(undefined), + lastUsedDataViewId$: new BehaviorSubject(mockDataView.id!), +}; +const controlGroupApi = { + parentApi: dashboardApi, + grow: new BehaviorSubject(DEFAULT_CONTROL_GROW), + width: new BehaviorSubject(DEFAULT_CONTROL_WIDTH), +} as unknown as ControlGroupApi; + +describe('Data control editor', () => { + const mountComponent = async ({ + initialState, + }: { + initialState?: Partial; + }) => { + mockDataViews.get = jest.fn().mockResolvedValue(mockDataView); + + const controlEditor = render( + + {}} + onSave={() => {}} + parentApi={controlGroupApi} + initialState={{ + dataViewId: dashboardApi.lastUsedDataViewId$.getValue(), + ...initialState, + }} + services={{ dataViews: mockDataViews }} + /> + + ); + + await waitFor(() => { + expect(mockDataViews.get).toHaveBeenCalledTimes(1); + }); + + return controlEditor; + }; + + const selectField = async (controlEditor: RenderResult, fieldName: string) => { + expect(controlEditor.queryByTestId(`field-picker-select-${fieldName}`)).toBeInTheDocument(); + await act(async () => { + fireEvent.click(controlEditor.getByTestId(`field-picker-select-${fieldName}`)); + }); + }; + + const getPressedAttribute = (controlEditor: RenderResult, testId: string) => { + return controlEditor.getByTestId(testId).getAttribute('aria-pressed'); + }; + + beforeAll(() => { + const mockRegistry: { [key: string]: ControlFactory } = + { + search: getMockedSearchControlFactory({ parentApi: controlGroupApi }), + optionsList: getMockedOptionsListControlFactory({ parentApi: controlGroupApi }), + rangeSlider: getMockedRangeSliderControlFactory({ parentApi: controlGroupApi }), + }; + (getAllControlTypes as jest.Mock).mockReturnValue(Object.keys(mockRegistry)); + (getControlFactory as jest.Mock).mockImplementation((key) => mockRegistry[key]); + }); + + describe('creating a new control', () => { + test('field list does not include fields that are not compatible with any control types', async () => { + const controlEditor = await mountComponent({}); + const nonAggOption = controlEditor.queryByTestId('field-picker-select-machine.os'); + expect(nonAggOption).not.toBeInTheDocument(); + }); + + test('cannot save before selecting a field', async () => { + const controlEditor = await mountComponent({}); + + const saveButton = controlEditor.getByTestId('control-editor-save'); + expect(saveButton).toBeDisabled(); + await selectField(controlEditor, 'machine.os.raw'); + expect(saveButton).toBeEnabled(); + }); + + test('selecting a keyword field - can only create an options list control', async () => { + const controlEditor = await mountComponent({}); + await selectField(controlEditor, 'machine.os.raw'); + + expect(controlEditor.getByTestId('create__optionsList')).toBeEnabled(); + expect(controlEditor.getByTestId('create__rangeSlider')).not.toBeEnabled(); + expect(controlEditor.getByTestId('create__search')).not.toBeEnabled(); + }); + + test('selecting an IP field - can only create an options list control', async () => { + const controlEditor = await mountComponent({}); + await selectField(controlEditor, 'clientip'); + + expect(controlEditor.getByTestId('create__optionsList')).toBeEnabled(); + expect(controlEditor.getByTestId('create__rangeSlider')).not.toBeEnabled(); + expect(controlEditor.getByTestId('create__search')).not.toBeEnabled(); + }); + + describe('selecting a number field', () => { + let controlEditor: RenderResult; + + beforeEach(async () => { + controlEditor = await mountComponent({}); + await selectField(controlEditor, 'bytes'); + }); + + test('can create an options list or range slider control', () => { + expect(controlEditor.getByTestId('create__optionsList')).toBeEnabled(); + expect(controlEditor.getByTestId('create__rangeSlider')).toBeEnabled(); + expect(controlEditor.getByTestId('create__search')).not.toBeEnabled(); + }); + + test('defaults to options list creation', () => { + expect(getPressedAttribute(controlEditor, 'create__optionsList')).toBe('true'); + expect(getPressedAttribute(controlEditor, 'create__rangeSlider')).toBe('false'); + }); + }); + + test('renders custom settings when provided', async () => { + const controlEditor = await mountComponent({}); + await selectField(controlEditor, 'machine.os.raw'); + expect(controlEditor.queryByTestId('optionsListCustomSettings')).toBeInTheDocument(); + }); + }); + + test('selects the default width and grow', async () => { + const controlEditor = await mountComponent({}); + + expect(getPressedAttribute(controlEditor, 'control-editor-width-small')).toBe('false'); + expect(getPressedAttribute(controlEditor, 'control-editor-width-medium')).toBe('true'); + expect(getPressedAttribute(controlEditor, 'control-editor-width-large')).toBe('false'); + expect( + controlEditor.getByTestId('control-editor-grow-switch').getAttribute('aria-checked') + ).toBe(`${DEFAULT_CONTROL_GROW}`); + }); + + describe('editing existing control', () => { + describe('control title', () => { + test('auto-fills input with the default title', async () => { + const controlEditor = await mountComponent({ + initialState: { + controlType: 'optionsList', + controlId: 'testId', + fieldName: 'machine.os.raw', + defaultPanelTitle: 'OS', + }, + }); + const titleInput = await controlEditor.findByTestId('control-editor-title-input'); + expect(titleInput.getAttribute('value')).toBe('OS'); + expect(titleInput.getAttribute('placeholder')).toBe('OS'); + }); + + test('auto-fills input with the custom title', async () => { + const controlEditor = await mountComponent({ + initialState: { + controlType: 'optionsList', + controlId: 'testId', + fieldName: 'machine.os.raw', + title: 'Custom title', + }, + }); + const titleInput = await controlEditor.findByTestId('control-editor-title-input'); + expect(titleInput.getAttribute('value')).toBe('Custom title'); + expect(titleInput.getAttribute('placeholder')).toBe('machine.os.raw'); + }); + }); + + test('selects the provided control type', async () => { + const controlEditor = await mountComponent({ + initialState: { + controlType: 'rangeSlider', + controlId: 'testId', + fieldName: 'bytes', + }, + }); + + expect(controlEditor.getByTestId('create__optionsList')).toBeEnabled(); + expect(controlEditor.getByTestId('create__rangeSlider')).toBeEnabled(); + expect(controlEditor.getByTestId('create__search')).not.toBeEnabled(); + + expect(getPressedAttribute(controlEditor, 'create__optionsList')).toBe('false'); + expect(getPressedAttribute(controlEditor, 'create__rangeSlider')).toBe('true'); + expect(getPressedAttribute(controlEditor, 'create__search')).toBe('false'); + }); + }); +}); diff --git a/examples/controls_example/public/react_controls/data_controls/data_control_editor.tsx b/examples/controls_example/public/react_controls/data_controls/data_control_editor.tsx index c35462b66ecb2..5e0079342a6df 100644 --- a/examples/controls_example/public/react_controls/data_controls/data_control_editor.tsx +++ b/examples/controls_example/public/react_controls/data_controls/data_control_editor.tsx @@ -48,20 +48,16 @@ import { import { getAllControlTypes, getControlFactory } from '../control_factory_registry'; import { ControlGroupApi } from '../control_group/types'; -import { ControlStateManager } from '../types'; import { DataControlEditorStrings } from './data_control_constants'; import { getDataControlFieldRegistry } from './data_control_editor_utils'; -import { DataControlFactory, DefaultDataControlState, isDataControlFactory } from './types'; +import { DataControlEditorState } from './open_data_control_editor'; +import { DataControlFactory, isDataControlFactory } from './types'; -export interface ControlEditorProps< - State extends DefaultDataControlState = DefaultDataControlState -> { - controlId?: string; // if provided, then editing existing control; otherwise, creating a new control - controlType?: string; - onCancel: () => void; - onSave: (type?: string) => void; - stateManager: ControlStateManager; +export interface ControlEditorProps { + initialState: State; parentApi: ControlGroupApi; // controls must always have a parent API + onCancel: (newState: State) => void; + onSave: (newState: State, type: string) => void; services: { dataViews: DataViewsPublicPluginStart; }; @@ -77,7 +73,7 @@ const CompatibleControlTypesComponent = ({ setSelectedControlType, }: { fieldRegistry?: DataControlFieldRegistry; - selectedFieldName: string; + selectedFieldName?: string; selectedControlType?: string; setSelectedControlType: (type: string) => void; }) => { @@ -129,38 +125,28 @@ const CompatibleControlTypesComponent = ({ ); }; -export const DataControlEditor = ({ - controlId, - controlType, +export const DataControlEditor = ({ + initialState, onSave, onCancel, - stateManager, parentApi: controlGroup, /** TODO: These should not be props */ services: { dataViews: dataViewService }, -}: ControlEditorProps) => { - const [ - selectedDataViewId, - selectedFieldName, - currentTitle, - selectedGrow, - selectedWidth, - defaultGrow, - defaultWidth, - ] = useBatchedPublishingSubjects( - stateManager.dataViewId, - stateManager.fieldName, - stateManager.title, - stateManager.grow, - stateManager.width, +}: ControlEditorProps) => { + const [defaultGrow, defaultWidth] = useBatchedPublishingSubjects( controlGroup.grow, controlGroup.width - // controlGroup.lastUsedDataViewId, // TODO: Implement last used data view id + // controlGroup.parentApi?.lastUsedDataViewId, // TODO: Make this work ); - - const [selectedFieldDisplayName, setSelectedFieldDisplayName] = useState(selectedFieldName); - const [selectedControlType, setSelectedControlType] = useState(controlType); - const [controlEditorValid, setControlEditorValid] = useState(false); + const [editorState, setEditorState] = useState(initialState); + const [defaultPanelTitle, setDefaultPanelTitle] = useState( + initialState.defaultPanelTitle ?? initialState.fieldName ?? '' + ); + const [panelTitle, setPanelTitle] = useState(initialState.title ?? defaultPanelTitle); + const [selectedControlType, setSelectedControlType] = useState( + initialState.controlType + ); + const [controlEditorValid, setControlEditorValid] = useState(false); /** TODO: Make `editorConfig` work when refactoring the `ControlGroupRenderer` */ // const editorConfig = controlGroup.getEditorConfig(); @@ -169,7 +155,7 @@ export const DataControlEditor = ({ loading: dataViewListLoading, value: dataViewListItems = [], error: dataViewListError, - } = useAsync(() => { + } = useAsync(async () => { return dataViewService.getIdsWithTitle(); }); @@ -182,25 +168,26 @@ export const DataControlEditor = ({ }, error: fieldListError, } = useAsync(async () => { - if (!selectedDataViewId) { + if (!editorState.dataViewId) { return; } - const dataView = await dataViewService.get(selectedDataViewId); + + const dataView = await dataViewService.get(editorState.dataViewId); const registry = await getDataControlFieldRegistry(dataView); return { selectedDataView: dataView, fieldRegistry: registry, }; - }, [selectedDataViewId]); + }, [editorState.dataViewId]); useEffect(() => { setControlEditorValid( - Boolean(selectedFieldName) && Boolean(selectedDataView) && Boolean(selectedControlType) + Boolean(editorState.fieldName) && Boolean(selectedDataView) && Boolean(selectedControlType) ); - }, [selectedFieldName, setControlEditorValid, selectedDataView, selectedControlType]); + }, [editorState.fieldName, setControlEditorValid, selectedDataView, selectedControlType]); const CustomSettingsComponent = useMemo(() => { - if (!selectedControlType || !selectedFieldName || !fieldRegistry) return; + if (!selectedControlType || !editorState.fieldName || !fieldRegistry) return; const controlFactory = getControlFactory(selectedControlType) as DataControlFactory; const CustomSettings = controlFactory.CustomOptionsComponent; @@ -222,17 +209,21 @@ export const DataControlEditor = ({ )} data-test-subj="control-editor-custom-settings" > - + setEditorState({ ...editorState, ...newState })} + setControlEditorValid={setControlEditorValid} + /> ); - }, [fieldRegistry, selectedControlType, selectedFieldName, stateManager]); + }, [fieldRegistry, selectedControlType, editorState, initialState]); return ( <>

- {!controlType + {!initialState.controlId // if no ID, then we are creating a new control ? DataControlEditorStrings.manageControl.getFlyoutCreateTitle() : DataControlEditorStrings.manageControl.getFlyoutEditTitle()}

@@ -260,9 +251,9 @@ export const DataControlEditor = ({ ) : ( { - stateManager.dataViewId.next(newDataViewId); + setEditorState({ ...editorState, dataViewId: newDataViewId }); setSelectedControlType(undefined); }} trigger={{ @@ -292,15 +283,17 @@ export const DataControlEditor = ({ // const customPredicate = controlGroup.fieldFilterPredicate?.(field) ?? true; return Boolean(fieldRegistry?.[field.name]); }} - selectedFieldName={selectedFieldName} + selectedFieldName={editorState.fieldName} dataView={selectedDataView} onSelectField={(field) => { + setEditorState({ ...editorState, fieldName: field.name }); setSelectedControlType(fieldRegistry?.[field.name]?.compatibleControlTypes[0]); + const newDefaultTitle = field.displayName ?? field.name; - stateManager.fieldName.next(field.name); - setSelectedFieldDisplayName(newDefaultTitle); - if (!currentTitle || currentTitle === selectedFieldDisplayName) { - stateManager.title.next(newDefaultTitle); + setDefaultPanelTitle(newDefaultTitle); + const currentTitle = editorState.title; + if (!currentTitle || currentTitle === newDefaultTitle) { + setPanelTitle(newDefaultTitle); } }} selectableProps={{ isLoading: dataViewListLoading || dataViewLoading }} @@ -314,7 +307,7 @@ export const DataControlEditor = ({
@@ -333,9 +326,15 @@ export const DataControlEditor = ({ > stateManager.title.next(e.target.value)} + placeholder={defaultPanelTitle} + value={panelTitle} + onChange={(e) => { + setPanelTitle(e.target.value ?? ''); + setEditorState({ + ...editorState, + title: e.target.value === '' ? undefined : e.target.value, + }); + }} /> {/* {!editorConfig?.hideWidthSettings && ( */} @@ -347,18 +346,20 @@ export const DataControlEditor = ({ color="primary" legend={DataControlEditorStrings.management.controlWidth.getWidthSwitchLegend()} options={CONTROL_WIDTH_OPTIONS} - idSelected={selectedWidth ?? defaultWidth ?? DEFAULT_CONTROL_WIDTH} - onChange={(newWidth: string) => stateManager.width.next(newWidth as ControlWidth)} + idSelected={editorState.width ?? defaultWidth ?? DEFAULT_CONTROL_WIDTH} + onChange={(newWidth: string) => + setEditorState({ ...editorState, width: newWidth as ControlWidth }) + } /> stateManager.grow.next(!selectedGrow)} + onChange={() => setEditorState({ ...editorState, grow: !editorState.grow })} data-test-subj="control-editor-grow-switch" />
@@ -367,17 +368,17 @@ export const DataControlEditor = ({ {CustomSettingsComponent} {/* {!editorConfig?.hideAdditionalSettings ? CustomSettingsComponent : null} */} - {controlId && ( + {initialState.controlId && ( <> { - onCancel(); - controlGroup.removePanel(controlId); + onCancel(initialState); // don't want to show "lost changes" warning + controlGroup.removePanel(initialState.controlId!); }} > {DataControlEditorStrings.manageControl.getDeleteButtonTitle()} @@ -390,11 +391,11 @@ export const DataControlEditor = ({ { - onCancel(); + onCancel(editorState); }} > {DataControlEditorStrings.manageControl.getCancelTitle()} @@ -402,13 +403,13 @@ export const DataControlEditor = ({ { - onSave(); + onSave(editorState, selectedControlType!); }} > {DataControlEditorStrings.manageControl.getSaveChangesTitle()} diff --git a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.tsx b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.tsx index d3a2b20fc0d02..2f5babd94951d 100644 --- a/examples/controls_example/public/react_controls/data_controls/initialize_data_control.tsx +++ b/examples/controls_example/public/react_controls/data_controls/initialize_data_control.tsx @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { isEqual } from 'lodash'; import { BehaviorSubject, combineLatest, switchMap } from 'rxjs'; import { CoreStart } from '@kbn/core-lifecycle-browser'; @@ -109,15 +110,46 @@ export const initializeDataControl = ( ); const onEdit = async () => { - openDataControlEditor( - { ...stateManager, ...editorStateManager } as ControlStateManager< - DefaultDataControlState & EditorState - >, - controlGroup, + // get the initial state from the state manager + const mergedStateManager = { + ...stateManager, + ...editorStateManager, + } as ControlStateManager; + const initialState = ( + Object.keys(mergedStateManager) as Array + ).reduce((prev, key) => { + return { + ...prev, + [key]: mergedStateManager[key]?.getValue(), + }; + }, {} as DefaultDataControlState & EditorState); + + // open the editor to get the new state + openDataControlEditor({ services, - controlType, - controlId - ); + onSave: ({ type: newType, state: newState }) => { + if (newType === controlType) { + // apply the changes from the new state via the state manager + (Object.keys(initialState) as Array).forEach( + (key) => { + if (!isEqual(mergedStateManager[key].getValue(), newState[key])) { + mergedStateManager[key].next(newState[key]); + } + } + ); + } else { + // replace the control with a new one of the updated type + controlGroup.replacePanel(controlId, { panelType: newType, initialState }); + } + }, + initialState: { + ...initialState, + controlType, + controlId, + defaultPanelTitle: defaultPanelTitle.getValue(), + }, + controlGroupApi: controlGroup, + }); }; const api: ControlApiInitialization = { diff --git a/examples/controls_example/public/react_controls/data_controls/mocks/data_control_mocks.tsx b/examples/controls_example/public/react_controls/data_controls/mocks/data_control_mocks.tsx new file mode 100644 index 0000000000000..dcb990a8d8419 --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/mocks/data_control_mocks.tsx @@ -0,0 +1,57 @@ +/* + * 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 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { DataControlFactory } from '../types'; + +export const getMockedSearchControlFactory = (api: any) => + ({ + type: 'search', + getIconType: () => 'searchControlIcon', + getDisplayName: () => 'Search', + isFieldCompatible: (field) => + field.aggregatable && + field.searchable && + field.spec.type === 'string' && + (field.spec.esTypes ?? []).includes('text'), + buildControl: jest.fn().mockReturnValue({ + api, + Component: <>Search control component, + }), + } as DataControlFactory); + +export const getMockedOptionsListControlFactory = (api: any) => + ({ + type: 'optionsList', + getIconType: () => 'optionsListIcon', + getDisplayName: () => 'Options list', + isFieldCompatible: (field) => + field.aggregatable && + !field.spec.scripted && + ['string', 'boolean', 'ip', 'date', 'number'].includes(field.type), + buildControl: jest.fn().mockReturnValue({ + api, + Component: <>Options list component, + }), + CustomOptionsComponent: () => ( +
Custom options list component
+ ), + } as DataControlFactory); + +export const getMockedRangeSliderControlFactory = (api: any) => + ({ + type: 'rangeSlider', + getIconType: () => 'rangeSliderIcon', + getDisplayName: () => 'Range slider', + isFieldCompatible: (field) => field.aggregatable && field.type === 'number', + buildControl: jest.fn().mockReturnValue({ + api, + Component: <>Range slider component, + }), + } as DataControlFactory); diff --git a/examples/controls_example/public/react_controls/data_controls/open_data_control_editor.tsx b/examples/controls_example/public/react_controls/data_controls/open_data_control_editor.tsx index 5c25ffcc7947d..3c16183b18434 100644 --- a/examples/controls_example/public/react_controls/data_controls/open_data_control_editor.tsx +++ b/examples/controls_example/public/react_controls/data_controls/open_data_control_editor.tsx @@ -8,7 +8,6 @@ import React from 'react'; import deepEqual from 'react-fast-compare'; -import { BehaviorSubject } from 'rxjs'; import { CoreStart, OverlayRef } from '@kbn/core/public'; import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public'; @@ -20,112 +19,92 @@ import { toMountPoint } from '@kbn/react-kibana-mount'; import { ControlGroupApi } from '../control_group/types'; import { DataControlEditor } from './data_control_editor'; import { DefaultDataControlState } from './types'; -import { ControlStateManager } from '../types'; -export const openDataControlEditor = async < - State extends DefaultDataControlState = DefaultDataControlState ->( - stateManager: ControlStateManager, - controlGroupApi: ControlGroupApi, +export type DataControlEditorState = Omit & { + fieldName?: string; + controlType?: string; + controlId?: string; + defaultPanelTitle?: string; +}; + +export const openDataControlEditor = < + State extends DataControlEditorState = DataControlEditorState +>({ + initialState, + onSave, + controlGroupApi, + services, +}: { + initialState: State; + onSave: ({ type, state }: { type: string; state: State }) => void; + controlGroupApi: ControlGroupApi; services: { core: CoreStart; dataViews: DataViewsPublicPluginStart; - }, - controlType?: string, - controlId?: string -): Promise => { - return new Promise((resolve) => { - /** - * Duplicate all state into a new manager because we do not want to actually apply the changes - * to the control until the user hits save. - */ - const editorStateManager: ControlStateManager = Object.keys(stateManager).reduce( - (prev, key) => { - return { - ...prev, - [key as keyof State]: new BehaviorSubject(stateManager[key as keyof State].getValue()), - }; - }, - {} as ControlStateManager - ); - - const closeOverlay = (overlayRef: OverlayRef) => { - if (apiHasParentApi(controlGroupApi) && tracksOverlays(controlGroupApi.parentApi)) { - controlGroupApi.parentApi.clearOverlays(); - } - overlayRef.close(); - }; - - const onCancel = (overlay: OverlayRef) => { - const initialState = Object.keys(stateManager).map((key) => { - return stateManager[key as keyof State].getValue(); - }); - const newState = Object.keys(editorStateManager).map((key) => { - return editorStateManager[key as keyof State].getValue(); - }); - - if (deepEqual(initialState, newState)) { - closeOverlay(overlay); - return; - } - services.core.overlays - .openConfirm( - i18n.translate('controls.controlGroup.management.discard.sub', { - defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`, - }), - { - confirmButtonText: i18n.translate('controls.controlGroup.management.discard.confirm', { - defaultMessage: 'Discard changes', - }), - cancelButtonText: i18n.translate('controls.controlGroup.management.discard.cancel', { - defaultMessage: 'Cancel', - }), - title: i18n.translate('controls.controlGroup.management.discard.title', { - defaultMessage: 'Discard changes?', - }), - buttonColor: 'danger', - } - ) - .then((confirmed) => { - if (confirmed) { - closeOverlay(overlay); - } - }); - }; + }; +}): void => { + const closeOverlay = (overlayRef: OverlayRef) => { + if (apiHasParentApi(controlGroupApi) && tracksOverlays(controlGroupApi.parentApi)) { + controlGroupApi.parentApi.clearOverlays(); + } + overlayRef.close(); + }; - const overlay = services.core.overlays.openFlyout( - toMountPoint( - { - onCancel(overlay); - }} - onSave={() => { - Object.keys(stateManager).forEach((key) => { - stateManager[key as keyof State].next( - editorStateManager[key as keyof State].getValue() - ); - }); - closeOverlay(overlay); - resolve(undefined); - }} - stateManager={editorStateManager} - services={{ dataViews: services.dataViews }} - />, + const onCancel = (newState: State, overlay: OverlayRef) => { + if (deepEqual(initialState, newState)) { + closeOverlay(overlay); + return; + } + services.core.overlays + .openConfirm( + i18n.translate('controls.controlGroup.management.discard.sub', { + defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`, + }), { - theme: services.core.theme, - i18n: services.core.i18n, + confirmButtonText: i18n.translate('controls.controlGroup.management.discard.confirm', { + defaultMessage: 'Discard changes', + }), + cancelButtonText: i18n.translate('controls.controlGroup.management.discard.cancel', { + defaultMessage: 'Cancel', + }), + title: i18n.translate('controls.controlGroup.management.discard.title', { + defaultMessage: 'Discard changes?', + }), + buttonColor: 'danger', + } + ) + .then((confirmed) => { + if (confirmed) { + closeOverlay(overlay); } - ), + }); + }; + + const overlay = services.core.overlays.openFlyout( + toMountPoint( + + parentApi={controlGroupApi} + initialState={initialState} + onCancel={(state) => { + onCancel(state, overlay); + }} + onSave={(state, selectedControlType) => { + closeOverlay(overlay); + onSave({ type: selectedControlType, state }); + }} + services={{ dataViews: services.dataViews }} + />, { - onClose: () => closeOverlay(overlay), + theme: services.core.theme, + i18n: services.core.i18n, } - ); - - if (apiHasParentApi(controlGroupApi) && tracksOverlays(controlGroupApi.parentApi)) { - controlGroupApi.parentApi.openOverlay(overlay); + ), + { + onClose: () => closeOverlay(overlay), } - }); + ); + + if (apiHasParentApi(controlGroupApi) && tracksOverlays(controlGroupApi.parentApi)) { + controlGroupApi.parentApi.openOverlay(overlay); + } }; diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/components/range_slider.scss b/examples/controls_example/public/react_controls/data_controls/range_slider/components/range_slider.scss index 344ba87dc6f73..5472ef81dd299 100644 --- a/examples/controls_example/public/react_controls/data_controls/range_slider/components/range_slider.scss +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/components/range_slider.scss @@ -22,8 +22,10 @@ } .rangeSliderAnchor__fieldNumber { + min-width: auto !important; font-weight: $euiFontWeightMedium; height: calc($euiButtonHeight - 3px) !important; + background-color: transparent !important; &.rangeSliderAnchor__fieldNumber--valid:invalid:not(:focus) { background-image: none; // override the red underline for values between steps diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/components/range_slider_control.tsx b/examples/controls_example/public/react_controls/data_controls/range_slider/components/range_slider_control.tsx index 74ac8c2f20a8e..f52dd0c3e528b 100644 --- a/examples/controls_example/public/react_controls/data_controls/range_slider/components/range_slider_control.tsx +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/components/range_slider_control.tsx @@ -24,6 +24,7 @@ interface Props { step: number | undefined; value: RangeValue | undefined; uuid: string; + controlPanelClassName?: string; } export const RangeSliderControl: FC = ({ @@ -36,7 +37,7 @@ export const RangeSliderControl: FC = ({ step, value, uuid, - ...rest + controlPanelClassName, }: Props) => { const rangeSliderRef = useRef(null); @@ -179,7 +180,7 @@ export const RangeSliderControl: FC = ({ max={displayedMax} isLoading={isLoading} inputPopoverProps={{ - ...rest, + className: controlPanelClassName, panelMinWidth: MIN_POPOVER_WIDTH, }} append={ diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx index 457796f1c028f..73aa593c0ce56 100644 --- a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.test.tsx @@ -7,20 +7,22 @@ */ import React from 'react'; -import { estypes } from '@elastic/elasticsearch'; -import { TimeRange } from '@kbn/es-query'; import { BehaviorSubject, first, of, skip } from 'rxjs'; -import { render, waitFor } from '@testing-library/react'; + +import { estypes } from '@elastic/elasticsearch'; import { coreMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; -import { ControlGroupApi } from '../../control_group/types'; -import { getRangesliderControlFactory } from './get_range_slider_control_factory'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; -import { ControlApiRegistration } from '../../types'; -import { RangesliderControlApi, RangesliderControlState } from './types'; -import { StateComparators } from '@kbn/presentation-publishing'; +import { TimeRange } from '@kbn/es-query'; import { SerializedPanelState } from '@kbn/presentation-containers'; +import { StateComparators } from '@kbn/presentation-publishing'; +import { fireEvent, render, waitFor } from '@testing-library/react'; + import { ControlFetchContext } from '../../control_group/control_fetch'; +import { ControlGroupApi } from '../../control_group/types'; +import { ControlApiRegistration } from '../../types'; +import { getRangesliderControlFactory } from './get_range_slider_control_factory'; +import { RangesliderControlApi, RangesliderControlState } from './types'; const DEFAULT_TOTAL_RESULTS = 20; const DEFAULT_MIN = 0; @@ -182,7 +184,7 @@ describe('RangesliderControlApi', () => { uuid, controlGroupApi ); - const { findByTestId } = render(); + const { findByTestId } = render(); await waitFor(async () => { await findByTestId('range-slider-control-invalid-append-myControl1'); }); @@ -200,7 +202,7 @@ describe('RangesliderControlApi', () => { uuid, controlGroupApi ); - const { findByTestId } = render(); + const { findByTestId } = render(); await waitFor(async () => { const minInput = await findByTestId('rangeSlider__lowerBoundFieldNumber'); expect(minInput).toHaveAttribute('placeholder', String(DEFAULT_MIN)); @@ -240,4 +242,53 @@ describe('RangesliderControlApi', () => { expect(serializedState.rawState.step).toBe(1024); }); }); + + describe('custom options component', () => { + test('defaults to step size of 1', async () => { + const CustomSettings = factory.CustomOptionsComponent!; + const component = render( + + ); + expect( + component.getByTestId('rangeSliderControl__stepAdditionalSetting').getAttribute('value') + ).toBe('1'); + }); + + test('validates step setting is greater than 0', async () => { + const setControlEditorValid = jest.fn(); + const CustomSettings = factory.CustomOptionsComponent!; + const component = render( + + ); + + fireEvent.change(component.getByTestId('rangeSliderControl__stepAdditionalSetting'), { + target: { valueAsNumber: -1 }, + }); + expect(setControlEditorValid).toBeCalledWith(false); + fireEvent.change(component.getByTestId('rangeSliderControl__stepAdditionalSetting'), { + target: { value: '' }, + }); + expect(setControlEditorValid).toBeCalledWith(false); + fireEvent.change(component.getByTestId('rangeSliderControl__stepAdditionalSetting'), { + target: { valueAsNumber: 0 }, + }); + expect(setControlEditorValid).toBeCalledWith(false); + fireEvent.change(component.getByTestId('rangeSliderControl__stepAdditionalSetting'), { + target: { valueAsNumber: 0.5 }, + }); + expect(setControlEditorValid).toBeCalledWith(true); + fireEvent.change(component.getByTestId('rangeSliderControl__stepAdditionalSetting'), { + target: { valueAsNumber: 10 }, + }); + expect(setControlEditorValid).toBeCalledWith(true); + }); + }); }); diff --git a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx index 602fc31721f5e..ed882873f73a7 100644 --- a/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx @@ -6,17 +6,20 @@ * Side Public License, v 1. */ -import React, { useEffect, useMemo } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import deepEqual from 'react-fast-compare'; -import { BehaviorSubject, combineLatest, distinctUntilChanged, map, skip } from 'rxjs'; + import { EuiFieldNumber, EuiFormRow } from '@elastic/eui'; -import { - useBatchedPublishingSubjects, - useStateFromPublishingSubject, -} from '@kbn/presentation-publishing'; import { buildRangeFilter, Filter, RangeFilterParams } from '@kbn/es-query'; +import { useBatchedPublishingSubjects } from '@kbn/presentation-publishing'; + +import { BehaviorSubject, combineLatest, distinctUntilChanged, map, skip } from 'rxjs'; import { initializeDataControl } from '../initialize_data_control'; import { DataControlFactory } from '../types'; +import { RangeSliderControl } from './components/range_slider_control'; +import { hasNoResults$ } from './has_no_results'; +import { minMax$ } from './min_max'; +import { RangeSliderStrings } from './range_slider_strings'; import { RangesliderControlApi, RangesliderControlState, @@ -24,10 +27,6 @@ import { RANGE_SLIDER_CONTROL_TYPE, Services, } from './types'; -import { RangeSliderStrings } from './range_slider_strings'; -import { RangeSliderControl } from './components/range_slider_control'; -import { minMax$ } from './min_max'; -import { hasNoResults$ } from './has_no_results'; export const getRangesliderControlFactory = ( services: Services @@ -39,8 +38,8 @@ export const getRangesliderControlFactory = ( isFieldCompatible: (field) => { return field.aggregatable && field.type === 'number'; }, - CustomOptionsComponent: ({ stateManager, setControlEditorValid }) => { - const step = useStateFromPublishingSubject(stateManager.step); + CustomOptionsComponent: ({ initialState, updateState, setControlEditorValid }) => { + const [step, setStep] = useState(initialState.step ?? 1); return ( <> @@ -49,7 +48,8 @@ export const getRangesliderControlFactory = ( value={step} onChange={(event) => { const newStep = event.target.valueAsNumber; - stateManager.step.next(newStep); + setStep(newStep); + updateState({ step: newStep }); setControlEditorValid(newStep > 0); }} min={0} @@ -210,7 +210,7 @@ export const getRangesliderControlFactory = ( return { api, - Component: (controlPanelClassNames) => { + Component: ({ className: controlPanelClassName }) => { const [dataLoading, dataViews, fieldName, max, min, selectionHasNotResults, step, value] = useBatchedPublishingSubjects( dataLoading$, @@ -246,7 +246,7 @@ export const getRangesliderControlFactory = ( return ( { - const searchTechnique = useStateFromPublishingSubject(stateManager.searchTechnique); + CustomOptionsComponent: ({ initialState, updateState }) => { + const [searchTechnique, setSearchTechnique] = useState(initialState.searchTechnique); return ( @@ -73,7 +75,8 @@ export const getSearchControlFactory = ({ idSelected={searchTechnique ?? DEFAULT_SEARCH_TECHNIQUE} onChange={(id) => { const newSearchTechnique = id as SearchControlTechniques; - stateManager.searchTechnique.next(newSearchTechnique); + setSearchTechnique(newSearchTechnique); + updateState({ searchTechnique: newSearchTechnique }); }} /> @@ -188,7 +191,7 @@ export const getSearchControlFactory = ({ * The `controlPanelClassNamess` prop is necessary because it contains the class names from the generic * ControlPanel that are necessary for styling */ - Component: (controlPanelClassNames) => { + Component: ({ className: controlPanelClassName }) => { const currentSearch = useStateFromPublishingSubject(searchString); useEffect(() => { @@ -202,7 +205,10 @@ export const getSearchControlFactory = ({ return ( & // control titles cannot be hidden @@ -35,7 +30,8 @@ export interface DataControlFactory< > extends ControlFactory { isFieldCompatible: (field: DataViewField) => boolean; CustomOptionsComponent?: React.FC<{ - stateManager: ControlStateManager; + initialState: Omit; + updateState: (newState: Partial) => void; setControlEditorValid: (valid: boolean) => void; }>; } diff --git a/examples/controls_example/public/react_controls/types.ts b/examples/controls_example/public/react_controls/types.ts index bbbd7584e2049..8d8c7f7a8e89a 100644 --- a/examples/controls_example/public/react_controls/types.ts +++ b/examples/controls_example/public/react_controls/types.ts @@ -85,7 +85,7 @@ export interface ControlFactory< ) => ControlApi, uuid: string, parentApi: ControlGroupApi - ) => { api: ControlApi; Component: React.FC<{}> }; + ) => { api: ControlApi; Component: React.FC<{ className: string }> }; } export type ControlStateManager = { diff --git a/examples/controls_example/tsconfig.json b/examples/controls_example/tsconfig.json index 76ff8d8a75f03..cc7905c5e5270 100644 --- a/examples/controls_example/tsconfig.json +++ b/examples/controls_example/tsconfig.json @@ -34,5 +34,6 @@ "@kbn/core-lifecycle-browser", "@kbn/presentation-panel-plugin", "@kbn/datemath", + "@kbn/ui-theme", ] }