From bf391c329566cf470b5cc1a7ba7743cb21c41103 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Thu, 4 Jul 2024 15:24:32 -0600 Subject: [PATCH 01/22] Remove use of state manager for editor + add create button --- .../public/app/react_control_example.tsx | 35 +++++- .../get_control_group_factory.tsx | 4 +- .../data_controls/data_control_editor.tsx | 105 +++++++++--------- .../data_controls/initialize_data_control.tsx | 34 ++++-- .../open_data_control_editor.tsx | 66 ++++------- .../get_range_slider_control_factory.tsx | 26 ++--- .../get_search_control_factory.tsx | 9 +- .../react_controls/data_controls/types.ts | 11 +- 8 files changed, 158 insertions(+), 132 deletions(-) diff --git a/examples/controls_example/public/app/react_control_example.tsx b/examples/controls_example/public/app/react_control_example.tsx index 391f41e0ac819..05dfd36808fce 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,33 @@ export const ReactControlExample = ({ Serialize control group + {controlGroupApi && ( + + { + const { controlType, initialState } = await openDataControlEditor({ + initialState: { + grow: DEFAULT_CONTROL_GROW, + width: DEFAULT_CONTROL_WIDTH, + dataViewId: dashboardApi.lastUsedDataViewId.getValue(), + }, + controlGroupApi, + services: { + core, + dataViews: dataViewsService, + }, + }); + controlGroupApi.addNewPanel({ + panelType: controlType, + initialState, + }); + }} + size="s" + > + Add new data control + + + )} { +export interface ControlEditorProps { controlId?: string; // if provided, then editing existing control; otherwise, creating a new control controlType?: string; - onCancel: () => void; - onSave: (type?: string) => void; - stateManager: ControlStateManager; + onCancel: (newState: State) => void; + onSave: (newState: State, type: string) => void; + initialState: State; parentApi: ControlGroupApi; // controls must always have a parent API services: { dataViews: DataViewsPublicPluginStart; @@ -77,7 +75,7 @@ const CompatibleControlTypesComponent = ({ setSelectedControlType, }: { fieldRegistry?: DataControlFieldRegistry; - selectedFieldName: string; + selectedFieldName?: string; selectedControlType?: string; setSelectedControlType: (type: string) => void; }) => { @@ -129,36 +127,23 @@ const CompatibleControlTypesComponent = ({ ); }; -export const DataControlEditor = ({ +export const DataControlEditor = ({ controlId, controlType, + 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 ); + const [editorState, setEditorState] = useState(initialState); - const [selectedFieldDisplayName, setSelectedFieldDisplayName] = useState(selectedFieldName); + const [selectedFieldDisplayName, setSelectedFieldDisplayName] = useState(initialState.fieldName); const [selectedControlType, setSelectedControlType] = useState(controlType); const [controlEditorValid, setControlEditorValid] = useState(false); /** TODO: Make `editorConfig` work when refactoring the `ControlGroupRenderer` */ @@ -182,25 +167,25 @@ 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,10 +207,14 @@ export const DataControlEditor = ({ )} data-test-subj="control-editor-custom-settings" > - + setEditorState({ ...editorState, ...newState })} + setControlEditorValid={setControlEditorValid} + /> ); - }, [fieldRegistry, selectedControlType, selectedFieldName, stateManager]); + }, [fieldRegistry, selectedControlType, editorState, initialState]); return ( <> @@ -260,9 +249,9 @@ export const DataControlEditor = ({ ) : ( { - stateManager.dataViewId.next(newDataViewId); + setEditorState({ ...editorState, dataViewId: newDataViewId }); setSelectedControlType(undefined); }} trigger={{ @@ -292,16 +281,20 @@ export const DataControlEditor = ({ // const customPredicate = controlGroup.fieldFilterPredicate?.(field) ?? true; return Boolean(fieldRegistry?.[field.name]); }} - selectedFieldName={selectedFieldName} + selectedFieldName={editorState.fieldName} dataView={selectedDataView} onSelectField={(field) => { + const newState = { fieldName: field.name } as Partial; setSelectedControlType(fieldRegistry?.[field.name]?.compatibleControlTypes[0]); + const newDefaultTitle = field.displayName ?? field.name; - stateManager.fieldName.next(field.name); setSelectedFieldDisplayName(newDefaultTitle); + const currentTitle = editorState.title; if (!currentTitle || currentTitle === selectedFieldDisplayName) { - stateManager.title.next(newDefaultTitle); + newState.title = newDefaultTitle; } + + setEditorState({ ...editorState, ...newState }); }} selectableProps={{ isLoading: dataViewListLoading || dataViewLoading }} /> @@ -314,7 +307,7 @@ export const DataControlEditor = ({
@@ -333,9 +326,9 @@ export const DataControlEditor = ({ > stateManager.title.next(e.target.value)} + placeholder={selectedFieldDisplayName ?? editorState.fieldName} + value={editorState.title} + onChange={(e) => setEditorState({ ...editorState, title: e.target.value })} /> {/* {!editorConfig?.hideWidthSettings && ( */} @@ -347,18 +340,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" />
@@ -371,12 +366,12 @@ export const DataControlEditor = ({ <> { - onCancel(); + onCancel(initialState); // don't want to show "lost changes" warning controlGroup.removePanel(controlId); }} > @@ -390,11 +385,11 @@ export const DataControlEditor = ({ { - onCancel(); + onCancel(editorState); }} > {DataControlEditorStrings.manageControl.getCancelTitle()} @@ -402,13 +397,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..21df377ae7985 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 @@ -109,14 +109,34 @@ 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 + const { initialState: newState } = await openDataControlEditor< + DefaultDataControlState & EditorState + >({ services, - controlType, - controlId + initialState: { controlId, controlType, ...initialState }, + controlGroupApi: controlGroup, + }); + + // apply the changes from the new state via the state manager + (Object.keys(initialState) as Array).forEach( + (key) => { + mergedStateManager[key].next(newState[key]); + } ); }; 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..2fe8b7cd19bb3 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,35 +19,28 @@ 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 type DataControlEditorState = Omit & { + fieldName?: string; +}; export const openDataControlEditor = async < - State extends DefaultDataControlState = DefaultDataControlState ->( - stateManager: ControlStateManager, - controlGroupApi: ControlGroupApi, + State extends DataControlEditorState = DataControlEditorState +>({ + initialState, + controlGroupApi, + services, +}: { + initialState: State & { controlType?: string; controlId?: string }; + 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 - ); + }; +}): Promise<{ controlType: string; initialState: State }> => { + const { controlType, controlId, ...controlState } = initialState; + return new Promise((resolve) => { const closeOverlay = (overlayRef: OverlayRef) => { if (apiHasParentApi(controlGroupApi) && tracksOverlays(controlGroupApi.parentApi)) { controlGroupApi.parentApi.clearOverlays(); @@ -56,14 +48,7 @@ export const openDataControlEditor = async < 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(); - }); - + const onCancel = (newState: State, overlay: OverlayRef) => { if (deepEqual(initialState, newState)) { closeOverlay(overlay); return; @@ -95,23 +80,18 @@ export const openDataControlEditor = async < const overlay = services.core.overlays.openFlyout( toMountPoint( - controlId={controlId} controlType={controlType} parentApi={controlGroupApi} - onCancel={() => { - onCancel(overlay); + onCancel={(state) => { + onCancel(state, overlay); }} - onSave={() => { - Object.keys(stateManager).forEach((key) => { - stateManager[key as keyof State].next( - editorStateManager[key as keyof State].getValue() - ); - }); + onSave={(state, selectedControlType) => { closeOverlay(overlay); - resolve(undefined); + resolve({ initialState: state, controlType: selectedControlType }); }} - stateManager={editorStateManager} + initialState={initialState} services={{ dataViews: services.dataViews }} />, { 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 79670226c6666..212b874babfdc 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} diff --git a/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx b/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx index dcb8161842ecd..708985f0003c0 100644 --- a/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx +++ b/examples/controls_example/public/react_controls/data_controls/search_control/get_search_control_factory.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import deepEqual from 'react-fast-compare'; import { BehaviorSubject, combineLatest, debounceTime, distinctUntilChanged } from 'rxjs'; @@ -63,8 +63,8 @@ export const getSearchControlFactory = ({ (field.spec.esTypes ?? []).includes('text') ); }, - CustomOptionsComponent: ({ stateManager }) => { - const searchTechnique = useStateFromPublishingSubject(stateManager.searchTechnique); + CustomOptionsComponent: ({ initialState, updateState }) => { + const [searchTechnique, setSearchTechnique] = useState(initialState.searchTechnique); return ( @@ -73,7 +73,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 }); }} /> diff --git a/examples/controls_example/public/react_controls/data_controls/types.ts b/examples/controls_example/public/react_controls/data_controls/types.ts index 24384fbc13584..a64ae11f048d9 100644 --- a/examples/controls_example/public/react_controls/data_controls/types.ts +++ b/examples/controls_example/public/react_controls/data_controls/types.ts @@ -6,6 +6,7 @@ * Side Public License, v 1. */ +import { CanClearSelections } from '@kbn/controls-plugin/public'; import { DataViewField } from '@kbn/data-views-plugin/common'; import { Filter } from '@kbn/es-query'; import { @@ -14,12 +15,7 @@ import { PublishesFilters, PublishesPanelTitle, } from '@kbn/presentation-publishing'; -import { - ControlFactory, - ControlStateManager, - DefaultControlApi, - DefaultControlState, -} from '../types'; +import { ControlFactory, DefaultControlApi, DefaultControlState } from '../types'; export type DataControlApi = DefaultControlApi & Omit & // control titles cannot be hidden @@ -35,7 +31,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; }>; } From 609a2f236e517a7224c93d6748448efd9b7ee23b Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Fri, 5 Jul 2024 08:54:59 -0600 Subject: [PATCH 02/22] Small style fix + add tooltip --- .../public/react_controls/control_panel.scss | 16 ++++++++------ .../public/react_controls/control_panel.tsx | 22 +++++++++++++++---- .../range_slider/components/range_slider.scss | 1 + 3 files changed, 28 insertions(+), 11 deletions(-) diff --git a/examples/controls_example/public/react_controls/control_panel.scss b/examples/controls_example/public/react_controls/control_panel.scss index bd347ac124d4d..43c27f3b4c037 100644 --- a/examples/controls_example/public/react_controls/control_panel.scss +++ b/examples/controls_example/public/react_controls/control_panel.scss @@ -10,14 +10,16 @@ border-radius: $euiBorderRadius !important; } - &--label { - @include euiTextTruncate; + &--labelWrapper { max-width: 40%; - background-color: transparent; - border-radius: $euiBorderRadius; - - margin-left: 0 !important; - padding-left: 0 !important; + + .controlPanel--label { + @include euiTextTruncate; + background-color: transparent; + border-radius: $euiBorderRadius; + margin-left: 0 !important; + padding-left: 0 !important; + } } &--hideComponent { diff --git a/examples/controls_example/public/react_controls/control_panel.tsx b/examples/controls_example/public/react_controls/control_panel.tsx index a427b3ed1801a..2cf46b20299ca 100644 --- a/examples/controls_example/public/react_controls/control_panel.tsx +++ b/examples/controls_example/public/react_controls/control_panel.tsx @@ -9,7 +9,15 @@ import classNames from 'classnames'; import React, { useState } from 'react'; -import { EuiFlexItem, EuiFormControlLayout, EuiFormLabel, EuiFormRow, EuiIcon } from '@elastic/eui'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiFormControlLayout, + EuiFormLabel, + EuiFormRow, + EuiIcon, + EuiToolTip, +} from '@elastic/eui'; import { ViewMode } from '@kbn/embeddable-plugin/public'; import { i18n } from '@kbn/i18n'; import { @@ -135,12 +143,18 @@ export const ControlPanel = + {api?.CustomPrependComponent ? ( ) : usingTwoLineLayout ? null : ( - - {panelTitle || defaultPanelTitle} - + + + {panelTitle || defaultPanelTitle} + + )} } 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..84c2ebc396550 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,6 +22,7 @@ } .rangeSliderAnchor__fieldNumber { + min-width: auto !important; font-weight: $euiFontWeightMedium; height: calc($euiButtonHeight - 3px) !important; From d00bc16fbc5ba093093f021346685a73e1db47a6 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Fri, 5 Jul 2024 15:13:16 -0600 Subject: [PATCH 03/22] Fix some small bugs with editor --- .../data_controls/data_control_editor.tsx | 55 +++++++++++-------- .../data_controls/initialize_data_control.tsx | 7 ++- .../open_data_control_editor.tsx | 14 ++--- .../react_controls/data_controls/types.ts | 1 - 4 files changed, 44 insertions(+), 33 deletions(-) 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 b1bbf9cd0ddb0..0523f1f9ef53f 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 @@ -50,16 +50,15 @@ import { getAllControlTypes, getControlFactory } from '../control_factory_regist import { ControlGroupApi } from '../control_group/types'; import { DataControlEditorStrings } from './data_control_constants'; import { getDataControlFieldRegistry } from './data_control_editor_utils'; -import { DataControlEditorState } from './open_data_control_editor'; +import { DataControlEditorApi, DataControlEditorState } from './open_data_control_editor'; import { DataControlFactory, isDataControlFactory } from './types'; export interface ControlEditorProps { - controlId?: string; // if provided, then editing existing control; otherwise, creating a new control - controlType?: string; - onCancel: (newState: State) => void; - onSave: (newState: State, type: string) => void; initialState: State; parentApi: ControlGroupApi; // controls must always have a parent API + controlApi?: DataControlEditorApi; + onCancel: (newState: State) => void; + onSave: (newState: State, type: string) => void; services: { dataViews: DataViewsPublicPluginStart; }; @@ -128,12 +127,11 @@ const CompatibleControlTypesComponent = ({ }; export const DataControlEditor = ({ - controlId, - controlType, initialState, onSave, onCancel, parentApi: controlGroup, + controlApi, /** TODO: These should not be props */ services: { dataViews: dataViewService }, }: ControlEditorProps) => { @@ -142,10 +140,14 @@ export const DataControlEditor = (initialState); - - const [selectedFieldDisplayName, setSelectedFieldDisplayName] = useState(initialState.fieldName); - const [selectedControlType, setSelectedControlType] = useState(controlType); - const [controlEditorValid, setControlEditorValid] = useState(false); + const [defaultPanelTitle, setDefaultPanelTitle] = useState( + controlApi?.defaultPanelTitle?.getValue() ?? initialState.fieldName ?? '' + ); + const [panelTitle, setPanelTitle] = useState(initialState.title ?? defaultPanelTitle); + const [selectedControlType, setSelectedControlType] = useState( + controlApi?.type + ); + const [controlEditorValid, setControlEditorValid] = useState(false); /** TODO: Make `editorConfig` work when refactoring the `ControlGroupRenderer` */ // const editorConfig = controlGroup.getEditorConfig(); @@ -154,7 +156,7 @@ export const DataControlEditor = { + } = useAsync(async () => { return dataViewService.getIdsWithTitle(); }); @@ -170,6 +172,7 @@ export const DataControlEditor =

- {!controlType + {!controlApi ? DataControlEditorStrings.manageControl.getFlyoutCreateTitle() : DataControlEditorStrings.manageControl.getFlyoutEditTitle()}

@@ -284,17 +287,15 @@ export const DataControlEditor = { - const newState = { fieldName: field.name } as Partial; + setEditorState({ ...editorState, fieldName: field.name }); setSelectedControlType(fieldRegistry?.[field.name]?.compatibleControlTypes[0]); const newDefaultTitle = field.displayName ?? field.name; - setSelectedFieldDisplayName(newDefaultTitle); + setDefaultPanelTitle(newDefaultTitle); const currentTitle = editorState.title; - if (!currentTitle || currentTitle === selectedFieldDisplayName) { - newState.title = newDefaultTitle; + if (!currentTitle || currentTitle === newDefaultTitle) { + setPanelTitle(newDefaultTitle); } - - setEditorState({ ...editorState, ...newState }); }} selectableProps={{ isLoading: dataViewListLoading || dataViewLoading }} /> @@ -326,9 +327,15 @@ export const DataControlEditor = setEditorState({ ...editorState, title: 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 && ( */} @@ -362,7 +369,7 @@ export const DataControlEditor = {CustomSettingsComponent} {/* {!editorConfig?.hideAdditionalSettings ? CustomSettingsComponent : null} */} - {controlId && ( + {controlApi?.uuid && ( <> { onCancel(initialState); // don't want to show "lost changes" warning - controlGroup.removePanel(controlId); + controlGroup.removePanel(controlApi.uuid); }} > {DataControlEditorStrings.manageControl.getDeleteButtonTitle()} 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 21df377ae7985..37d35f7ceb3b9 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 @@ -128,7 +128,12 @@ export const initializeDataControl = ( DefaultDataControlState & EditorState >({ services, - initialState: { controlId, controlType, ...initialState }, + initialState, + controlApi: { + type: controlType, + uuid: controlId, + defaultPanelTitle, + }, controlGroupApi: controlGroup, }); 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 2fe8b7cd19bb3..201b7d95e5f24 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 @@ -18,28 +18,29 @@ import { toMountPoint } from '@kbn/react-kibana-mount'; import { ControlGroupApi } from '../control_group/types'; import { DataControlEditor } from './data_control_editor'; -import { DefaultDataControlState } from './types'; +import { DataControlApi, DefaultDataControlState } from './types'; export type DataControlEditorState = Omit & { fieldName?: string; }; +export type DataControlEditorApi = Pick; export const openDataControlEditor = async < State extends DataControlEditorState = DataControlEditorState >({ initialState, + controlApi, controlGroupApi, services, }: { - initialState: State & { controlType?: string; controlId?: string }; + initialState: State; + controlApi?: DataControlEditorApi; controlGroupApi: ControlGroupApi; services: { core: CoreStart; dataViews: DataViewsPublicPluginStart; }; }): Promise<{ controlType: string; initialState: State }> => { - const { controlType, controlId, ...controlState } = initialState; - return new Promise((resolve) => { const closeOverlay = (overlayRef: OverlayRef) => { if (apiHasParentApi(controlGroupApi) && tracksOverlays(controlGroupApi.parentApi)) { @@ -81,9 +82,9 @@ export const openDataControlEditor = async < const overlay = services.core.overlays.openFlyout( toMountPoint( - controlId={controlId} - controlType={controlType} + controlApi={controlApi} parentApi={controlGroupApi} + initialState={initialState} onCancel={(state) => { onCancel(state, overlay); }} @@ -91,7 +92,6 @@ export const openDataControlEditor = async < closeOverlay(overlay); resolve({ initialState: state, controlType: selectedControlType }); }} - initialState={initialState} services={{ dataViews: services.dataViews }} />, { diff --git a/examples/controls_example/public/react_controls/data_controls/types.ts b/examples/controls_example/public/react_controls/data_controls/types.ts index a64ae11f048d9..a4cf9cab9fc53 100644 --- a/examples/controls_example/public/react_controls/data_controls/types.ts +++ b/examples/controls_example/public/react_controls/data_controls/types.ts @@ -6,7 +6,6 @@ * Side Public License, v 1. */ -import { CanClearSelections } from '@kbn/controls-plugin/public'; import { DataViewField } from '@kbn/data-views-plugin/common'; import { Filter } from '@kbn/es-query'; import { From 2337d232aac46a90661f0755858ffaadf6415c06 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Fri, 5 Jul 2024 16:47:48 -0600 Subject: [PATCH 04/22] Clean up + add data editor tests --- .../data_control_editor.test.tsx | 233 ++++++++++++++++++ .../data_controls/data_control_editor.tsx | 15 +- .../data_controls/initialize_data_control.tsx | 29 ++- .../mocks/data_control_mocks.tsx | 57 +++++ .../open_data_control_editor.tsx | 9 +- 5 files changed, 318 insertions(+), 25 deletions(-) create mode 100644 examples/controls_example/public/react_controls/data_controls/data_control_editor.test.tsx create mode 100644 examples/controls_example/public/react_controls/data_controls/mocks/data_control_mocks.tsx 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..864390ea1821c --- /dev/null +++ b/examples/controls_example/public/react_controls/data_controls/data_control_editor.test.tsx @@ -0,0 +1,233 @@ +/* + * 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 { registerControlFactory } from '../control_factory_registry'; +import { ControlGroupApi } from '../control_group/types'; +import { DataControlEditor } from './data_control_editor'; +import { + getMockedOptionsListControlFactory, + getMockedRangeSliderControlFactory, + getMockedSearchControlFactory, +} from './mocks/data_control_mocks'; +import { DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '@kbn/controls-plugin/common'; +import { DataControlEditorState } from './open_data_control_editor'; + +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(() => { + /** Register all of my mocked data controls */ + registerControlFactory('search', async () => { + return getMockedSearchControlFactory({ parentApi: controlGroupApi }); + }); + registerControlFactory('optionsList', async () => { + return getMockedOptionsListControlFactory({ parentApi: controlGroupApi }); + }); + registerControlFactory('rangeSlider', async () => { + return getMockedRangeSliderControlFactory({ parentApi: controlGroupApi }); + }); + }); + + 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({}); + + await waitFor(() => { + expect(mockDataViews.getIdsWithTitle).toHaveBeenCalledTimes(1); + expect(mockDataViews.get).toHaveBeenCalledTimes(1); + }); + + const nonAggOption = controlEditor.queryByTestId('field-picker-select-machine.os'); + expect(nonAggOption).not.toBeInTheDocument(); + }); + + 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 0523f1f9ef53f..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 @@ -50,13 +50,12 @@ import { getAllControlTypes, getControlFactory } from '../control_factory_regist import { ControlGroupApi } from '../control_group/types'; import { DataControlEditorStrings } from './data_control_constants'; import { getDataControlFieldRegistry } from './data_control_editor_utils'; -import { DataControlEditorApi, DataControlEditorState } from './open_data_control_editor'; +import { DataControlEditorState } from './open_data_control_editor'; import { DataControlFactory, isDataControlFactory } from './types'; export interface ControlEditorProps { initialState: State; parentApi: ControlGroupApi; // controls must always have a parent API - controlApi?: DataControlEditorApi; onCancel: (newState: State) => void; onSave: (newState: State, type: string) => void; services: { @@ -131,21 +130,21 @@ export const DataControlEditor = ) => { const [defaultGrow, defaultWidth] = useBatchedPublishingSubjects( controlGroup.grow, controlGroup.width + // controlGroup.parentApi?.lastUsedDataViewId, // TODO: Make this work ); const [editorState, setEditorState] = useState(initialState); const [defaultPanelTitle, setDefaultPanelTitle] = useState( - controlApi?.defaultPanelTitle?.getValue() ?? initialState.fieldName ?? '' + initialState.defaultPanelTitle ?? initialState.fieldName ?? '' ); const [panelTitle, setPanelTitle] = useState(initialState.title ?? defaultPanelTitle); const [selectedControlType, setSelectedControlType] = useState( - controlApi?.type + initialState.controlType ); const [controlEditorValid, setControlEditorValid] = useState(false); /** TODO: Make `editorConfig` work when refactoring the `ControlGroupRenderer` */ @@ -224,7 +223,7 @@ export const DataControlEditor =

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

@@ -369,7 +368,7 @@ export const DataControlEditor = {CustomSettingsComponent} {/* {!editorConfig?.hideAdditionalSettings ? CustomSettingsComponent : null} */} - {controlApi?.uuid && ( + {initialState.controlId && ( <> { onCancel(initialState); // don't want to show "lost changes" warning - controlGroup.removePanel(controlApi.uuid); + controlGroup.removePanel(initialState.controlId!); }} > {DataControlEditorStrings.manageControl.getDeleteButtonTitle()} 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 37d35f7ceb3b9..3dcb455d6261c 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 @@ -124,25 +124,30 @@ export const initializeDataControl = ( }, {} as DefaultDataControlState & EditorState); // open the editor to get the new state - const { initialState: newState } = await openDataControlEditor< + const { initialState: newState, controlType: newType } = await openDataControlEditor< DefaultDataControlState & EditorState >({ services, - initialState, - controlApi: { - type: controlType, - uuid: controlId, - defaultPanelTitle, + initialState: { + ...initialState, + controlType, + controlId, + defaultPanelTitle: defaultPanelTitle.getValue(), }, controlGroupApi: controlGroup, }); - // apply the changes from the new state via the state manager - (Object.keys(initialState) as Array).forEach( - (key) => { - mergedStateManager[key].next(newState[key]); - } - ); + if (newType === controlType) { + // apply the changes from the new state via the state manager + (Object.keys(initialState) as Array).forEach( + (key) => { + mergedStateManager[key].next(newState[key]); + } + ); + } else { + // replae the control with a new one of the updated type + controlGroup.replacePanel(controlId, { panelType: newType, initialState }); + } }; 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 201b7d95e5f24..cbcb3c660b759 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 @@ -18,23 +18,23 @@ import { toMountPoint } from '@kbn/react-kibana-mount'; import { ControlGroupApi } from '../control_group/types'; import { DataControlEditor } from './data_control_editor'; -import { DataControlApi, DefaultDataControlState } from './types'; +import { DefaultDataControlState } from './types'; export type DataControlEditorState = Omit & { fieldName?: string; + controlType?: string; + controlId?: string; + defaultPanelTitle?: string; }; -export type DataControlEditorApi = Pick; export const openDataControlEditor = async < State extends DataControlEditorState = DataControlEditorState >({ initialState, - controlApi, controlGroupApi, services, }: { initialState: State; - controlApi?: DataControlEditorApi; controlGroupApi: ControlGroupApi; services: { core: CoreStart; @@ -82,7 +82,6 @@ export const openDataControlEditor = async < const overlay = services.core.overlays.openFlyout( toMountPoint( - controlApi={controlApi} parentApi={controlGroupApi} initialState={initialState} onCancel={(state) => { From 0d5a6614d78045dc3c57563115cd0d93be02a4ec Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Fri, 5 Jul 2024 17:03:12 -0600 Subject: [PATCH 05/22] Add range slider custom options test --- .../get_range_slider_control_factory.test.tsx | 63 ++++++++++++++++--- 1 file changed, 56 insertions(+), 7 deletions(-) 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 98ce7619dda99..9734ff424a9d3 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 @@ -6,19 +6,19 @@ * Side Public License, v 1. */ -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 { coreMock } from '@kbn/core/public/mocks'; import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; -import { ControlGroupApi, DataControlFetchContext } from '../../control_group/types'; -import { getRangesliderControlFactory } from './get_range_slider_control_factory'; import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; +import { TimeRange } from '@kbn/es-query'; +import { StateComparators } from '@kbn/presentation-publishing'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import React from 'react'; +import { BehaviorSubject, first, of, skip } from 'rxjs'; +import { ControlGroupApi, DataControlFetchContext } from '../../control_group/types'; import { ControlApiRegistration } from '../../types'; +import { getRangesliderControlFactory } from './get_range_slider_control_factory'; import { RangesliderControlApi, RangesliderControlState } from './types'; -import { StateComparators } from '@kbn/presentation-publishing'; const DEFAULT_TOTAL_RESULTS = 20; const DEFAULT_MIN = 0; @@ -207,4 +207,53 @@ describe('RangesliderControlApi', () => { }); }); }); + + 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: { valueAsNumber: undefined }, + }); + 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); + }); + }); }); From d699bd58714163a904ea767c255b08a93d51519a Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Fri, 5 Jul 2024 17:08:11 -0600 Subject: [PATCH 06/22] Fix tests --- .../data_controls/data_control_editor.test.tsx | 15 +++++++++------ .../get_range_slider_control_factory.test.tsx | 4 ++-- 2 files changed, 11 insertions(+), 8 deletions(-) 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 index 864390ea1821c..03eb48d9b5b1d 100644 --- 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 @@ -115,16 +115,19 @@ describe('Data control editor', () => { 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({}); - - await waitFor(() => { - expect(mockDataViews.getIdsWithTitle).toHaveBeenCalledTimes(1); - expect(mockDataViews.get).toHaveBeenCalledTimes(1); - }); - 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'); 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 9734ff424a9d3..04517760c3b73 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 @@ -220,7 +220,7 @@ describe('RangesliderControlApi', () => { ); expect( component.getByTestId('rangeSliderControl__stepAdditionalSetting').getAttribute('value') - ).toBe(1); + ).toBe('1'); }); test('validates step setting is greater than 0', async () => { @@ -239,7 +239,7 @@ describe('RangesliderControlApi', () => { }); expect(setControlEditorValid).toBeCalledWith(false); fireEvent.change(component.getByTestId('rangeSliderControl__stepAdditionalSetting'), { - target: { valueAsNumber: undefined }, + target: { value: '' }, }); expect(setControlEditorValid).toBeCalledWith(false); fireEvent.change(component.getByTestId('rangeSliderControl__stepAdditionalSetting'), { From 8c63479762392fe2fd4bec9ce56785c2814823e2 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 8 Jul 2024 09:23:07 -0600 Subject: [PATCH 07/22] Fix linting --- .../controls_example/public/react_controls/control_panel.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/controls_example/public/react_controls/control_panel.scss b/examples/controls_example/public/react_controls/control_panel.scss index 43c27f3b4c037..d116210a53e10 100644 --- a/examples/controls_example/public/react_controls/control_panel.scss +++ b/examples/controls_example/public/react_controls/control_panel.scss @@ -12,7 +12,7 @@ &--labelWrapper { max-width: 40%; - + .controlPanel--label { @include euiTextTruncate; background-color: transparent; From 6ab5cb771eedddb25b66d30e4ee9e2bdcd6382ba Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Mon, 8 Jul 2024 16:12:42 +0000 Subject: [PATCH 08/22] [CI] Auto-commit changed files from 'node scripts/eslint --no-cache --fix' --- .../controls_example/public/react_controls/control_panel.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/controls_example/public/react_controls/control_panel.tsx b/examples/controls_example/public/react_controls/control_panel.tsx index 2cf46b20299ca..95ff67e60b34c 100644 --- a/examples/controls_example/public/react_controls/control_panel.tsx +++ b/examples/controls_example/public/react_controls/control_panel.tsx @@ -10,7 +10,6 @@ import classNames from 'classnames'; import React, { useState } from 'react'; import { - EuiFlexGroup, EuiFlexItem, EuiFormControlLayout, EuiFormLabel, From 2600815509ae9cc7977a4b2443e91dc9836c32ba Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 8 Jul 2024 10:21:45 -0600 Subject: [PATCH 09/22] Small cleanup --- .../controls_example/public/app/react_control_example.tsx | 2 +- .../react_controls/data_controls/initialize_data_control.tsx | 2 +- .../react_controls/data_controls/open_data_control_editor.tsx | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/controls_example/public/app/react_control_example.tsx b/examples/controls_example/public/app/react_control_example.tsx index 05dfd36808fce..365f23109e7b9 100644 --- a/examples/controls_example/public/app/react_control_example.tsx +++ b/examples/controls_example/public/app/react_control_example.tsx @@ -292,7 +292,7 @@ export const ReactControlExample = ({ { - const { controlType, initialState } = await openDataControlEditor({ + const { type: controlType, state: initialState } = await openDataControlEditor({ initialState: { grow: DEFAULT_CONTROL_GROW, width: DEFAULT_CONTROL_WIDTH, 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 3dcb455d6261c..ef5c75c88a88e 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 @@ -124,7 +124,7 @@ export const initializeDataControl = ( }, {} as DefaultDataControlState & EditorState); // open the editor to get the new state - const { initialState: newState, controlType: newType } = await openDataControlEditor< + const { type: newType, state: newState } = await openDataControlEditor< DefaultDataControlState & EditorState >({ services, 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 cbcb3c660b759..e183ef3acd655 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 @@ -40,7 +40,7 @@ export const openDataControlEditor = async < core: CoreStart; dataViews: DataViewsPublicPluginStart; }; -}): Promise<{ controlType: string; initialState: State }> => { +}): Promise<{ type: string; state: State }> => { return new Promise((resolve) => { const closeOverlay = (overlayRef: OverlayRef) => { if (apiHasParentApi(controlGroupApi) && tracksOverlays(controlGroupApi.parentApi)) { @@ -89,7 +89,7 @@ export const openDataControlEditor = async < }} onSave={(state, selectedControlType) => { closeOverlay(overlay); - resolve({ initialState: state, controlType: selectedControlType }); + resolve({ state, controlType: selectedControlType }); }} services={{ dataViews: services.dataViews }} />, From 416a80d61d1528b41ec74fc4c9a52e1fb75870d7 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 8 Jul 2024 10:35:38 -0600 Subject: [PATCH 10/22] Remove unnecessary default --- .../range_slider/get_range_slider_control_factory.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a0a50d7a359b0..07d6d8662fed0 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 @@ -39,7 +39,7 @@ export const getRangesliderControlFactory = ( return field.aggregatable && field.type === 'number'; }, CustomOptionsComponent: ({ initialState, updateState, setControlEditorValid }) => { - const [step, setStep] = useState(initialState.step ?? 1); + const [step, setStep] = useState(initialState.step); return ( <> From 0eb12c10db431854ff6d6f3f1c7c0d41ad7e85b5 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 8 Jul 2024 10:38:30 -0600 Subject: [PATCH 11/22] Cleanup --- .../range_slider/get_range_slider_control_factory.test.tsx | 1 + .../range_slider/get_range_slider_control_factory.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) 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 77804f45047d1..ce2f3f9984c77 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 @@ -16,6 +16,7 @@ import { dataViewPluginMocks } from '@kbn/data-views-plugin/public/mocks'; import { TimeRange } from '@kbn/es-query'; import { StateComparators } from '@kbn/presentation-publishing'; import { fireEvent, render, waitFor } from '@testing-library/react'; +import { SerializedPanelState } from '@kbn/presentation-containers'; import { ControlGroupApi, DataControlFetchContext } from '../../control_group/types'; import { ControlApiRegistration } from '../../types'; 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 07d6d8662fed0..a0a50d7a359b0 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 @@ -39,7 +39,7 @@ export const getRangesliderControlFactory = ( return field.aggregatable && field.type === 'number'; }, CustomOptionsComponent: ({ initialState, updateState, setControlEditorValid }) => { - const [step, setStep] = useState(initialState.step); + const [step, setStep] = useState(initialState.step ?? 1); return ( <> From 0faa00f0ddbb6c0f345e2994dbd3f75e83c47659 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 8 Jul 2024 11:40:05 -0600 Subject: [PATCH 12/22] Fix missed rename --- .../react_controls/data_controls/open_data_control_editor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 e183ef3acd655..e20e1f5e36c38 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 @@ -89,7 +89,7 @@ export const openDataControlEditor = async < }} onSave={(state, selectedControlType) => { closeOverlay(overlay); - resolve({ state, controlType: selectedControlType }); + resolve({ type: selectedControlType, state }); }} services={{ dataViews: services.dataViews }} />, From dbd3578980d26f3df23a5502a893aeee31b42a32 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 8 Jul 2024 13:06:11 -0600 Subject: [PATCH 13/22] Fix typo in comment --- .../react_controls/data_controls/initialize_data_control.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ef5c75c88a88e..74cf65e0bc155 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 @@ -145,7 +145,7 @@ export const initializeDataControl = ( } ); } else { - // replae the control with a new one of the updated type + // replace the control with a new one of the updated type controlGroup.replacePanel(controlId, { panelType: newType, initialState }); } }; From 2ab084599f96ab62bc7be321552d1ecd5df4bb77 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 8 Jul 2024 14:00:00 -0600 Subject: [PATCH 14/22] Make `openDataControlEditor` synchronous --- .../public/app/react_control_example.tsx | 14 +- .../data_controls/initialize_data_control.tsx | 29 ++-- .../open_data_control_editor.tsx | 124 +++++++++--------- 3 files changed, 84 insertions(+), 83 deletions(-) diff --git a/examples/controls_example/public/app/react_control_example.tsx b/examples/controls_example/public/app/react_control_example.tsx index 365f23109e7b9..a9cb26bae58ff 100644 --- a/examples/controls_example/public/app/react_control_example.tsx +++ b/examples/controls_example/public/app/react_control_example.tsx @@ -291,23 +291,25 @@ export const ReactControlExample = ({ {controlGroupApi && ( { - const { type: controlType, state: initialState } = await openDataControlEditor({ + onClick={() => { + 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, }, }); - controlGroupApi.addNewPanel({ - panelType: controlType, - initialState, - }); }} size="s" > 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 74cf65e0bc155..fcbb52658fc1a 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 @@ -124,10 +124,21 @@ export const initializeDataControl = ( }, {} as DefaultDataControlState & EditorState); // open the editor to get the new state - const { type: newType, state: newState } = await openDataControlEditor< - DefaultDataControlState & EditorState - >({ + openDataControlEditor({ services, + 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) => { + 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, @@ -136,18 +147,6 @@ export const initializeDataControl = ( }, controlGroupApi: controlGroup, }); - - if (newType === controlType) { - // apply the changes from the new state via the state manager - (Object.keys(initialState) as Array).forEach( - (key) => { - mergedStateManager[key].next(newState[key]); - } - ); - } else { - // replace the control with a new one of the updated type - controlGroup.replacePanel(controlId, { panelType: newType, initialState }); - } }; const api: ControlApiInitialization = { 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 e20e1f5e36c38..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 @@ -27,84 +27,84 @@ export type DataControlEditorState = Omit defaultPanelTitle?: string; }; -export const openDataControlEditor = async < +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; }; -}): Promise<{ type: string; state: State }> => { - return new Promise((resolve) => { - const closeOverlay = (overlayRef: OverlayRef) => { - if (apiHasParentApi(controlGroupApi) && tracksOverlays(controlGroupApi.parentApi)) { - controlGroupApi.parentApi.clearOverlays(); - } - overlayRef.close(); - }; - - 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?`, - }), - { - 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( - - parentApi={controlGroupApi} - initialState={initialState} - onCancel={(state) => { - onCancel(state, overlay); - }} - onSave={(state, selectedControlType) => { - closeOverlay(overlay); - resolve({ type: selectedControlType, state }); - }} - 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); + } }; From 1ef8e39f72192f9ee754364b0d0d59b31e435995 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 8 Jul 2024 15:30:54 -0600 Subject: [PATCH 15/22] Mock registry functions --- .../data_control_editor.test.tsx | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) 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 index 03eb48d9b5b1d..fe3249b6f63de 100644 --- 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 @@ -16,16 +16,21 @@ import { TimeRange } from '@kbn/es-query'; import { I18nProvider } from '@kbn/i18n-react'; import { act, fireEvent, render, RenderResult, waitFor } from '@testing-library/react'; -import { registerControlFactory } from '../control_factory_registry'; +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 { DEFAULT_CONTROL_GROW, DEFAULT_CONTROL_WIDTH } from '@kbn/controls-plugin/common'; -import { DataControlEditorState } from './open_data_control_editor'; const mockDataViews = dataViewPluginMocks.createStartContract(); const mockDataView = createStubDataView({ @@ -52,7 +57,7 @@ const dashboardApi = { timeRange$: new BehaviorSubject(undefined), lastUsedDataViewId$: new BehaviorSubject(mockDataView.id!), }; -const controlGroupApi = { +const mockedControlGroupApi = { parentApi: dashboardApi, grow: new BehaviorSubject(DEFAULT_CONTROL_GROW), width: new BehaviorSubject(DEFAULT_CONTROL_WIDTH), @@ -71,7 +76,7 @@ describe('Data control editor', () => { {}} onSave={() => {}} - parentApi={controlGroupApi} + parentApi={mockedControlGroupApi} initialState={{ dataViewId: dashboardApi.lastUsedDataViewId$.getValue(), ...initialState, @@ -100,16 +105,13 @@ describe('Data control editor', () => { }; beforeAll(() => { - /** Register all of my mocked data controls */ - registerControlFactory('search', async () => { - return getMockedSearchControlFactory({ parentApi: controlGroupApi }); - }); - registerControlFactory('optionsList', async () => { - return getMockedOptionsListControlFactory({ parentApi: controlGroupApi }); - }); - registerControlFactory('rangeSlider', async () => { - return getMockedRangeSliderControlFactory({ parentApi: controlGroupApi }); - }); + const mockRegistry = { + search: getMockedSearchControlFactory({ parentApi: mockedControlGroupApi }), + optionsList: getMockedOptionsListControlFactory({ parentApi: mockedControlGroupApi }), + rangeSlider: getMockedRangeSliderControlFactory({ parentApi: mockedControlGroupApi }), + }; + (getAllControlTypes as jest.Mock).mockReturnValue(Object.keys(mockRegistry)); + (getControlFactory as jest.Mock).mockImplementation((key) => mockRegistry[key]); }); describe('creating a new control', () => { From be2636d7f66f15bcd70fc288799108d623f948fd Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 8 Jul 2024 15:32:00 -0600 Subject: [PATCH 16/22] Clean up name --- .../data_controls/data_control_editor.test.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 index fe3249b6f63de..e1c7c31512ac2 100644 --- 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 @@ -57,7 +57,7 @@ const dashboardApi = { timeRange$: new BehaviorSubject(undefined), lastUsedDataViewId$: new BehaviorSubject(mockDataView.id!), }; -const mockedControlGroupApi = { +const controlGroupApi = { parentApi: dashboardApi, grow: new BehaviorSubject(DEFAULT_CONTROL_GROW), width: new BehaviorSubject(DEFAULT_CONTROL_WIDTH), @@ -76,7 +76,7 @@ describe('Data control editor', () => { {}} onSave={() => {}} - parentApi={mockedControlGroupApi} + parentApi={controlGroupApi} initialState={{ dataViewId: dashboardApi.lastUsedDataViewId$.getValue(), ...initialState, @@ -106,9 +106,9 @@ describe('Data control editor', () => { beforeAll(() => { const mockRegistry = { - search: getMockedSearchControlFactory({ parentApi: mockedControlGroupApi }), - optionsList: getMockedOptionsListControlFactory({ parentApi: mockedControlGroupApi }), - rangeSlider: getMockedRangeSliderControlFactory({ parentApi: mockedControlGroupApi }), + 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]); From 3bf7641cd4562cbb914a22f8caa43e8ff6cf5818 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Wed, 10 Jul 2024 11:16:33 -0600 Subject: [PATCH 17/22] Only apply updated changes + fix types --- .../data_controls/data_control_editor.test.tsx | 13 ++++++++----- .../data_controls/initialize_data_control.tsx | 4 +++- 2 files changed, 11 insertions(+), 6 deletions(-) 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 index e1c7c31512ac2..d90e5349f36d8 100644 --- 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 @@ -31,6 +31,8 @@ import { getMockedRangeSliderControlFactory, getMockedSearchControlFactory, } from './mocks/data_control_mocks'; +import { ControlFactory } from '../types'; +import { DataControlApi, DefaultDataControlState } from './types'; const mockDataViews = dataViewPluginMocks.createStartContract(); const mockDataView = createStubDataView({ @@ -105,11 +107,12 @@ describe('Data control editor', () => { }; beforeAll(() => { - const mockRegistry = { - search: getMockedSearchControlFactory({ parentApi: controlGroupApi }), - optionsList: getMockedOptionsListControlFactory({ parentApi: controlGroupApi }), - rangeSlider: getMockedRangeSliderControlFactory({ parentApi: controlGroupApi }), - }; + 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]); }); 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 fcbb52658fc1a..8491bdf47fe67 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 @@ -131,7 +131,9 @@ export const initializeDataControl = ( // apply the changes from the new state via the state manager (Object.keys(initialState) as Array).forEach( (key) => { - mergedStateManager[key].next(newState[key]); + if (mergedStateManager[key].getValue() !== newState[key]) { + mergedStateManager[key].next(newState[key]); + } } ); } else { From b5147af6b74fdab2146ab21520ec611c7f0bde6a Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Mon, 15 Jul 2024 09:37:21 -0600 Subject: [PATCH 18/22] Switch to `isEqual` --- .../react_controls/data_controls/initialize_data_control.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 8491bdf47fe67..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'; @@ -131,7 +132,7 @@ export const initializeDataControl = ( // apply the changes from the new state via the state manager (Object.keys(initialState) as Array).forEach( (key) => { - if (mergedStateManager[key].getValue() !== newState[key]) { + if (!isEqual(mergedStateManager[key].getValue(), newState[key])) { mergedStateManager[key].next(newState[key]); } } From d81e30c1ee51d50c1aab11f2eaddddf76d8c42a0 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Tue, 16 Jul 2024 11:06:09 -0600 Subject: [PATCH 19/22] Small range slider style fix --- .../data_controls/range_slider/components/range_slider.scss | 1 + 1 file changed, 1 insertion(+) 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 84c2ebc396550..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 @@ -25,6 +25,7 @@ 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 From 4fcd85a84ba74b32f55f5ce8c544736c0234d027 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Tue, 16 Jul 2024 11:12:45 -0600 Subject: [PATCH 20/22] More quick style fixes --- .../public/react_controls/control_panel.scss | 2 +- .../range_slider/components/range_slider_control.tsx | 5 +++-- .../range_slider/get_range_slider_control_factory.tsx | 4 ++-- .../search_control/get_search_control_factory.tsx | 9 +++++++-- examples/controls_example/public/react_controls/types.ts | 2 +- 5 files changed, 14 insertions(+), 8 deletions(-) diff --git a/examples/controls_example/public/react_controls/control_panel.scss b/examples/controls_example/public/react_controls/control_panel.scss index d116210a53e10..5a08475da3414 100644 --- a/examples/controls_example/public/react_controls/control_panel.scss +++ b/examples/controls_example/public/react_controls/control_panel.scss @@ -1,7 +1,7 @@ .controlPanel { width: 100%; max-inline-size: 100% !important; - height: calc($euiButtonHeight - 2px); + height: $euiButtonHeight; box-shadow: none !important; background-color: $euiFormBackgroundColor !important; 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.tsx b/examples/controls_example/public/react_controls/data_controls/range_slider/get_range_slider_control_factory.tsx index 8fd9bde659ba6..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 @@ -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 ( { + Component: ({ className: controlPanelClassName }) => { const currentSearch = useStateFromPublishingSubject(searchString); useEffect(() => { @@ -203,7 +205,10 @@ export const getSearchControlFactory = ({ return ( ControlApi, uuid: string, parentApi: ControlGroupApi - ) => { api: ControlApi; Component: React.FC<{}> }; + ) => { api: ControlApi; Component: React.FC<{ className: string }> }; } export type ControlStateManager = { From f33c1ca08727a0b9e578bb8120b0073559ab4d12 Mon Sep 17 00:00:00 2001 From: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Date: Tue, 16 Jul 2024 17:24:09 +0000 Subject: [PATCH 21/22] [CI] Auto-commit changed files from 'node scripts/lint_ts_projects --fix' --- examples/controls_example/tsconfig.json | 1 + 1 file changed, 1 insertion(+) 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", ] } From 7b8c87b9092ec5d177d0029145ab0f8b1cec7647 Mon Sep 17 00:00:00 2001 From: Hannah Mudge Date: Tue, 16 Jul 2024 12:45:19 -0600 Subject: [PATCH 22/22] Fix types --- .../range_slider/get_range_slider_control_factory.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 c92422ff69229..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 @@ -184,7 +184,7 @@ describe('RangesliderControlApi', () => { uuid, controlGroupApi ); - const { findByTestId } = render(); + const { findByTestId } = render(); await waitFor(async () => { await findByTestId('range-slider-control-invalid-append-myControl1'); }); @@ -202,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));