diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts b/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts index faaa155249949..3f89e2e549d2a 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts +++ b/src/plugins/presentation_util/public/components/controls/__stories__/controls_service_stub.ts @@ -8,7 +8,7 @@ import { ControlsService } from '../controls_service'; import { InputControlFactory } from '../../../services/controls'; -import { flightFields, getEuiSelectableOptions } from './flights'; +import { flightFields, getFlightSearchOptions } from './flights'; import { OptionsListEmbeddableFactory } from '../control_types/options_list'; export const getControlsServiceStub = () => { @@ -16,8 +16,8 @@ export const getControlsServiceStub = () => { const optionsListFactoryStub = new OptionsListEmbeddableFactory( ({ field, search }) => - new Promise((r) => setTimeout(() => r(getEuiSelectableOptions(field, search)), 500)), - () => Promise.resolve(['demo data flights']), + new Promise((r) => setTimeout(() => r(getFlightSearchOptions(field.name, search)), 120)), + () => Promise.resolve([{ title: 'demo data flights', fields: [] }]), () => Promise.resolve(flightFields) ); diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx b/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx index c5d3cf2c815be..c4c8d83ccc210 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx +++ b/src/plugins/presentation_util/public/components/controls/__stories__/decorators.tsx @@ -11,8 +11,8 @@ import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { Story } from '@storybook/react'; const bar = '#c5ced8'; -const panel = '#f7f9fa'; -const background = '#e0e6ec'; +const panel = '#ffff'; +const background = '#FAFBFD'; const minHeight = 60; const panelStyle = { @@ -23,12 +23,10 @@ const panelStyle = { const kqlBarStyle = { background: bar, padding: 16, minHeight, fontStyle: 'italic' }; -const inputBarStyle = { background: '#fff', padding: 4 }; - const layout = (OptionStory: Story) => ( KQL Bar - + diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts b/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts index e405b704796ec..941b91c0c92f1 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts +++ b/src/plugins/presentation_util/public/components/controls/__stories__/flights.ts @@ -7,22 +7,17 @@ */ import { map, uniq } from 'lodash'; -import { EuiSelectableOption } from '@elastic/eui'; - import { flights } from '../../fixtures/flights'; export type Flight = typeof flights[number]; export type FlightField = keyof Flight; -export const getOptions = (field: string) => uniq(map(flights, field)).sort(); +export const getFlightOptions = (field: string) => uniq(map(flights, field)).sort(); -export const getEuiSelectableOptions = (field: string, search?: string): EuiSelectableOption[] => { - const options = getOptions(field) - .map((option) => ({ - label: option + '', - searchableLabel: option + '', - })) - .filter((option) => !search || option.label.toLowerCase().includes(search.toLowerCase())); +export const getFlightSearchOptions = (field: string, search?: string): string[] => { + const options = getFlightOptions(field) + .map((option) => option + '') + .filter((option) => !search || option.toLowerCase().includes(search.toLowerCase())); if (options.length > 10) options.length = 10; return options; }; @@ -57,4 +52,8 @@ export const flightFieldLabels: Record = { timestamp: 'Timestamp', }; -export const flightFields = Object.keys(flightFieldLabels) as FlightField[]; +export const flightFields = Object.keys(flightFieldLabels).map((field) => ({ + name: field, + type: 'string', + aggregatable: true, +})); diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx b/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx index 66f1d8b36399e..f984b7c996a03 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx +++ b/src/plugins/presentation_util/public/components/controls/__stories__/input_controls.stories.tsx @@ -64,9 +64,15 @@ export const ConfiguredControlGroupStory = () => ( explicitInput: { title: 'Origin City', id: 'optionsList1', - indexPattern: 'demo data flights', - field: 'OriginCityName', - defaultSelections: ['Toronto'], + indexPattern: { + title: 'demo data flights', + }, + field: { + name: 'OriginCityName', + type: 'string', + aggregatable: true, + }, + selectedOptions: ['Toronto'], } as OptionsListEmbeddableInput, }, optionsList2: { @@ -76,9 +82,15 @@ export const ConfiguredControlGroupStory = () => ( explicitInput: { title: 'Destination City', id: 'optionsList2', - indexPattern: 'demo data flights', - field: 'DestCityName', - defaultSelections: ['London'], + indexPattern: { + title: 'demo data flights', + }, + field: { + name: 'DestCityName', + type: 'string', + aggregatable: true, + }, + selectedOptions: ['London'], } as OptionsListEmbeddableInput, }, optionsList3: { @@ -88,8 +100,14 @@ export const ConfiguredControlGroupStory = () => ( explicitInput: { title: 'Carrier', id: 'optionsList3', - indexPattern: 'demo data flights', - field: 'Carrier', + indexPattern: { + title: 'demo data flights', + }, + field: { + name: 'Carrier', + type: 'string', + aggregatable: true, + }, } as OptionsListEmbeddableInput, }, }} diff --git a/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts b/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts index 3048adc74d8c7..deb5b85336f27 100644 --- a/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts +++ b/src/plugins/presentation_util/public/components/controls/__stories__/storybook_control_factories.ts @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import { flightFields, getEuiSelectableOptions } from './flights'; +import { flightFields, getFlightSearchOptions } from './flights'; import { OptionsListEmbeddableFactory } from '../control_types/options_list'; import { InputControlFactory, PresentationControlsService } from '../../../services/controls'; @@ -15,8 +15,14 @@ export const populateStorybookControlFactories = ( ) => { const optionsListFactoryStub = new OptionsListEmbeddableFactory( ({ field, search }) => - new Promise((r) => setTimeout(() => r(getEuiSelectableOptions(field, search)), 500)), - () => Promise.resolve(['demo data flights']), + new Promise((r) => setTimeout(() => r(getFlightSearchOptions(field.name, search)), 120)), + () => + Promise.resolve([ + { + title: 'demo data flights', + fields: [], + }, + ]), () => Promise.resolve(flightFields) ); diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx index 103ce6dd0e27c..7d8893cb6b5a5 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_frame_component.tsx @@ -21,6 +21,7 @@ import { EditControlButton } from '../editor/edit_control'; import { useChildEmbeddable } from '../../hooks/use_child_embeddable'; import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; import { ControlGroupStrings } from '../control_group_strings'; +import { pluginServices } from '../../../../services'; export interface ControlFrameProps { customPrepend?: JSX.Element; @@ -36,6 +37,10 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con } = useReduxContainerContext(); const { controlStyle } = useEmbeddableSelector((state) => state); + // Presentation Services Context + const { overlays } = pluginServices.getHooks(); + const { openConfirm } = overlays.useService(); + const embeddable = useChildEmbeddable({ untilEmbeddableLoaded, embeddableId }); const [title, setTitle] = useState(); @@ -52,9 +57,9 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con const floatingActions = (
@@ -63,7 +68,18 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con removeEmbeddable(embeddableId)} + onClick={() => + openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { + confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), + cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), + title: ControlGroupStrings.management.deleteControls.getDeleteTitle(), + buttonColor: 'danger', + }).then((confirmed) => { + if (confirmed) { + removeEmbeddable(embeddableId); + } + }) + } iconType="cross" color="danger" /> @@ -73,13 +89,13 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con const form = ( {customPrepend ?? null} {usingTwoLineLayout ? undefined : ( - + {title} )} @@ -87,7 +103,7 @@ export const ControlFrame = ({ customPrepend, enableActions, embeddableId }: Con } >
{ const dispatch = useEmbeddableDispatch(); // current state - const { panels } = useEmbeddableSelector((state) => state); + const { panels, controlStyle } = useEmbeddableSelector((state) => state); const idsInOrder = useMemo( () => @@ -92,63 +99,105 @@ export const ControlGroup = () => { setDraggingId(null); }; + const emptyState = !(idsInOrder && idsInOrder.length > 0); + return ( - - - setDraggingId(active.id)} - onDragEnd={onDragEnd} - onDragCancel={() => setDraggingId(null)} - sensors={sensors} - collisionDetection={closestCenter} - layoutMeasuring={{ - strategy: LayoutMeasuringStrategy.Always, - }} + + {idsInOrder.length > 0 ? ( + - - - {idsInOrder.map( - (controlId, index) => - panels[controlId] && ( - - ) - )} - - - {draggingId ? : null} - - - - - - - openFlyout(forwardAllContext(, reduxContainerContext)) - } - /> - + setDraggingId(active.id)} + onDragEnd={onDragEnd} + onDragCancel={() => setDraggingId(null)} + sensors={sensors} + collisionDetection={closestCenter} + layoutMeasuring={{ + strategy: LayoutMeasuringStrategy.Always, + }} + > + + + {idsInOrder.map( + (controlId, index) => + panels[controlId] && ( + + ) + )} + + + + {draggingId ? : null} + + - - - - + + + + + { + const flyoutInstance = openFlyout( + forwardAllContext( + flyoutInstance.close()} />, + reduxContainerContext + ) + ); + }} + /> + + + + + + + + - - + ) : ( + <> + + + +

{ControlGroupStrings.emptyState.getCallToAction()}

+
+
+ +
+ +
+
+
+ + )} + ); }; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx index 5c222e3c130b5..d72f7980fab8b 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/component/control_group_sortable_item.tsx @@ -66,7 +66,7 @@ const SortableControlInner = forwardRef< const width = panels[embeddableId].width; const dragHandle = ( - ); @@ -74,13 +74,13 @@ const SortableControlInner = forwardRef< return ( (draggingIndex ?? -1), + className={classNames('controlFrameWrapper', { + 'controlFrameWrapper-isDragging': isDragging, + 'controlFrameWrapper--small': width === 'small', + 'controlFrameWrapper--medium': width === 'medium', + 'controlFrameWrapper--large': width === 'large', + 'controlFrameWrapper--insertBefore': isOver && (index ?? -1) < (draggingIndex ?? -1), + 'controlFrameWrapper--insertAfter': isOver && (index ?? -1) > (draggingIndex ?? -1), })} style={style} > @@ -106,17 +106,17 @@ export const ControlClone = ({ draggingId }: { draggingId: string }) => { const title = panels[draggingId].explicitInput.title; return ( {controlStyle === 'twoLine' ? {title} : undefined} - + - + {controlStyle === 'oneLine' ? {title} : undefined} diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss b/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss index f49efa7aab043..00a135c65a75e 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group.scss @@ -4,32 +4,59 @@ $largeControl: $euiSize * 50; $controlMinWidth: $euiSize * 14; .controlGroup { - margin-left: $euiSizeXS; overflow-x: clip; // sometimes when using auto width, removing a control can cause a horizontal scrollbar to appear. min-height: $euiSize * 4; - padding: $euiSize 0; } -.controlFrame--cloneWrapper { +.controlsWrapper { + &--empty { + display: flex; + @include euiBreakpoint('m', 'l', 'xl') { + background: url(opt_a.svg); + background-position: left top; + background-repeat: no-repeat; + .addControlButton { + text-align: center; + } + .emptyStateText { + padding-left: $euiSize * 2; + } + } + @include euiBreakpoint('xs', 's') { + .addControlButton { + text-align: center; + } + } + min-height: $euiSize * 4; + } + + &--twoLine { + .groupEditActions { + padding-top: $euiSize; + } + } +} + +.controlFrameCloneWrapper { width: max-content; .euiFormLabel { padding-bottom: $euiSizeXS; } - &-small { + &--small { width: $smallControl; } - &-medium { + &--medium { width: $mediumControl; } - &-large { + &--large { width: $largeControl; } - &-twoLine { + &--twoLine { margin-top: -$euiSize * 1.25; } @@ -37,7 +64,7 @@ $controlMinWidth: $euiSize * 14; cursor: grabbing !important; // prevents cursor flickering while dragging the clone } - .controlFrame--draggable { + .controlFrame__draggable { cursor: grabbing; height: $euiButtonHeight; align-items: center; @@ -49,41 +76,41 @@ $controlMinWidth: $euiSize * 14; min-width: $controlMinWidth; } - .controlFrame--formControlLayout, .controlFrame--draggable { + .controlFrame__formControlLayout, .controlFrame__draggable { &-clone { box-shadow: 0 0 0 1px $euiShadowColor, 0 1px 6px 0 $euiShadowColor; cursor: grabbing !important; } - .controlFrame--dragHandle { + .controlFrame__dragHandle { cursor: grabbing; } } } -.controlFrame--wrapper { +.controlFrameWrapper { flex-basis: auto; position: relative; display: block; - .controlFrame--formControlLayout { + .controlFrame__formControlLayout { width: 100%; min-width: $controlMinWidth; transition:background-color .1s, color .1s; - &__label { + &Label { @include euiTextTruncate; max-width: 50%; } - &:not(.controlFrame--formControlLayout-clone) { - .controlFrame--dragHandle { + &:not(.controlFrame__formControlLayout-clone) { + .controlFrame__dragHandle { cursor: grab; } } - .controlFrame--control { + .controlFrame__control { height: 100%; transition: opacity .1s; @@ -93,21 +120,21 @@ $controlMinWidth: $euiSize * 14; } } - &-small { + &--small { width: $smallControl; } - &-medium { + &--medium { width: $mediumControl; } - &-large { + &--large { width: $largeControl; } - &-insertBefore, - &-insertAfter { - .controlFrame--formControlLayout:after { + &--insertBefore, + &--insertAfter { + .controlFrame__formControlLayout:after { content: ''; position: absolute; background-color: transparentize($euiColorPrimary, .5); @@ -118,19 +145,19 @@ $controlMinWidth: $euiSize * 14; } } - &-insertBefore { - .controlFrame--formControlLayout:after { + &--insertBefore { + .controlFrame__formControlLayout:after { left: -$euiSizeS; } } - &-insertAfter { - .controlFrame--formControlLayout:after { + &--insertAfter { + .controlFrame__formControlLayout:after { right: -$euiSizeS; } } - .controlFrame--floatingActions { + .controlFrameFloatingActions { visibility: hidden; opacity: 0; @@ -140,23 +167,23 @@ $controlMinWidth: $euiSize * 14; z-index: 1; position: absolute; - &-oneLine { + &--oneLine { right:$euiSizeXS; top: -$euiSizeL; padding: $euiSizeXS; border-radius: $euiBorderRadius; background-color: $euiColorEmptyShade; - box-shadow: 0 0 0 1pt $euiColorLightShade; + box-shadow: 0 0 0 1px $euiColorLightShade; } - &-twoLine { + &--twoLine { right:$euiSizeXS; top: -$euiSizeXS; } } &:hover { - .controlFrame--floatingActions { + .controlFrameFloatingActions { transition:visibility .1s, opacity .1s; visibility: visible; opacity: 1; @@ -167,7 +194,7 @@ $controlMinWidth: $euiSize * 14; .euiFormRow__labelWrapper { opacity: 0; } - .controlFrame--formControlLayout { + .controlFrame__formControlLayout { background-color: $euiColorEmptyShade !important; color: transparent !important; box-shadow: none; @@ -176,7 +203,7 @@ $controlMinWidth: $euiSize * 14; opacity: 0; } - .controlFrame--control { + .controlFrame__control { opacity: 0; } } diff --git a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts index 35e490b0ea530..657add5ef048f 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_group/control_group_strings.ts @@ -13,10 +13,30 @@ export const ControlGroupStrings = { i18n.translate('presentationUtil.inputControls.controlGroup.title', { defaultMessage: 'Control group', }), + emptyState: { + getCallToAction: () => + i18n.translate('presentationUtil.inputControls.controlGroup.emptyState.callToAction', { + defaultMessage: 'Controls let you filter and interact with your dashboard data', + }), + getAddControlButtonTitle: () => + i18n.translate( + 'presentationUtil.inputControls.controlGroup.emptyState.addControlButtonTitle', + { + defaultMessage: 'Add control', + } + ), + }, manageControl: { - getFlyoutTitle: () => - i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.flyoutTitle', { - defaultMessage: 'Manage control', + getFlyoutCreateTitle: () => + i18n.translate( + 'presentationUtil.inputControls.controlGroup.manageControl.createFlyoutTitle', + { + defaultMessage: 'Create control', + } + ), + getFlyoutEditTitle: () => + i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.editFlyoutTitle', { + defaultMessage: 'Edit control', }), getTitleInputTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.titleInputTitle', { @@ -24,7 +44,7 @@ export const ControlGroupStrings = { }), getWidthInputTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.widthInputTitle', { - defaultMessage: 'Control width', + defaultMessage: 'Control size', }), getSaveChangesTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.manageControl.saveChangesTitle', { @@ -42,15 +62,15 @@ export const ControlGroupStrings = { }), getManageButtonTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.buttonTitle', { - defaultMessage: 'Manage controls', + defaultMessage: 'Configure controls', }), getFlyoutTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.flyoutTitle', { - defaultMessage: 'Manage controls', + defaultMessage: 'Configure controls', }), getDefaultWidthTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.defaultWidthTitle', { - defaultMessage: 'Default width', + defaultMessage: 'Default size', }), getLayoutTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.layoutTitle', { @@ -62,7 +82,7 @@ export const ControlGroupStrings = { }), getSetAllWidthsToDefaultTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.setAllWidths', { - defaultMessage: 'Set all widths to default', + defaultMessage: 'Set all sizes to default', }), getDeleteAllButtonTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteAll', { @@ -73,7 +93,7 @@ export const ControlGroupStrings = { i18n.translate( 'presentationUtil.inputControls.controlGroup.management.layout.controlWidthLegend', { - defaultMessage: 'Change control width', + defaultMessage: 'Change control size', } ), getAutoWidthTitle: () => @@ -103,11 +123,11 @@ export const ControlGroupStrings = { ), getSingleLineTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.singleLine', { - defaultMessage: 'Single line layout', + defaultMessage: 'Single line', }), getTwoLineTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.layout.twoLine', { - defaultMessage: 'Two line layout', + defaultMessage: 'Double line', }), }, deleteControls: { @@ -141,16 +161,15 @@ export const ControlGroupStrings = { discardChanges: { getTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.title', { - defaultMessage: 'Discard?', + defaultMessage: 'Discard changes?', }), getSubtitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.sub', { - defaultMessage: - 'Discard changes to this control? Changes are not recoverable once discardsd.', + defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`, }), getConfirm: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.confirm', { - defaultMessage: 'Discard', + defaultMessage: 'Discard changes', }), getCancel: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.discard.cancel', { @@ -160,15 +179,15 @@ export const ControlGroupStrings = { discardNewControl: { getTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.title', { - defaultMessage: 'Discard?', + defaultMessage: 'Discard new control', }), getSubtitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.sub', { - defaultMessage: 'Discard new control? Controls are not recoverable once discarded.', + defaultMessage: `Changes that you've made to this control will be discarded, are you sure you want to continue?`, }), getConfirm: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.confirm', { - defaultMessage: 'Discard', + defaultMessage: 'Discard control', }), getCancel: () => i18n.translate('presentationUtil.inputControls.controlGroup.management.deleteNew.cancel', { @@ -179,7 +198,7 @@ export const ControlGroupStrings = { floatingActions: { getEditButtonTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.editTitle', { - defaultMessage: 'Manage control', + defaultMessage: 'Edit control', }), getRemoveButtonTitle: () => i18n.translate('presentationUtil.inputControls.controlGroup.floatingActions.removeTitle', { diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx index 38d8faf37397a..a55dd381857b7 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/control_editor.tsx @@ -37,6 +37,7 @@ import { CONTROL_WIDTH_OPTIONS } from '../control_group_constants'; interface ManageControlProps { title?: string; + isCreate: boolean; onSave: () => void; width: ControlWidth; onCancel: () => void; @@ -51,6 +52,7 @@ export const ControlEditor = ({ removeControl, updateTitle, updateWidth, + isCreate, onCancel, onSave, title, @@ -68,7 +70,11 @@ export const ControlEditor = ({ <> -

{ControlGroupStrings.manageControl.getFlyoutTitle()}

+

+ {isCreate + ? ControlGroupStrings.manageControl.getFlyoutCreateTitle() + : ControlGroupStrings.manageControl.getFlyoutEditTitle()} +

@@ -118,10 +124,10 @@ export const ControlEditor = ({ - + { onCancel(); @@ -132,7 +138,7 @@ export const ControlEditor = ({ { +export const CreateControlButton = ({ isIconButton }: { isIconButton: boolean }) => { // Presentation Services Context const { overlays, controls } = pluginServices.getHooks(); const { getInputControlTypes, getControlFactory } = controls.useService(); @@ -49,7 +50,7 @@ export const CreateControlButton = () => { // current state const { defaultControlWidth } = useEmbeddableSelector((state) => state); - const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [isControlTypePopoverOpen, setIsControlTypePopoverOpen] = useState(false); const createNewControl = async (type: string) => { const factory = getControlFactory(type); @@ -80,6 +81,7 @@ export const CreateControlButton = () => { const flyoutInstance = openFlyout( forwardAllContext( (inputToReturn.title = newTitle)} updateWidth={(newWidth) => dispatch(setDefaultControlWidth(newWidth as ControlWidth))} @@ -112,12 +114,33 @@ export const CreateControlButton = () => { if (getInputControlTypes().length === 0) return null; const commonButtonProps = { - iconType: 'plus', - color: 'text' as EuiButtonIconColor, - 'data-test-subj': 'inputControlsSortingButton', + iconType: 'plusInCircle', + color: 'primary' as EuiButtonIconColor, + 'data-test-subj': 'controlsCreateButton', 'aria-label': ControlGroupStrings.management.getManageButtonTitle(), }; + const onCreateButtonClick = () => { + if (getInputControlTypes().length > 1) { + setIsControlTypePopoverOpen(!isControlTypePopoverOpen); + return; + } + createNewControl(getInputControlTypes()[0]); + }; + + const createControlButton = isIconButton ? ( + + ) : ( + + {ControlGroupStrings.emptyState.getAddControlButtonTitle()} + + ); + if (getInputControlTypes().length > 1) { const items: ReactElement[] = []; getInputControlTypes().forEach((type) => { @@ -127,7 +150,7 @@ export const CreateControlButton = () => { key={type} icon={factory.getIconType?.()} onClick={() => { - setIsPopoverOpen(false); + setIsControlTypePopoverOpen(false); createNewControl(type); }} > @@ -135,24 +158,18 @@ export const CreateControlButton = () => { ); }); - const button = setIsPopoverOpen(true)} />; return ( setIsPopoverOpen(false)} + closePopover={() => setIsControlTypePopoverOpen(false)} > ); } - return ( - createNewControl(getInputControlTypes()[0])} - /> - ); + return createControlButton; }; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx index 58c59c8f84fe0..891d83569b08b 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control.tsx @@ -20,6 +20,7 @@ import { ControlGroupStrings } from '../control_group_strings'; import { controlGroupReducers } from '../state/control_group_reducers'; import { EmbeddableFactoryNotFoundError } from '../../../../../../embeddable/public'; import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { InputControlInput } from '../../../../services/controls'; export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => { // Presentation Services Context @@ -54,13 +55,18 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => const factory = getControlFactory(panel.type); const embeddable = await untilEmbeddableLoaded(embeddableId); + let inputToReturn: Partial = {}; + if (!factory) throw new EmbeddableFactoryNotFoundError(panel.type); let removed = false; const onCancel = (ref: OverlayRef) => { if ( removed || - (isEqual(latestPanelState.current.explicitInput, panel.explicitInput) && + (isEqual(latestPanelState.current.explicitInput, { + ...panel.explicitInput, + ...inputToReturn, + }) && isEqual(latestPanelState.current.width, panel.width)) ) { ref.close(); @@ -73,7 +79,6 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => buttonColor: 'danger', }).then((confirmed) => { if (confirmed) { - updateInputForChild(embeddableId, panel.explicitInput); dispatch(setControlWidth({ width: panel.width, embeddableId })); ref.close(); } @@ -83,6 +88,7 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => const flyoutInstance = openFlyout( forwardAllContext( { @@ -99,13 +105,18 @@ export const EditControlButton = ({ embeddableId }: { embeddableId: string }) => } }); }} - updateTitle={(newTitle) => updateInputForChild(embeddableId, { title: newTitle })} + updateTitle={(newTitle) => (inputToReturn.title = newTitle)} controlEditorComponent={(factory as IEditableControlFactory).getControlEditor?.({ - onChange: (partialInput) => updateInputForChild(embeddableId, partialInput), + onChange: (partialInput) => { + inputToReturn = { ...inputToReturn, ...partialInput }; + }, initialInput: embeddable.getInput(), })} onCancel={() => onCancel(flyoutInstance)} - onSave={() => flyoutInstance.close()} + onSave={() => { + updateInputForChild(embeddableId, inputToReturn); + flyoutInstance.close(); + }} updateWidth={(newWidth) => dispatch(setControlWidth({ width: newWidth, embeddableId }))} />, reduxContainerContext diff --git a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx index 9438091e2fb1d..681af9c10ba20 100644 --- a/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_group/editor/edit_control_group.tsx @@ -6,7 +6,7 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useState } from 'react'; import { EuiTitle, EuiSpacer, @@ -17,6 +17,9 @@ import { EuiButtonGroup, EuiButtonEmpty, EuiFlyoutHeader, + EuiCheckbox, + EuiFlyoutFooter, + EuiButton, } from '@elastic/eui'; import { @@ -31,7 +34,13 @@ import { ControlGroupStrings } from '../control_group_strings'; import { controlGroupReducers } from '../state/control_group_reducers'; import { useReduxContainerContext } from '../../../redux_embeddables/redux_embeddable_context'; -export const EditControlGroup = () => { +interface EditControlGroupState { + newControlStyle: ControlGroupInput['controlStyle']; + newDefaultWidth: ControlGroupInput['defaultControlWidth']; + setAllWidths: boolean; +} + +export const EditControlGroup = ({ closeFlyout }: { closeFlyout: () => void }) => { const { overlays } = pluginServices.getHooks(); const { openConfirm } = overlays.useService(); @@ -41,10 +50,29 @@ export const EditControlGroup = () => { useEmbeddableDispatch, actions: { setControlStyle, setAllControlWidths, setDefaultControlWidth }, } = useReduxContainerContext(); - const dispatch = useEmbeddableDispatch(); const { panels, controlStyle, defaultControlWidth } = useEmbeddableSelector((state) => state); + const [state, setState] = useState({ + newControlStyle: controlStyle, + newDefaultWidth: defaultControlWidth, + setAllWidths: false, + }); + + const onSave = () => { + const { newControlStyle, newDefaultWidth, setAllWidths } = state; + if (newControlStyle && newControlStyle !== controlStyle) { + dispatch(setControlStyle(newControlStyle)); + } + if (newDefaultWidth && newDefaultWidth !== defaultControlWidth) { + dispatch(setDefaultControlWidth(newDefaultWidth)); + } + if (setAllWidths && newDefaultWidth) { + dispatch(setAllControlWidths(newDefaultWidth)); + } + closeFlyout(); + }; + return ( <> @@ -58,46 +86,37 @@ export const EditControlGroup = () => { color="primary" legend={ControlGroupStrings.management.controlStyle.getDesignSwitchLegend()} options={CONTROL_LAYOUT_OPTIONS} - idSelected={controlStyle} + idSelected={state.newControlStyle} onChange={(newControlStyle) => - dispatch(setControlStyle(newControlStyle as ControlStyle)) + setState((s) => ({ ...s, newControlStyle: newControlStyle as ControlStyle })) } /> - - - - dispatch(setDefaultControlWidth(newWidth as ControlWidth)) - } - /> - - - - dispatch(setAllControlWidths(defaultControlWidth ?? DEFAULT_CONTROL_WIDTH)) - } - aria-label={'delete-all'} - iconType="returnKey" - size="s" - > - {ControlGroupStrings.management.getSetAllWidthsToDefaultTitle()} - - - + + setState((s) => ({ ...s, newDefaultWidth: newDefaultWidth as ControlWidth })) + } + /> - - + + setState((s) => ({ ...s, setAllWidths: e.target.checked }))} + /> + { if (!containerActions?.removeEmbeddable) return; + closeFlyout(); openConfirm(ControlGroupStrings.management.deleteControls.getSubtitle(), { confirmButtonText: ControlGroupStrings.management.deleteControls.getConfirm(), cancelButtonText: ControlGroupStrings.management.deleteControls.getCancel(), @@ -119,6 +138,33 @@ export const EditControlGroup = () => { {ControlGroupStrings.management.getDeleteAllButtonTitle()} + + + + { + closeFlyout(); + }} + > + {ControlGroupStrings.manageControl.getCancelTitle()} + + + + { + onSave(); + }} + > + {ControlGroupStrings.manageControl.getSaveChangesTitle()} + + + + ); }; diff --git a/src/plugins/presentation_util/public/components/controls/control_group/opt_a.svg b/src/plugins/presentation_util/public/components/controls/control_group/opt_a.svg new file mode 100644 index 0000000000000..6722db6f26a55 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_group/opt_a.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss index e9a4ef215733e..b74a08d96c8c3 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list.scss @@ -7,10 +7,24 @@ height: 100%; } +.optionsList--loadingOverlay { + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + position: absolute; + align-items: center; + justify-content: center; + background-color: $euiColorEmptyShade; +} + .optionsList--items { @include euiScrollBar; overflow-y: auto; + position: relative; + min-height: $euiSize * 5; max-height: $euiSize * 30; width: $euiSize * 25; max-width: 100%; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx index 0d12c69fdab46..900570b38ca4d 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_component.tsx @@ -6,48 +6,74 @@ * Side Public License, v 1. */ -import React, { useState } from 'react'; +import { EuiFilterButton, EuiFilterGroup, EuiPopover } from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import classNames from 'classnames'; - -import { EuiFilterButton, EuiFilterGroup, EuiPopover, EuiSelectableOption } from '@elastic/eui'; import { Subject } from 'rxjs'; -import { OptionsListStrings } from './options_list_strings'; + +import { useReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { OptionsListEmbeddableInput } from './options_list_embeddable'; import { OptionsListPopover } from './options_list_popover_component'; +import { optionsListReducers } from './options_list_reducers'; +import { OptionsListStrings } from './options_list_strings'; import './options_list.scss'; import { useStateObservable } from '../../hooks/use_state_observable'; +// Availableoptions and loading state is controled by the embeddable, but is not considered embeddable input. export interface OptionsListComponentState { - availableOptions?: EuiSelectableOption[]; - selectedOptionsString?: string; - selectedOptionsCount?: number; - twoLineLayout?: boolean; - searchString?: string; + availableOptions?: string[]; loading: boolean; } export const OptionsListComponent = ({ - componentStateSubject, typeaheadSubject, - updateOption, + componentStateSubject, }: { - componentStateSubject: Subject; typeaheadSubject: Subject; - updateOption: (index: number) => void; + componentStateSubject: Subject; }) => { const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const optionsListState = useStateObservable(componentStateSubject, { - loading: true, - }); + const [searchString, setSearchString] = useState(''); + // Redux embeddable Context to get state from Embeddable input const { - selectedOptionsString, - selectedOptionsCount, - availableOptions, - twoLineLayout, - searchString, - loading, - } = optionsListState; + useEmbeddableDispatch, + useEmbeddableSelector, + actions: { replaceSelection }, + } = useReduxEmbeddableContext(); + const dispatch = useEmbeddableDispatch(); + const { twoLineLayout, selectedOptions, singleSelect } = useEmbeddableSelector((state) => state); + + // useStateObservable to get component state from Embeddable + const { availableOptions, loading } = useStateObservable( + componentStateSubject, + { + loading: true, + } + ); + + // remove all other selections if this control is single select + useEffect(() => { + if (singleSelect && selectedOptions && selectedOptions?.length > 1) { + dispatch(replaceSelection(selectedOptions[0])); + } + }, [selectedOptions, singleSelect, dispatch, replaceSelection]); + + const updateSearchString = useCallback( + (newSearchString: string) => { + typeaheadSubject.next(newSearchString); + setSearchString(newSearchString); + }, + [typeaheadSubject] + ); + + const { selectedOptionsCount, selectedOptionsString } = useMemo(() => { + return { + selectedOptionsCount: selectedOptions?.length, + selectedOptionsString: selectedOptions?.join(OptionsListStrings.summary.getSeparator()), + }; + }, [selectedOptions]); const button = ( setIsPopoverOpen((openState) => !openState)} isSelected={isPopoverOpen} - numFilters={availableOptions?.length ?? 0} - hasActiveFilters={(selectedOptionsCount ?? 0) > 0} + numFilters={availableOptions?.length ?? 0} // Remove this once https://github.com/elastic/eui/pull/5268 is in an EUI release numActiveFilters={selectedOptionsCount} + hasActiveFilters={(selectedOptionsCount ?? 0) > 0} > {!selectedOptionsCount ? OptionsListStrings.summary.getPlaceholder() : selectedOptionsString} @@ -79,15 +105,14 @@ export const OptionsListComponent = ({ anchorClassName="optionsList--anchorOverride" closePopover={() => setIsPopoverOpen(false)} panelPaddingSize="none" - anchorPosition="upLeft" + anchorPosition="downCenter" ownFocus repositionOnScroll > diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx index 3e5770da22ce9..d8f70501a34ad 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_editor.tsx @@ -6,9 +6,10 @@ * Side Public License, v 1. */ -import { EuiFormRow, EuiSuperSelect, EuiSuperSelectOption } from '@elastic/eui'; +import { EuiFormRow, EuiSuperSelect, EuiSuperSelectOption, EuiSwitch } from '@elastic/eui'; import React, { useEffect, useState } from 'react'; import useMount from 'react-use/lib/useMount'; +import { IFieldType, IIndexPattern } from '../../../../../../data/public'; import { ControlEditorProps, GetControlEditorComponentProps } from '../../types'; import { OptionsListEmbeddableInput, @@ -25,10 +26,15 @@ interface OptionsListEditorProps extends ControlEditorProps { } interface OptionsListEditorState { - availableIndexPatterns: Array>; - indexPattern?: string; - availableFields: Array>; - field?: string; + singleSelect?: boolean; + + indexPatternSelectOptions: Array>; + availableIndexPatterns?: { [key: string]: IIndexPattern }; + indexPattern?: IIndexPattern; + + fieldSelectOptions: Array>; + availableFields?: { [key: string]: IFieldType }; + field?: IFieldType; } export const OptionsListEditor = ({ @@ -41,12 +47,25 @@ export const OptionsListEditor = ({ const [state, setState] = useState({ indexPattern: initialInput?.indexPattern, field: initialInput?.field, - availableIndexPatterns: [], - availableFields: [], + singleSelect: initialInput?.singleSelect, + indexPatternSelectOptions: [], + fieldSelectOptions: [], }); - const applySelection = ({ field, indexPattern }: { field?: string; indexPattern?: string }) => { - const newState = { ...(field ? { field } : {}), ...(indexPattern ? { indexPattern } : {}) }; + const applySelection = ({ + field, + singleSelect, + indexPattern, + }: { + field?: IFieldType; + singleSelect?: boolean; + indexPattern?: IIndexPattern; + }) => { + const newState = { + ...(field ? { field } : {}), + ...(indexPattern ? { indexPattern } : {}), + ...(singleSelect !== undefined ? { singleSelect } : {}), + }; /** * apply state and run onChange concurrently. State is copied here rather than by subscribing to embeddable * input so that the same editor component can cover the 'create' use case. @@ -60,24 +79,43 @@ export const OptionsListEditor = ({ useMount(() => { (async () => { - const indexPatterns = (await fetchIndexPatterns()).map((indexPattern) => ({ - value: indexPattern, - inputDisplay: indexPattern, + const newIndexPatterns = await fetchIndexPatterns(); + const newAvailableIndexPatterns = newIndexPatterns.reduce( + (acc: { [key: string]: IIndexPattern }, curr) => ((acc[curr.title] = curr), acc), + {} + ); + const newIndexPatternSelectOptions = newIndexPatterns.map((indexPattern) => ({ + value: indexPattern.title, + inputDisplay: indexPattern.title, + })); + setState((currentState) => ({ + ...currentState, + availableIndexPatterns: newAvailableIndexPatterns, + indexPatternSelectOptions: newIndexPatternSelectOptions, })); - setState((currentState) => ({ ...currentState, availableIndexPatterns: indexPatterns })); })(); }); useEffect(() => { (async () => { - let availableFields: Array> = []; + let newFieldSelectOptions: Array> = []; + let newAvailableFields: { [key: string]: IFieldType } = {}; if (state.indexPattern) { - availableFields = (await fetchFields(state.indexPattern)).map((field) => ({ - value: field, - inputDisplay: field, + const newFields = await fetchFields(state.indexPattern); + newAvailableFields = newFields.reduce( + (acc: { [key: string]: IFieldType }, curr) => ((acc[curr.name] = curr), acc), + {} + ); + newFieldSelectOptions = newFields.map((field) => ({ + value: field.name, + inputDisplay: field.displayName ?? field.name, })); } - setState((currentState) => ({ ...currentState, availableFields })); + setState((currentState) => ({ + ...currentState, + fieldSelectOptions: newFieldSelectOptions, + availableFields: newAvailableFields, + })); })(); }, [state.indexPattern, fetchFields]); @@ -90,17 +128,26 @@ export const OptionsListEditor = ({ <> applySelection({ indexPattern })} - valueOfSelected={state.indexPattern} + options={state.indexPatternSelectOptions} + onChange={(patternTitle) => + applySelection({ indexPattern: state.availableIndexPatterns?.[patternTitle] }) + } + valueOfSelected={state.indexPattern?.title} /> applySelection({ field })} - valueOfSelected={state.field} + options={state.fieldSelectOptions} + onChange={(fieldName) => applySelection({ field: state.availableFields?.[fieldName] })} + valueOfSelected={state.field?.name} + /> + + + applySelection({ singleSelect: !e.target.checked })} /> diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx index 97a128c3e84eb..93330772d7cad 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_embeddable.tsx @@ -8,26 +8,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { merge, Subject } from 'rxjs'; import deepEqual from 'fast-deep-equal'; -import { EuiSelectableOption } from '@elastic/eui'; -import { tap, debounceTime, map, distinctUntilChanged } from 'rxjs/operators'; +import { merge, Subject, Subscription } from 'rxjs'; +import { tap, debounceTime, map, distinctUntilChanged, skip } from 'rxjs/operators'; -import { esFilters } from '../../../../../../data/public'; -import { OptionsListStrings } from './options_list_strings'; -import { Embeddable, IContainer } from '../../../../../../embeddable/public'; +import { isEqual } from 'lodash'; +import { ReduxEmbeddableWrapper } from '../../../redux_embeddables/redux_embeddable_wrapper'; import { InputControlInput, InputControlOutput } from '../../../../services/controls'; +import { esFilters, IIndexPattern, IFieldType } from '../../../../../../data/public'; +import { Embeddable, IContainer } from '../../../../../../embeddable/public'; import { OptionsListComponent, OptionsListComponentState } from './options_list_component'; - -const toggleAvailableOptions = ( - indices: number[], - availableOptions: EuiSelectableOption[], - enabled?: boolean -) => { - const newAvailableOptions = [...availableOptions]; - indices.forEach((index) => (newAvailableOptions[index].checked = enabled ? 'on' : undefined)); - return newAvailableOptions; -}; +import { optionsListReducers } from './options_list_reducers'; const diffDataFetchProps = ( current?: OptionsListDataFetchProps, @@ -42,28 +33,29 @@ const diffDataFetchProps = ( }; interface OptionsListDataFetchProps { - field: string; search?: string; - indexPattern: string; + field: IFieldType; + indexPattern: IIndexPattern; query?: InputControlInput['query']; filters?: InputControlInput['filters']; timeRange?: InputControlInput['timeRange']; } -export type OptionsListIndexPatternFetcher = () => Promise; // TODO: use the proper types here. -export type OptionsListFieldFetcher = (indexPattern: string) => Promise; // TODO: use the proper types here. +export type OptionsListIndexPatternFetcher = () => Promise; +export type OptionsListFieldFetcher = (indexPattern: IIndexPattern) => Promise; -export type OptionsListDataFetcher = ( - props: OptionsListDataFetchProps -) => Promise; +export type OptionsListDataFetcher = (props: OptionsListDataFetchProps) => Promise; export const OPTIONS_LIST_CONTROL = 'optionsListControl'; export interface OptionsListEmbeddableInput extends InputControlInput { - field: string; - indexPattern: string; + field: IFieldType; + indexPattern: IIndexPattern; + + selectedOptions?: string[]; singleSelect?: boolean; - defaultSelections?: string[]; + loading?: boolean; } + export class OptionsListEmbeddable extends Embeddable< OptionsListEmbeddableInput, InputControlOutput @@ -72,8 +64,8 @@ export class OptionsListEmbeddable extends Embeddable< private node?: HTMLElement; // internal state for this input control. - private selectedOptions: Set; private typeaheadSubject: Subject = new Subject(); + private searchString = ''; private componentState: OptionsListComponentState; private componentStateSubject$ = new Subject(); @@ -85,110 +77,74 @@ export class OptionsListEmbeddable extends Embeddable< this.componentStateSubject$.next(this.componentState); } + private subscriptions: Subscription = new Subscription(); + constructor( input: OptionsListEmbeddableInput, output: InputControlOutput, private fetchData: OptionsListDataFetcher, parent?: IContainer ) { - super(input, output, parent); + super({ ...input, loading: true }, output, parent); this.fetchData = fetchData; - // populate default selections from input - this.selectedOptions = new Set(input.defaultSelections ?? []); - const { selectedOptionsCount, selectedOptionsString } = this.buildSelectedOptionsString(); + const dataFetchPipe = this.getInput$().pipe( + map((newInput) => ({ + field: newInput.field, + indexPattern: newInput.indexPattern, + query: newInput.query, + filters: newInput.filters, + timeRange: newInput.timeRange, + })), + distinctUntilChanged(diffDataFetchProps) + ); - // fetch available options when input changes or when search string has changed + // push searchString changes into a debounced typeahead subject + this.typeaheadSubject = new Subject(); const typeaheadPipe = this.typeaheadSubject.pipe( - tap((newSearchString) => this.updateComponentState({ searchString: newSearchString })), - debounceTime(100) + tap((newSearchString) => (this.searchString = newSearchString), debounceTime(100)) ); - const inputPipe = this.getInput$().pipe( - map( - (newInput) => ({ - field: newInput.field, - indexPattern: newInput.indexPattern, - query: newInput.query, - filters: newInput.filters, - timeRange: newInput.timeRange, - }), - distinctUntilChanged(diffDataFetchProps) - ) + + // fetch available options when input changes or when search string has changed + this.subscriptions.add( + merge(dataFetchPipe, typeaheadPipe).subscribe(this.fetchAvailableOptions) ); - merge(typeaheadPipe, inputPipe).subscribe(this.fetchAvailableOptions); - // push changes from input into component state - this.getInput$().subscribe((newInput) => { - if (newInput.twoLineLayout !== this.componentState.twoLineLayout) - this.updateComponentState({ twoLineLayout: newInput.twoLineLayout }); - }); + // clear all selections when field or index pattern change + this.subscriptions.add( + this.getInput$() + .pipe( + distinctUntilChanged( + (a, b) => isEqual(a.field, b.field) && isEqual(a.indexPattern, b.indexPattern) + ), + skip(1) // skip the first change to preserve default selections after init + ) + .subscribe(() => this.updateInput({ selectedOptions: [] })) + ); - this.componentState = { - loading: true, - selectedOptionsCount, - selectedOptionsString, - twoLineLayout: input.twoLineLayout, - }; + this.componentState = { loading: true }; this.updateComponentState(this.componentState); } private fetchAvailableOptions = async () => { this.updateComponentState({ loading: true }); - const { indexPattern, timeRange, filters, field, query } = this.getInput(); - let newOptions = await this.fetchData({ - search: this.componentState.searchString, + const newOptions = await this.fetchData({ + search: this.searchString, indexPattern, timeRange, filters, field, query, }); - - // We now have new 'availableOptions', we need to ensure the selected options are still selected in the new list. - const enabledIndices: number[] = []; - this.selectedOptions?.forEach((selectedOption) => { - const optionIndex = newOptions.findIndex( - (availableOption) => availableOption.label === selectedOption - ); - if (optionIndex >= 0) enabledIndices.push(optionIndex); - }); - newOptions = toggleAvailableOptions(enabledIndices, newOptions, true); - this.updateComponentState({ loading: false, availableOptions: newOptions }); + this.updateComponentState({ availableOptions: newOptions, loading: false }); }; - private updateOption = (index: number) => { - const item = this.componentState.availableOptions?.[index]; - if (!item) return; - const toggleOff = item.checked === 'on'; - - // update availableOptions to show selection check marks - const newAvailableOptions = toggleAvailableOptions( - [index], - this.componentState.availableOptions ?? [], - !toggleOff - ); - this.componentState.availableOptions = newAvailableOptions; - - // update selectedOptions string - if (toggleOff) this.selectedOptions.delete(item.label); - else this.selectedOptions.add(item.label); - const { selectedOptionsString, selectedOptionsCount } = this.buildSelectedOptionsString(); - this.updateComponentState({ selectedOptionsString, selectedOptionsCount }); + public destroy = () => { + super.destroy(); + this.subscriptions.unsubscribe(); }; - private buildSelectedOptionsString(): { - selectedOptionsString: string; - selectedOptionsCount: number; - } { - const selectedOptionsArray = Array.from(this.selectedOptions ?? []); - const selectedOptionsString = selectedOptionsArray.join( - OptionsListStrings.summary.getSeparator() - ); - const selectedOptionsCount = selectedOptionsArray.length; - return { selectedOptionsString, selectedOptionsCount }; - } - reload = () => { this.fetchAvailableOptions(); }; @@ -199,11 +155,15 @@ export class OptionsListEmbeddable extends Embeddable< } this.node = node; ReactDOM.render( - , + + embeddable={this} + reducers={optionsListReducers} + > + + , node ); }; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx index 4bfce9eb377e9..35dca40a26ab9 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_popover_component.tsx @@ -6,76 +6,170 @@ * Side Public License, v 1. */ -import React from 'react'; +import React, { useMemo, useState } from 'react'; import { - EuiFieldSearch, EuiFilterSelectItem, - EuiIcon, EuiLoadingChart, EuiPopoverTitle, - EuiSelectableOption, + EuiFieldSearch, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiFormRow, EuiSpacer, + EuiIcon, } from '@elastic/eui'; -import { Subject } from 'rxjs'; import { OptionsListStrings } from './options_list_strings'; - -interface OptionsListPopoverProps { - loading: boolean; - typeaheadSubject: Subject; - searchString?: string; - updateOption: (index: number) => void; - availableOptions?: EuiSelectableOption[]; -} +import { useReduxEmbeddableContext } from '../../../redux_embeddables/redux_embeddable_context'; +import { OptionsListEmbeddableInput } from './options_list_embeddable'; +import { optionsListReducers } from './options_list_reducers'; +import { OptionsListComponentState } from './options_list_component'; export const OptionsListPopover = ({ loading, - updateOption, searchString, - typeaheadSubject, availableOptions, -}: OptionsListPopoverProps) => { + updateSearchString, +}: { + searchString: string; + loading: OptionsListComponentState['loading']; + updateSearchString: (newSearchString: string) => void; + availableOptions: OptionsListComponentState['availableOptions']; +}) => { + // Redux embeddable container Context + const { + useEmbeddableSelector, + useEmbeddableDispatch, + actions: { selectOption, deselectOption, clearSelections, replaceSelection }, + } = useReduxEmbeddableContext(); + + const dispatch = useEmbeddableDispatch(); + const { selectedOptions, singleSelect } = useEmbeddableSelector((state) => state); + + // track selectedOptions in a set for more efficient lookup + const selectedOptionsSet = useMemo(() => new Set(selectedOptions), [selectedOptions]); + const [showOnlySelected, setShowOnlySelected] = useState(false); + return ( <> - { - typeaheadSubject.next(event.target.value); - }} - value={searchString} - /> + + + + updateSearchString(event.target.value)} + value={searchString} + /> + + + + dispatch(clearSelections({}))} + /> + + + + + setShowOnlySelected(!showOnlySelected)} + /> + + + + +
- {!loading && - availableOptions && - availableOptions.map((item, index) => ( - updateOption(index)} - > - {item.label} - - ))} - {loading && ( -
-
- - -

{OptionsListStrings.popover.getLoadingMessage()}

-
-
- )} + {!showOnlySelected && ( + <> + {availableOptions?.map((availableOption, index) => ( + { + if (singleSelect) { + dispatch(replaceSelection(availableOption)); + return; + } + if (selectedOptionsSet.has(availableOption)) { + dispatch(deselectOption(availableOption)); + return; + } + dispatch(selectOption(availableOption)); + }} + > + {availableOption} + + ))} + {loading && ( +
+
+
+ + +

{OptionsListStrings.popover.getLoadingMessage()}

+
+
+
+ )} - {!loading && (!availableOptions || availableOptions.length === 0) && ( -
-
- - -

{OptionsListStrings.popover.getEmptyMessage()}

-
-
+ {!loading && (!availableOptions || availableOptions.length === 0) && ( +
+
+ + +

{OptionsListStrings.popover.getEmptyMessage()}

+
+
+ )} + + )} + {showOnlySelected && ( + <> + {selectedOptions && + selectedOptions.map((availableOption, index) => ( + dispatch(deselectOption(availableOption))} + > + {availableOption} + + ))} + {(!selectedOptions || selectedOptions.length === 0) && ( +
+
+ + +

{OptionsListStrings.popover.getSelectionsEmptyMessage()}

+
+
+ )} + )}
diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts new file mode 100644 index 0000000000000..3e4104f62f914 --- /dev/null +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_reducers.ts @@ -0,0 +1,43 @@ +/* + * 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 { PayloadAction } from '@reduxjs/toolkit'; +import { WritableDraft } from 'immer/dist/types/types-external'; + +import { OptionsListEmbeddableInput } from './options_list_embeddable'; + +export const optionsListReducers = { + deselectOption: ( + state: WritableDraft, + action: PayloadAction + ) => { + if (!state.selectedOptions) return; + const itemIndex = state.selectedOptions.indexOf(action.payload); + if (itemIndex !== -1) { + const newSelections = [...state.selectedOptions]; + newSelections.splice(itemIndex, 1); + state.selectedOptions = newSelections; + } + }, + selectOption: ( + state: WritableDraft, + action: PayloadAction + ) => { + if (!state.selectedOptions) state.selectedOptions = []; + state.selectedOptions?.push(action.payload); + }, + replaceSelection: ( + state: WritableDraft, + action: PayloadAction + ) => { + state.selectedOptions = [action.payload]; + }, + clearSelections: (state: WritableDraft) => { + state.selectedOptions = []; + }, +}; diff --git a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts index c07881020c9c2..40828f9e335f2 100644 --- a/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts +++ b/src/plugins/presentation_util/public/components/controls/control_types/options_list/options_list_strings.ts @@ -28,6 +28,10 @@ export const OptionsListStrings = { i18n.translate('presentationUtil.inputControls.optionsList.editor.fieldTitle', { defaultMessage: 'Field', }), + getAllowMultiselectTitle: () => + i18n.translate('presentationUtil.inputControls.optionsList.editor.allowMultiselectTitle', { + defaultMessage: 'Allow multiple selections in dropdown', + }), }, popover: { getLoadingMessage: () => @@ -38,5 +42,21 @@ export const OptionsListStrings = { i18n.translate('presentationUtil.inputControls.optionsList.popover.empty', { defaultMessage: 'No filters found', }), + getSelectionsEmptyMessage: () => + i18n.translate('presentationUtil.inputControls.optionsList.popover.selectionsEmpty', { + defaultMessage: 'You have no selections', + }), + getAllOptionsButtonTitle: () => + i18n.translate('presentationUtil.inputControls.optionsList.popover.allOptionsTitle', { + defaultMessage: 'Show all options', + }), + getSelectedOptionsButtonTitle: () => + i18n.translate('presentationUtil.inputControls.optionsList.popover.selectedOptionsTitle', { + defaultMessage: 'Show only selected options', + }), + getClearAllSelectionsButtonTitle: () => + i18n.translate('presentationUtil.inputControls.optionsList.popover.clearAllSelectionsTitle', { + defaultMessage: 'Clear selections', + }), }, }; diff --git a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx index a4912b5b5f2fc..4a112f7d6e574 100644 --- a/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx +++ b/src/plugins/presentation_util/public/components/redux_embeddables/redux_embeddable_wrapper.tsx @@ -6,12 +6,17 @@ * Side Public License, v 1. */ -import React, { PropsWithChildren, useEffect, useMemo, useRef } from 'react'; import { Provider, TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import { SliceCaseReducers, PayloadAction, createSlice } from '@reduxjs/toolkit'; +import React, { PropsWithChildren, useEffect, useMemo, useRef } from 'react'; import { Draft } from 'immer/dist/types/types-external'; import { isEqual } from 'lodash'; -import { SliceCaseReducers, PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { + ReduxContainerContextServices, + ReduxEmbeddableContextServices, + ReduxEmbeddableWrapperProps, +} from './types'; import { IEmbeddable, EmbeddableInput, @@ -19,11 +24,6 @@ import { IContainer, } from '../../../../embeddable/public'; import { getManagedEmbeddablesStore } from './generic_embeddable_store'; -import { - ReduxContainerContextServices, - ReduxEmbeddableContextServices, - ReduxEmbeddableWrapperProps, -} from './types'; import { ReduxEmbeddableContext, useReduxEmbeddableContext } from './redux_embeddable_context'; const getDefaultProps = (): Required< @@ -139,18 +139,22 @@ const ReduxEmbeddableSync = state); const stateRef = useRef(currentState); - // When Embeddable Input changes, push differences to redux. useEffect(() => { - embeddable.getInput$().subscribe(() => { - const differences = diffInput(embeddable.getInput(), stateRef.current); - if (differences && Object.keys(differences).length > 0) { - dispatch(updateEmbeddableReduxState(differences)); - } - }); + // When Embeddable Input changes, push differences to redux. + const inputSubscription = embeddable + .getInput$() + // .pipe(debounceTime(0)) // debounce input changes to ensure that when many updates are made in one render the latest wins out + .subscribe(() => { + const differences = diffInput(embeddable.getInput(), stateRef.current); + if (differences && Object.keys(differences).length > 0) { + dispatch(updateEmbeddableReduxState(differences)); + } + }); + return () => inputSubscription.unsubscribe(); }, [diffInput, dispatch, embeddable, updateEmbeddableReduxState]); - // When redux state changes, push differences to Embeddable Input. useEffect(() => { + // When redux state changes, push differences to Embeddable Input. stateRef.current = currentState; const differences = diffInput(currentState, embeddable.getInput()); if (differences && Object.keys(differences).length > 0) { diff --git a/x-pack/plugins/apm/common/runtime_types/comparison_type_rt.ts b/x-pack/plugins/apm/common/runtime_types/comparison_type_rt.ts new file mode 100644 index 0000000000000..93c0a31c40cde --- /dev/null +++ b/x-pack/plugins/apm/common/runtime_types/comparison_type_rt.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ +import * as t from 'io-ts'; + +export enum TimeRangeComparisonEnum { + WeekBefore = 'week', + DayBefore = 'day', + PeriodBefore = 'period', +} + +export const comparisonTypeRt = t.union([ + t.literal('day'), + t.literal('week'), + t.literal('period'), +]); + +export type TimeRangeComparisonType = t.TypeOf; diff --git a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_detail_dependencies_table.tsx b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_detail_dependencies_table.tsx index be493f8a98b1c..57efea4ffdcac 100644 --- a/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_detail_dependencies_table.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_detail_overview/backend_detail_dependencies_table.tsx @@ -74,7 +74,7 @@ export function BackendDetailDependenciesTable() { serviceName={location.serviceName} agentName={location.agentName} query={{ - comparisonEnabled: comparisonEnabled ? 'true' : 'false', + comparisonEnabled, comparisonType, environment, kuery, diff --git a/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx b/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx index 05eb9892fc108..c214c4348bbe7 100644 --- a/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx +++ b/x-pack/plugins/apm/public/components/app/backend_inventory/backend_inventory_dependencies_table/index.tsx @@ -68,7 +68,7 @@ export function BackendInventoryDependenciesTable() { type={location.spanType} subtype={location.spanSubtype} query={{ - comparisonEnabled: comparisonEnabled ? 'true' : 'false', + comparisonEnabled, comparisonType, environment, kuery, diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/Distribution/index.stories.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/Distribution/index.stories.tsx index 4601f1db0277d..4efc00ef71b91 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/Distribution/index.stories.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/Distribution/index.stories.tsx @@ -12,6 +12,7 @@ import { ApmPluginContextValue, } from '../../../../context/apm_plugin/apm_plugin_context'; import { ErrorDistribution } from './'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; export default { title: 'app/ErrorGroupDetails/Distribution', @@ -39,9 +40,26 @@ export default { export function Example() { const distribution = { - noHits: false, bucketSize: 62350, - buckets: [ + currentPeriod: [ + { key: 1624279912350, count: 6 }, + { key: 1624279974700, count: 1 }, + { key: 1624280037050, count: 2 }, + { key: 1624280099400, count: 3 }, + { key: 1624280161750, count: 13 }, + { key: 1624280224100, count: 1 }, + { key: 1624280286450, count: 2 }, + { key: 1624280348800, count: 0 }, + { key: 1624280411150, count: 4 }, + { key: 1624280473500, count: 4 }, + { key: 1624280535850, count: 1 }, + { key: 1624280598200, count: 4 }, + { key: 1624280660550, count: 0 }, + { key: 1624280722900, count: 2 }, + { key: 1624280785250, count: 3 }, + { key: 1624280847600, count: 0 }, + ], + previousPeriod: [ { key: 1624279912350, count: 6 }, { key: 1624279974700, count: 1 }, { key: 1624280037050, count: 2 }, @@ -61,16 +79,23 @@ export function Example() { ], }; - return ; + return ( + + ); } export function EmptyState() { return ( diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/Distribution/index.tsx index 3d1d0ee564ba4..429ad989b9738 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/Distribution/index.tsx @@ -8,28 +8,30 @@ import { Axis, Chart, - HistogramBarSeries, + BarSeries, niceTimeFormatter, Position, ScaleType, Settings, - SettingsSpec, - TooltipValue, } from '@elastic/charts'; import { EuiTitle } from '@elastic/eui'; -import d3 from 'd3'; import React, { Suspense, useState } from 'react'; import type { ALERT_RULE_TYPE_ID as ALERT_RULE_TYPE_ID_TYPED } from '@kbn/rule-data-utils'; // @ts-expect-error import { ALERT_RULE_TYPE_ID as ALERT_RULE_TYPE_ID_NON_TYPED } from '@kbn/rule-data-utils/target_node/technical_field_names'; +import { i18n } from '@kbn/i18n'; import { useApmServiceContext } from '../../../../context/apm_service/use_apm_service_context'; import { APIReturnType } from '../../../../services/rest/createCallApmApi'; -import { asRelativeDateTimeRange } from '../../../../../common/utils/formatters'; +import { offsetPreviousPeriodCoordinates } from '../../../../../common/utils/offset_previous_period_coordinate'; import { useTheme } from '../../../../hooks/use_theme'; +import { FETCH_STATUS } from '../../../../hooks/use_fetcher'; import { AlertType } from '../../../../../common/alert_types'; import { getAlertAnnotations } from '../../../shared/charts/helper/get_alert_annotations'; +import { ChartContainer } from '../../../shared/charts/chart_container'; import { useApmPluginContext } from '../../../../context/apm_plugin/use_apm_plugin_context'; import { LazyAlertsFlyout } from '../../../../../../observability/public'; +import { useUrlParams } from '../../../../context/url_params_context/use_url_params'; +import { Coordinate } from '../../../../../typings/timeseries'; const ALERT_RULE_TYPE_ID: typeof ALERT_RULE_TYPE_ID_TYPED = ALERT_RULE_TYPE_ID_NON_TYPED; @@ -37,70 +39,85 @@ const ALERT_RULE_TYPE_ID: typeof ALERT_RULE_TYPE_ID_TYPED = type ErrorDistributionAPIResponse = APIReturnType<'GET /internal/apm/services/{serviceName}/errors/distribution'>; -interface FormattedBucket { - x0: number; - x: number; - y: number | undefined; -} - -export function getFormattedBuckets( - buckets: ErrorDistributionAPIResponse['buckets'], - bucketSize: number -): FormattedBucket[] { +export function getCoordinatedBuckets( + buckets: + | ErrorDistributionAPIResponse['currentPeriod'] + | ErrorDistributionAPIResponse['previousPeriod'] +): Coordinate[] { return buckets.map(({ count, key }) => { return { - x0: key, - x: key + bucketSize, + x: key, y: count, }; }); } - interface Props { + fetchStatus: FETCH_STATUS; distribution: ErrorDistributionAPIResponse; title: React.ReactNode; } -export function ErrorDistribution({ distribution, title }: Props) { +export function ErrorDistribution({ distribution, title, fetchStatus }: Props) { const theme = useTheme(); - const buckets = getFormattedBuckets( - distribution.buckets, - distribution.bucketSize - ); + const currentPeriod = getCoordinatedBuckets(distribution.currentPeriod); + const previousPeriod = getCoordinatedBuckets(distribution.previousPeriod); - const xMin = d3.min(buckets, (d) => d.x0); - const xMax = d3.max(buckets, (d) => d.x0); + const { urlParams } = useUrlParams(); + const { comparisonEnabled } = urlParams; - const xFormatter = niceTimeFormatter([xMin, xMax]); - const { observabilityRuleTypeRegistry } = useApmPluginContext(); + const timeseries = [ + { + data: currentPeriod, + color: theme.eui.euiColorVis1, + title: i18n.translate('xpack.apm.errorGroup.chart.ocurrences', { + defaultMessage: 'Occurences', + }), + }, + ...(comparisonEnabled + ? [ + { + data: offsetPreviousPeriodCoordinates({ + currentPeriodTimeseries: currentPeriod, + previousPeriodTimeseries: previousPeriod, + }), + color: theme.eui.euiColorMediumShade, + title: i18n.translate( + 'xpack.apm.errorGroup.chart.ocurrences.previousPeriodLabel', + { defaultMessage: 'Previous period' } + ), + }, + ] + : []), + ]; + + const xValues = timeseries.flatMap(({ data }) => data.map(({ x }) => x)); + const min = Math.min(...xValues); + const max = Math.max(...xValues); + + const xFormatter = niceTimeFormatter([min, max]); + const { observabilityRuleTypeRegistry } = useApmPluginContext(); const { alerts } = useApmServiceContext(); const { getFormatter } = observabilityRuleTypeRegistry; const [selectedAlertId, setSelectedAlertId] = useState( undefined ); - const tooltipProps: SettingsSpec['tooltip'] = { - stickTo: 'top', - headerFormatter: (tooltip: TooltipValue) => { - const serie = buckets.find((bucket) => bucket.x0 === tooltip.value); - if (serie) { - return asRelativeDateTimeRange(serie.x0, serie.x); - } - return `${tooltip.value}`; - }, - }; - return ( <> {title} -
+ - + + {timeseries.map((serie) => { + return ( + + ); + })} {getAlertAnnotations({ alerts: alerts?.filter( (alert) => alert[ALERT_RULE_TYPE_ID]?.[0] === AlertType.ErrorCount ), - chartStartTime: buckets[0]?.x0, + chartStartTime: xValues[0], getFormatter, selectedAlertId, setSelectedAlertId, @@ -150,7 +172,7 @@ export function ErrorDistribution({ distribution, title }: Props) { /> -
+ ); } diff --git a/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx b/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx index 0114348892984..bc12b0c64f179 100644 --- a/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx +++ b/x-pack/plugins/apm/public/components/app/error_group_details/index.tsx @@ -146,7 +146,7 @@ export function ErrorGroupDetails() { [environment, kuery, serviceName, start, end, groupId] ); - const { errorDistributionData } = useErrorGroupDistributionFetcher({ + const { errorDistributionData, status } = useErrorGroupDistributionFetcher({ serviceName, groupId, environment, @@ -209,6 +209,7 @@ export function ErrorGroupDetails() { )} - - - - - + + + + + + + + + + + + diff --git a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx index 945d977e30362..d62955b593df1 100644 --- a/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx +++ b/x-pack/plugins/apm/public/components/app/service_inventory/service_inventory.test.tsx @@ -13,6 +13,7 @@ import { MemoryRouter } from 'react-router-dom'; import { EuiThemeProvider } from '../../../../../../../src/plugins/kibana_react/common'; import { createKibanaReactContext } from '../../../../../../../src/plugins/kibana_react/public'; import { ServiceHealthStatus } from '../../../../common/service_health_status'; +import { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; import { ServiceInventory } from '.'; import { ApmPluginContextValue } from '../../../context/apm_plugin/apm_plugin_context'; import { @@ -25,7 +26,6 @@ import * as useDynamicIndexPatternHooks from '../../../hooks/use_dynamic_index_p import { SessionStorageMock } from '../../../services/__mocks__/SessionStorageMock'; import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; import * as hook from '../../../context/anomaly_detection_jobs/use_anomaly_detection_jobs_context'; -import { TimeRangeComparisonType } from '../../shared/time_comparison/get_time_range_comparison'; const KibanaReactContext = createKibanaReactContext({ usageCollection: { reportUiCounter: () => {} }, @@ -60,7 +60,7 @@ function wrapper({ children }: { children?: ReactNode }) { start: '2021-02-12T13:20:43.344Z', end: '2021-02-12T13:20:58.344Z', comparisonEnabled: true, - comparisonType: TimeRangeComparisonType.DayBefore, + comparisonType: TimeRangeComparisonEnum.DayBefore, }} > {children} diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index c73d412fb4506..557854dd2692b 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -16,7 +16,7 @@ import { ChartPointerEventContextProvider } from '../../../context/chart_pointer import { useBreakpoints } from '../../../hooks/use_breakpoints'; import { LatencyChart } from '../../shared/charts/latency_chart'; import { TransactionBreakdownChart } from '../../shared/charts/transaction_breakdown_chart'; -import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; +import { FailedTransactionRateChart } from '../../shared/charts/failed_transaction_rate_chart'; import { ServiceOverviewDependenciesTable } from './service_overview_dependencies_table'; import { ServiceOverviewErrorsTable } from './service_overview_errors_table'; import { ServiceOverviewInstancesChartAndTable } from './service_overview_instances_chart_and_table'; @@ -138,7 +138,7 @@ export function ServiceOverview() { > {!isRumAgent && ( - , params: t.partial({ query: t.partial({ - comparisonEnabled: t.string, - comparisonType: t.string, + comparisonEnabled: toBooleanRt, + comparisonType: comparisonTypeRt, }), }), children: [ diff --git a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx index 16cba23da6423..4afa10cbf9a5d 100644 --- a/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx +++ b/x-pack/plugins/apm/public/components/routing/service_detail/index.tsx @@ -8,6 +8,8 @@ import * as t from 'io-ts'; import { i18n } from '@kbn/i18n'; import React from 'react'; import { Outlet } from '@kbn/typed-react-router-config'; +import { toBooleanRt } from '@kbn/io-ts-utils'; +import { comparisonTypeRt } from '../../../../common/runtime_types/comparison_type_rt'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; import { environmentRt } from '../../../../common/environment_rt'; import { ServiceOverview } from '../../app/service_overview'; @@ -79,8 +81,8 @@ export const serviceDetail = { kuery: t.string, }), t.partial({ - comparisonEnabled: t.string, - comparisonType: t.string, + comparisonEnabled: toBooleanRt, + comparisonType: comparisonTypeRt, latencyAggregationType: t.string, transactionType: t.string, refreshPaused: t.union([t.literal('true'), t.literal('false')]), @@ -162,6 +164,9 @@ export const serviceDetail = { defaultMessage: 'Errors', }), element: , + searchBarOptions: { + showTimeComparison: true, + }, }), params: t.partial({ query: t.partial({ diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/failed_transaction_rate_chart/index.tsx similarity index 99% rename from x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx rename to x-pack/plugins/apm/public/components/shared/charts/failed_transaction_rate_chart/index.tsx index 95b73a5276b8a..cf57f618940b4 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_error_rate_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/failed_transaction_rate_chart/index.tsx @@ -51,7 +51,7 @@ const INITIAL_STATE: ErrorRate = { }, }; -export function TransactionErrorRateChart({ +export function FailedTransactionRateChart({ height, showAnnotations = true, environment, diff --git a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx index 4fdce0dfa705e..9ff128657dbb1 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/transaction_charts/index.tsx @@ -12,7 +12,7 @@ import { ChartPointerEventContextProvider } from '../../../../context/chart_poin import { ServiceOverviewThroughputChart } from '../../../app/service_overview/service_overview_throughput_chart'; import { LatencyChart } from '../latency_chart'; import { TransactionBreakdownChart } from '../transaction_breakdown_chart'; -import { TransactionErrorRateChart } from '../transaction_error_rate_chart/'; +import { FailedTransactionRateChart } from '../failed_transaction_rate_chart'; export function TransactionCharts({ kuery, @@ -55,7 +55,7 @@ export function TransactionCharts({ - diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_types.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_types.ts index 0115718ac07a9..97754cd91fd3e 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_types.ts +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_comparison_types.ts @@ -6,8 +6,8 @@ */ import moment from 'moment'; +import { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; import { getDateDifference } from '../../../../common/utils/formatters'; -import { TimeRangeComparisonType } from './get_time_range_comparison'; export function getComparisonTypes({ start, @@ -29,17 +29,17 @@ export function getComparisonTypes({ // Less than or equals to one day if (dateDiff <= 1) { return [ - TimeRangeComparisonType.DayBefore, - TimeRangeComparisonType.WeekBefore, + TimeRangeComparisonEnum.DayBefore, + TimeRangeComparisonEnum.WeekBefore, ]; } // Less than or equals to one week if (dateDiff <= 7) { - return [TimeRangeComparisonType.WeekBefore]; + return [TimeRangeComparisonEnum.WeekBefore]; } // } // above one week or when rangeTo is not "now" - return [TimeRangeComparisonType.PeriodBefore]; + return [TimeRangeComparisonEnum.PeriodBefore]; } diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts index da903e42bd3c7..7e67d76c2ada2 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.test.ts @@ -4,10 +4,8 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ -import { - getTimeRangeComparison, - TimeRangeComparisonType, -} from './get_time_range_comparison'; +import { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; +import { getTimeRangeComparison } from './get_time_range_comparison'; describe('getTimeRangeComparison', () => { describe('return empty object', () => { @@ -16,7 +14,7 @@ describe('getTimeRangeComparison', () => { const result = getTimeRangeComparison({ start: undefined, end, - comparisonType: TimeRangeComparisonType.DayBefore, + comparisonType: TimeRangeComparisonEnum.DayBefore, comparisonEnabled: false, }); expect(result).toEqual({}); @@ -26,7 +24,7 @@ describe('getTimeRangeComparison', () => { const result = getTimeRangeComparison({ start: undefined, end, - comparisonType: TimeRangeComparisonType.DayBefore, + comparisonType: TimeRangeComparisonEnum.DayBefore, comparisonEnabled: true, }); expect(result).toEqual({}); @@ -37,7 +35,7 @@ describe('getTimeRangeComparison', () => { const result = getTimeRangeComparison({ start, end: undefined, - comparisonType: TimeRangeComparisonType.DayBefore, + comparisonType: TimeRangeComparisonEnum.DayBefore, comparisonEnabled: true, }); expect(result).toEqual({}); @@ -50,7 +48,7 @@ describe('getTimeRangeComparison', () => { const start = '2021-01-28T14:45:00.000Z'; const end = '2021-01-28T15:00:00.000Z'; const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonType.DayBefore, + comparisonType: TimeRangeComparisonEnum.DayBefore, comparisonEnabled: true, start, end, @@ -65,7 +63,7 @@ describe('getTimeRangeComparison', () => { const start = '2021-01-28T14:45:00.000Z'; const end = '2021-01-28T15:00:00.000Z'; const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonType.WeekBefore, + comparisonType: TimeRangeComparisonEnum.WeekBefore, comparisonEnabled: true, start, end, @@ -82,7 +80,7 @@ describe('getTimeRangeComparison', () => { const result = getTimeRangeComparison({ start, end, - comparisonType: TimeRangeComparisonType.PeriodBefore, + comparisonType: TimeRangeComparisonEnum.PeriodBefore, comparisonEnabled: true, }); expect(result).toEqual({ @@ -100,7 +98,7 @@ describe('getTimeRangeComparison', () => { const start = '2021-01-26T15:00:00.000Z'; const end = '2021-01-28T15:00:00.000Z'; const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonType.WeekBefore, + comparisonType: TimeRangeComparisonEnum.WeekBefore, comparisonEnabled: true, start, end, @@ -117,7 +115,7 @@ describe('getTimeRangeComparison', () => { const start = '2021-01-10T15:00:00.000Z'; const end = '2021-01-18T15:00:00.000Z'; const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonType.PeriodBefore, + comparisonType: TimeRangeComparisonEnum.PeriodBefore, comparisonEnabled: true, start, end, @@ -131,7 +129,7 @@ describe('getTimeRangeComparison', () => { const start = '2021-01-01T15:00:00.000Z'; const end = '2021-01-31T15:00:00.000Z'; const result = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonType.PeriodBefore, + comparisonType: TimeRangeComparisonEnum.PeriodBefore, comparisonEnabled: true, start, end, diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts index d9f9a249f1320..547be69ff6298 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/get_time_range_comparison.ts @@ -7,14 +7,12 @@ import moment from 'moment'; import { EuiTheme } from 'src/plugins/kibana_react/common'; +import { + TimeRangeComparisonType, + TimeRangeComparisonEnum, +} from '../../../../common/runtime_types/comparison_type_rt'; import { getDateDifference } from '../../../../common/utils/formatters'; -export enum TimeRangeComparisonType { - WeekBefore = 'week', - DayBefore = 'day', - PeriodBefore = 'period', -} - export function getComparisonChartTheme(theme: EuiTheme) { return { areaSeriesStyle: { @@ -63,17 +61,17 @@ export function getTimeRangeComparison({ let offset: string; switch (comparisonType) { - case TimeRangeComparisonType.DayBefore: + case TimeRangeComparisonEnum.DayBefore: diff = oneDayInMilliseconds; offset = '1d'; break; - case TimeRangeComparisonType.WeekBefore: + case TimeRangeComparisonEnum.WeekBefore: diff = oneWeekInMilliseconds; offset = '1w'; break; - case TimeRangeComparisonType.PeriodBefore: + case TimeRangeComparisonEnum.PeriodBefore: diff = getDateDifference({ start: startMoment, end: endMoment, diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx index ce7d05d467291..e20a6df12ad46 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.test.tsx @@ -16,10 +16,13 @@ import { import { getSelectOptions, TimeComparison } from './'; import * as urlHelpers from '../../shared/Links/url_helpers'; import moment from 'moment'; -import { TimeRangeComparisonType } from './get_time_range_comparison'; import { getComparisonTypes } from './get_comparison_types'; import { MockApmPluginContextWrapper } from '../../../context/apm_plugin/mock_apm_plugin_context'; import { ENVIRONMENT_ALL } from '../../../../common/environment_filter_values'; +import { + TimeRangeComparisonType, + TimeRangeComparisonEnum, +} from '../../../../common/runtime_types/comparison_type_rt'; import { MockUrlParamsContextProvider } from '../../../context/url_params_context/mock_url_params_context_provider'; function getWrapper({ @@ -68,8 +71,8 @@ describe('TimeComparison', () => { end: '2021-06-04T16:32:02.335Z', }) ).toEqual([ - TimeRangeComparisonType.DayBefore.valueOf(), - TimeRangeComparisonType.WeekBefore.valueOf(), + TimeRangeComparisonEnum.DayBefore.valueOf(), + TimeRangeComparisonEnum.WeekBefore.valueOf(), ]); }); @@ -80,8 +83,8 @@ describe('TimeComparison', () => { end: '2021-06-05T03:59:59.999Z', }) ).toEqual([ - TimeRangeComparisonType.DayBefore.valueOf(), - TimeRangeComparisonType.WeekBefore.valueOf(), + TimeRangeComparisonEnum.DayBefore.valueOf(), + TimeRangeComparisonEnum.WeekBefore.valueOf(), ]); }); @@ -92,8 +95,8 @@ describe('TimeComparison', () => { end: '2021-06-04T16:31:35.748Z', }) ).toEqual([ - TimeRangeComparisonType.DayBefore.valueOf(), - TimeRangeComparisonType.WeekBefore.valueOf(), + TimeRangeComparisonEnum.DayBefore.valueOf(), + TimeRangeComparisonEnum.WeekBefore.valueOf(), ]); }); @@ -104,8 +107,8 @@ describe('TimeComparison', () => { end: '2021-10-14T00:52:59.553Z', }) ).toEqual([ - TimeRangeComparisonType.DayBefore.valueOf(), - TimeRangeComparisonType.WeekBefore.valueOf(), + TimeRangeComparisonEnum.DayBefore.valueOf(), + TimeRangeComparisonEnum.WeekBefore.valueOf(), ]); }); @@ -115,7 +118,7 @@ describe('TimeComparison', () => { start: '2021-06-02T12:32:00.000Z', end: '2021-06-03T13:32:09.079Z', }) - ).toEqual([TimeRangeComparisonType.WeekBefore.valueOf()]); + ).toEqual([TimeRangeComparisonEnum.WeekBefore.valueOf()]); }); it('shows week before when 7 days is selected', () => { @@ -124,7 +127,7 @@ describe('TimeComparison', () => { start: '2021-05-28T16:32:17.520Z', end: '2021-06-04T16:32:17.520Z', }) - ).toEqual([TimeRangeComparisonType.WeekBefore.valueOf()]); + ).toEqual([TimeRangeComparisonEnum.WeekBefore.valueOf()]); }); it('shows period before when 8 days is selected', () => { expect( @@ -132,7 +135,7 @@ describe('TimeComparison', () => { start: '2021-05-27T16:32:46.747Z', end: '2021-06-04T16:32:46.747Z', }) - ).toEqual([TimeRangeComparisonType.PeriodBefore.valueOf()]); + ).toEqual([TimeRangeComparisonEnum.PeriodBefore.valueOf()]); }); }); @@ -141,24 +144,24 @@ describe('TimeComparison', () => { expect( getSelectOptions({ comparisonTypes: [ - TimeRangeComparisonType.DayBefore, - TimeRangeComparisonType.WeekBefore, - TimeRangeComparisonType.PeriodBefore, + TimeRangeComparisonEnum.DayBefore, + TimeRangeComparisonEnum.WeekBefore, + TimeRangeComparisonEnum.PeriodBefore, ], start: '2021-05-27T16:32:46.747Z', end: '2021-06-04T16:32:46.747Z', }) ).toEqual([ { - value: TimeRangeComparisonType.DayBefore.valueOf(), + value: TimeRangeComparisonEnum.DayBefore.valueOf(), text: 'Day before', }, { - value: TimeRangeComparisonType.WeekBefore.valueOf(), + value: TimeRangeComparisonEnum.WeekBefore.valueOf(), text: 'Week before', }, { - value: TimeRangeComparisonType.PeriodBefore.valueOf(), + value: TimeRangeComparisonEnum.PeriodBefore.valueOf(), text: '19/05 18:32 - 27/05 18:32', }, ]); @@ -167,13 +170,13 @@ describe('TimeComparison', () => { it('formats period before as DD/MM/YY HH:mm when range years are different', () => { expect( getSelectOptions({ - comparisonTypes: [TimeRangeComparisonType.PeriodBefore], + comparisonTypes: [TimeRangeComparisonEnum.PeriodBefore], start: '2020-05-27T16:32:46.747Z', end: '2021-06-04T16:32:46.747Z', }) ).toEqual([ { - value: TimeRangeComparisonType.PeriodBefore.valueOf(), + value: TimeRangeComparisonEnum.PeriodBefore.valueOf(), text: '20/05/19 18:32 - 27/05/20 18:32', }, ]); @@ -195,7 +198,7 @@ describe('TimeComparison', () => { expect(spy).toHaveBeenCalledWith(expect.anything(), { query: { comparisonEnabled: 'true', - comparisonType: TimeRangeComparisonType.DayBefore, + comparisonType: TimeRangeComparisonEnum.DayBefore, }, }); }); @@ -204,7 +207,7 @@ describe('TimeComparison', () => { exactStart: '2021-06-04T16:17:02.335Z', exactEnd: '2021-06-04T16:32:02.335Z', comparisonEnabled: true, - comparisonType: TimeRangeComparisonType.DayBefore, + comparisonType: TimeRangeComparisonEnum.DayBefore, }); const component = render(, { wrapper: Wrapper }); expectTextsInDocument(component, ['Day before', 'Week before']); @@ -219,7 +222,7 @@ describe('TimeComparison', () => { exactStart: '2021-06-03T16:31:35.748Z', exactEnd: '2021-06-04T16:31:35.748Z', comparisonEnabled: true, - comparisonType: TimeRangeComparisonType.DayBefore, + comparisonType: TimeRangeComparisonEnum.DayBefore, }); const component = render(, { wrapper: Wrapper }); expectTextsInDocument(component, ['Day before', 'Week before']); @@ -236,7 +239,7 @@ describe('TimeComparison', () => { exactStart: '2021-06-02T12:32:00.000Z', exactEnd: '2021-06-03T13:32:09.079Z', comparisonEnabled: true, - comparisonType: TimeRangeComparisonType.WeekBefore, + comparisonType: TimeRangeComparisonEnum.WeekBefore, }); const component = render(, { wrapper: Wrapper, @@ -255,7 +258,7 @@ describe('TimeComparison', () => { expect(spy).toHaveBeenCalledWith(expect.anything(), { query: { comparisonEnabled: 'true', - comparisonType: TimeRangeComparisonType.WeekBefore, + comparisonType: TimeRangeComparisonEnum.WeekBefore, }, }); }); @@ -264,7 +267,7 @@ describe('TimeComparison', () => { exactStart: '2021-06-02T12:32:00.000Z', exactEnd: '2021-06-03T13:32:09.079Z', comparisonEnabled: true, - comparisonType: TimeRangeComparisonType.WeekBefore, + comparisonType: TimeRangeComparisonEnum.WeekBefore, }); const component = render(, { wrapper: Wrapper, @@ -284,7 +287,7 @@ describe('TimeComparison', () => { exactStart: '2021-05-27T16:32:46.747Z', exactEnd: '2021-06-04T16:32:46.747Z', comparisonEnabled: true, - comparisonType: TimeRangeComparisonType.PeriodBefore, + comparisonType: TimeRangeComparisonEnum.PeriodBefore, }); const component = render(, { wrapper: Wrapper, @@ -302,7 +305,7 @@ describe('TimeComparison', () => { exactStart: '2020-05-27T16:32:46.747Z', exactEnd: '2021-06-04T16:32:46.747Z', comparisonEnabled: true, - comparisonType: TimeRangeComparisonType.PeriodBefore, + comparisonType: TimeRangeComparisonEnum.PeriodBefore, }); const component = render(, { wrapper: Wrapper, diff --git a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx index d5e38a3df7aac..35a6bc7634813 100644 --- a/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/time_comparison/index.tsx @@ -12,16 +12,14 @@ import React from 'react'; import { useHistory } from 'react-router-dom'; import { euiStyled } from '../../../../../../../src/plugins/kibana_react/common'; import { useUiTracker } from '../../../../../observability/public'; +import { TimeRangeComparisonEnum } from '../../../../common/runtime_types/comparison_type_rt'; import { useUrlParams } from '../../../context/url_params_context/use_url_params'; import { useApmParams } from '../../../hooks/use_apm_params'; import { useBreakpoints } from '../../../hooks/use_breakpoints'; import { useTimeRange } from '../../../hooks/use_time_range'; import * as urlHelpers from '../../shared/Links/url_helpers'; import { getComparisonTypes } from './get_comparison_types'; -import { - getTimeRangeComparison, - TimeRangeComparisonType, -} from './get_time_range_comparison'; +import { getTimeRangeComparison } from './get_time_range_comparison'; const PrependContainer = euiStyled.div` display: flex; @@ -66,13 +64,13 @@ export function getSelectOptions({ start, end, }: { - comparisonTypes: TimeRangeComparisonType[]; + comparisonTypes: TimeRangeComparisonEnum[]; start?: string; end?: string; }) { return comparisonTypes.map((value) => { switch (value) { - case TimeRangeComparisonType.DayBefore: { + case TimeRangeComparisonEnum.DayBefore: { return { value, text: i18n.translate('xpack.apm.timeComparison.select.dayBefore', { @@ -80,7 +78,7 @@ export function getSelectOptions({ }), }; } - case TimeRangeComparisonType.WeekBefore: { + case TimeRangeComparisonEnum.WeekBefore: { return { value, text: i18n.translate('xpack.apm.timeComparison.select.weekBefore', { @@ -88,9 +86,9 @@ export function getSelectOptions({ }), }; } - case TimeRangeComparisonType.PeriodBefore: { + case TimeRangeComparisonEnum.PeriodBefore: { const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ - comparisonType: TimeRangeComparisonType.PeriodBefore, + comparisonType: TimeRangeComparisonEnum.PeriodBefore, start, end, comparisonEnabled: true, diff --git a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts index 32771bd56a72a..845fdb175bb65 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/resolve_url_params.ts @@ -6,12 +6,12 @@ */ import { Location } from 'history'; +import { TimeRangeComparisonType } from '../../../common/runtime_types/comparison_type_rt'; import { uxLocalUIFilterNames } from '../../../common/ux_ui_filter'; import { ENVIRONMENT_ALL } from '../../../common/environment_filter_values'; import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; import { pickKeys } from '../../../common/utils/pick_keys'; import { toQuery } from '../../components/shared/Links/url_helpers'; -import { TimeRangeComparisonType } from '../../components/shared/time_comparison/get_time_range_comparison'; import { getDateRange, removeUndefinedProps, diff --git a/x-pack/plugins/apm/public/context/url_params_context/types.ts b/x-pack/plugins/apm/public/context/url_params_context/types.ts index 4deef1662c236..8f167fc0ab734 100644 --- a/x-pack/plugins/apm/public/context/url_params_context/types.ts +++ b/x-pack/plugins/apm/public/context/url_params_context/types.ts @@ -5,9 +5,9 @@ * 2.0. */ +import { TimeRangeComparisonType } from '../../../common/runtime_types/comparison_type_rt'; import { LatencyAggregationType } from '../../../common/latency_aggregation_types'; import { UxLocalUIFilterName } from '../../../common/ux_ui_filter'; -import { TimeRangeComparisonType } from '../../components/shared/time_comparison/get_time_range_comparison'; export type UrlParams = { detailTab?: string; diff --git a/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx b/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx index 120cbba952f3e..2878353da8eb7 100644 --- a/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx +++ b/x-pack/plugins/apm/public/hooks/use_error_group_distribution_fetcher.tsx @@ -4,7 +4,7 @@ * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ - +import { getTimeRangeComparison } from '../components/shared/time_comparison/get_time_range_comparison'; import { useApmParams } from './use_apm_params'; import { useFetcher } from './use_fetcher'; import { useTimeRange } from './use_time_range'; @@ -21,12 +21,18 @@ export function useErrorGroupDistributionFetcher({ environment: string; }) { const { - query: { rangeFrom, rangeTo }, - } = useApmParams('/services/{serviceName}'); + query: { rangeFrom, rangeTo, comparisonEnabled, comparisonType }, + } = useApmParams('/services/{serviceName}/errors'); const { start, end } = useTimeRange({ rangeFrom, rangeTo }); + const { comparisonStart, comparisonEnd } = getTimeRangeComparison({ + start, + end, + comparisonType, + comparisonEnabled, + }); - const { data } = useFetcher( + const { data, status } = useFetcher( (callApmApi) => { if (start && end) { return callApmApi({ @@ -39,14 +45,25 @@ export function useErrorGroupDistributionFetcher({ kuery, start, end, + comparisonStart, + comparisonEnd, groupId, }, }, }); } }, - [environment, kuery, serviceName, start, end, groupId] + [ + environment, + kuery, + serviceName, + start, + end, + comparisonStart, + comparisonEnd, + groupId, + ] ); - return { errorDistributionData: data }; + return { errorDistributionData: data, status }; } diff --git a/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts b/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts index 8ec6111fd2b8b..31c533814e697 100644 --- a/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts +++ b/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts @@ -86,7 +86,6 @@ export async function getBuckets({ ); return { - noHits: resp.hits.total.value === 0, buckets: resp.hits.total.value > 0 ? buckets : [], }; } diff --git a/x-pack/plugins/apm/server/lib/errors/distribution/get_distribution.ts b/x-pack/plugins/apm/server/lib/errors/distribution/get_distribution.ts index 5f88452d45b32..7c2eaf38be6a7 100644 --- a/x-pack/plugins/apm/server/lib/errors/distribution/get_distribution.ts +++ b/x-pack/plugins/apm/server/lib/errors/distribution/get_distribution.ts @@ -21,6 +21,8 @@ export async function getErrorDistribution({ setup, start, end, + comparisonStart, + comparisonEnd, }: { environment: string; kuery: string; @@ -29,22 +31,40 @@ export async function getErrorDistribution({ setup: Setup; start: number; end: number; + comparisonStart?: number; + comparisonEnd?: number; }) { const bucketSize = getBucketSize({ start, end }); - const { buckets, noHits } = await getBuckets({ + const commonProps = { environment, kuery, serviceName, groupId, - bucketSize, setup, + bucketSize, + }; + const currentPeriodPromise = getBuckets({ + ...commonProps, start, end, }); + const previousPeriodPromise = + comparisonStart && comparisonEnd + ? getBuckets({ + ...commonProps, + start: comparisonStart, + end: comparisonEnd, + }) + : { buckets: [], bucketSize: null }; + + const [currentPeriod, previousPeriod] = await Promise.all([ + currentPeriodPromise, + previousPeriodPromise, + ]); return { - noHits, - buckets, + currentPeriod: currentPeriod.buckets, + previousPeriod: previousPeriod.buckets, bucketSize, }; } diff --git a/x-pack/plugins/apm/server/routes/errors.ts b/x-pack/plugins/apm/server/routes/errors.ts index 0864276b67fee..3a6e07acd14bc 100644 --- a/x-pack/plugins/apm/server/routes/errors.ts +++ b/x-pack/plugins/apm/server/routes/errors.ts @@ -11,7 +11,12 @@ import { getErrorDistribution } from '../lib/errors/distribution/get_distributio import { getErrorGroupSample } from '../lib/errors/get_error_group_sample'; import { getErrorGroups } from '../lib/errors/get_error_groups'; import { setupRequest } from '../lib/helpers/setup_request'; -import { environmentRt, kueryRt, rangeRt } from './default_api_types'; +import { + environmentRt, + kueryRt, + rangeRt, + comparisonRangeRt, +} from './default_api_types'; import { createApmServerRouteRepository } from './create_apm_server_route_repository'; const errorsRoute = createApmServerRoute({ @@ -94,6 +99,7 @@ const errorDistributionRoute = createApmServerRoute({ environmentRt, kueryRt, rangeRt, + comparisonRangeRt, ]), }), options: { tags: ['access:apm'] }, @@ -101,7 +107,15 @@ const errorDistributionRoute = createApmServerRoute({ const setup = await setupRequest(resources); const { params } = resources; const { serviceName } = params.path; - const { environment, kuery, groupId, start, end } = params.query; + const { + environment, + kuery, + groupId, + start, + end, + comparisonStart, + comparisonEnd, + } = params.query; return getErrorDistribution({ environment, kuery, @@ -110,6 +124,8 @@ const errorDistributionRoute = createApmServerRoute({ setup, start, end, + comparisonStart, + comparisonEnd, }); }, }); diff --git a/x-pack/plugins/canvas/server/saved_objects/custom_element.ts b/x-pack/plugins/canvas/server/saved_objects/custom_element.ts index ee721c33460fd..d62642f5619ea 100644 --- a/x-pack/plugins/canvas/server/saved_objects/custom_element.ts +++ b/x-pack/plugins/canvas/server/saved_objects/custom_element.ts @@ -11,7 +11,8 @@ import { CUSTOM_ELEMENT_TYPE } from '../../common/lib/constants'; export const customElementType: SavedObjectsType = { name: CUSTOM_ELEMENT_TYPE, hidden: false, - namespaceType: 'single', + namespaceType: 'multiple-isolated', + convertToMultiNamespaceTypeVersion: '8.0.0', mappings: { dynamic: false, properties: { diff --git a/x-pack/plugins/canvas/server/saved_objects/workpad.ts b/x-pack/plugins/canvas/server/saved_objects/workpad.ts index fde9f5c69f146..a8f0f3daf2175 100644 --- a/x-pack/plugins/canvas/server/saved_objects/workpad.ts +++ b/x-pack/plugins/canvas/server/saved_objects/workpad.ts @@ -12,7 +12,8 @@ import { removeAttributesId } from './migrations/remove_attributes_id'; export const workpadType: SavedObjectsType = { name: CANVAS_TYPE, hidden: false, - namespaceType: 'single', + namespaceType: 'multiple-isolated', + convertToMultiNamespaceTypeVersion: '8.0.0', mappings: { dynamic: false, properties: { diff --git a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx index fb01ea0e61865..5b5e74cb2c618 100644 --- a/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx +++ b/x-pack/plugins/security/public/management/users/edit_user/edit_user_page.test.tsx @@ -34,7 +34,8 @@ const userMock = { roles: ['superuser'], }; -describe('EditUserPage', () => { +// Failing: See https://github.com/elastic/kibana/issues/115473 +describe.skip('EditUserPage', () => { it('warns when viewing deactivated user', async () => { const coreStart = coreMock.createStart(); const history = createMemoryHistory({ initialEntries: ['/edit/jdoe'] }); diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index db6c55ebb3a79..4ec7b2183bee6 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -6288,7 +6288,6 @@ "xpack.apm.errorGroupDetails.occurrencesChartLabel": "オカレンス", "xpack.apm.errorGroupDetails.relatedTransactionSample": "関連トランザクションサンプル", "xpack.apm.errorGroupDetails.unhandledLabel": "未対応", - "xpack.apm.errorRate": "失敗したトランザクション率", "xpack.apm.errorRate.chart.errorRate": "失敗したトランザクション率(平均)", "xpack.apm.errorRate.chart.errorRate.previousPeriodLabel": "前の期間", "xpack.apm.errorsTable.errorMessageAndCulpritColumnLabel": "エラーメッセージと原因", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 8ed1699a9edc5..a933d0b85b1a6 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -6339,7 +6339,6 @@ "xpack.apm.errorGroupDetails.relatedTransactionSample": "相关的事务样本", "xpack.apm.errorGroupDetails.unhandledLabel": "未处理", "xpack.apm.errorGroupDetails.viewOccurrencesInDiscoverButtonLabel": "在 Discover 中查看 {occurrencesCount} 次{occurrencesCount, plural, other {发生}}", - "xpack.apm.errorRate": "失败事务率", "xpack.apm.errorRate.chart.errorRate": "失败事务率(平均值)", "xpack.apm.errorRate.chart.errorRate.previousPeriodLabel": "上一时段", "xpack.apm.errorsTable.errorMessageAndCulpritColumnLabel": "错误消息和原因", diff --git a/x-pack/test/functional/apps/ml/anomaly_detection/annotations.ts b/x-pack/test/functional/apps/ml/anomaly_detection/annotations.ts index e6218b9853dfd..446f8a0549fc8 100644 --- a/x-pack/test/functional/apps/ml/anomaly_detection/annotations.ts +++ b/x-pack/test/functional/apps/ml/anomaly_detection/annotations.ts @@ -263,7 +263,8 @@ export default function ({ getService }: FtrProviderContext) { }); }); - describe('with errors', function () { + // FAILING ES PROMOTION: https://github.com/elastic/kibana/issues/115849 + describe.skip('with errors', function () { before(async () => { // Points the read/write aliases of annotations to an index with wrong mappings // so we can simulate errors when requesting annotations.