From 6f0745eb1c61f7a717e65a890ca8ba0d9c75b36f Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Mon, 2 May 2022 18:48:11 -0700 Subject: [PATCH] [D&D] Type Service Contributions (#1402) * feat(Contributions): Simple working contributions Signed-off-by: Ashwin Pc * feat(Contributions): Add new field from dropbox Signed-off-by: Ashwin Pc * feat(Contributions): Working edit mode Signed-off-by: Ashwin Pc * fix(Contributions): Fixes switching dropbox fields Signed-off-by: Ashwin Pc * chore: Updates license headers Signed-off-by: Ashwin Pc * feat(Contributions): Adds initial drag & drop validation Signed-off-by: Ashwin Pc * feat(Dropbox): Allows multiple instances of the same field Signed-off-by: Ashwin Pc * feat(Dropbox): Working reorder fields Signed-off-by: Ashwin Pc * feat(Contributions): Working container contributions Signed-off-by: Ashwin Pc * chore: clean up exports Signed-off-by: Ashwin Pc * test(Contribution): Adds unit test `mergeArrays` Signed-off-by: Ashwin Pc * test(TypeService): Adds plugin unit tests Signed-off-by: Ashwin Pc * chore: remove redundant code Signed-off-by: Ashwin Pc * test(Functional): Updates Chart types Signed-off-by: Ashwin Pc --- src/plugins/wizard/opensearch_dashboards.json | 3 +- .../application/{components => }/_util.scss | 0 .../wizard/public/application/app.scss | 4 +- .../components/data_tab/config_panel.scss | 9 - .../components/data_tab/config_panel.tsx | 31 --- .../components/data_tab/config_section.scss | 23 --- .../components/data_tab/config_section.tsx | 83 -------- .../components/data_tab/field_selector.scss | 10 - .../application/components/side_nav.scss | 2 +- .../application/components/side_nav.tsx | 30 +-- .../application/components/workspace.scss | 6 +- .../application/components/workspace.tsx | 10 +- .../application/contributions/constants.ts | 12 ++ .../containers/common/items/index.tsx | 9 + .../containers/common/items/select.tsx | 47 +++++ .../containers/common/items/text_input.tsx | 37 ++++ .../containers/common/items/types.ts | 43 ++++ .../containers/data_tab/config_panel.scss | 34 ++++ .../containers/data_tab/config_panel.tsx | 91 +++++++++ .../containers}/data_tab/field_search.tsx | 4 +- .../containers/data_tab/field_selector.scss | 22 +++ .../containers}/data_tab/field_selector.tsx | 48 +++-- .../data_tab/field_selector_field.scss | 0 .../data_tab/field_selector_field.tsx | 16 +- .../containers}/data_tab/index.scss | 2 +- .../containers}/data_tab/index.tsx | 6 +- .../containers/data_tab/items/dropbox.scss | 55 ++++++ .../containers/data_tab/items/dropbox.tsx | 113 +++++++++++ .../containers/data_tab/items/form_field.tsx | 20 ++ .../containers/data_tab/items/index.tsx | 9 + .../containers/data_tab/items/title.tsx | 56 ++++++ .../containers/data_tab/items/types.ts | 67 +++++++ .../containers/data_tab/items/use/index.ts | 7 + .../data_tab/items/use/use_dropbox.tsx | 182 +++++++++++++++++ .../data_tab/items/use/use_form_field.tsx | 77 ++++++++ .../data_tab/utils/item_to_panel.tsx | 60 ++++++ .../contributions/containers/index.ts | 7 + .../containers/style_tab/index.tsx} | 2 + .../public/application/contributions/index.ts | 6 + .../utils/drag_drop/drag_drop_context.tsx | 78 +++++--- .../application/utils/drag_drop/types.ts | 17 ++ .../utils/state_management/config_slice.ts | 184 ++++++++++++++---- .../utils/state_management/preload.ts | 3 + .../public/application/utils/use/index.ts | 6 + .../utils/use/use_visualization_type.ts | 24 +++ src/plugins/wizard/public/plugin.test.ts | 44 +++++ .../public/services/type_service/index.ts | 1 + .../type_service/type_service.test.ts | 67 +++++++ .../services/type_service/type_service.ts | 10 +- .../public/services/type_service/types.ts | 41 ++++ .../services/type_service/utils/index.ts | 6 + .../type_service/utils/merge_array.test.ts | 24 +++ .../type_service/utils/merge_arrays.ts | 39 ++++ .../type_service/visualization_type.test.tsx | 95 +++++++++ .../type_service/visualization_type.ts | 48 ----- .../type_service/visualization_type.tsx | 77 ++++++++ .../public/visualizations/bar_chart/index.ts | 80 +++++++- .../wizard/public/visualizations/index.ts | 4 +- .../public/visualizations/pie_chart/index.ts | 2 +- .../functional/apps/visualize/_chart_types.ts | 1 + 60 files changed, 1751 insertions(+), 343 deletions(-) rename src/plugins/wizard/public/application/{components => }/_util.scss (100%) delete mode 100644 src/plugins/wizard/public/application/components/data_tab/config_panel.scss delete mode 100644 src/plugins/wizard/public/application/components/data_tab/config_panel.tsx delete mode 100644 src/plugins/wizard/public/application/components/data_tab/config_section.scss delete mode 100644 src/plugins/wizard/public/application/components/data_tab/config_section.tsx delete mode 100644 src/plugins/wizard/public/application/components/data_tab/field_selector.scss create mode 100644 src/plugins/wizard/public/application/contributions/constants.ts create mode 100644 src/plugins/wizard/public/application/contributions/containers/common/items/index.tsx create mode 100644 src/plugins/wizard/public/application/contributions/containers/common/items/select.tsx create mode 100644 src/plugins/wizard/public/application/contributions/containers/common/items/text_input.tsx create mode 100644 src/plugins/wizard/public/application/contributions/containers/common/items/types.ts create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/config_panel.scss create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/config_panel.tsx rename src/plugins/wizard/public/application/{components => contributions/containers}/data_tab/field_search.tsx (89%) create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector.scss rename src/plugins/wizard/public/application/{components => contributions/containers}/data_tab/field_selector.tsx (80%) rename src/plugins/wizard/public/application/{components => contributions/containers}/data_tab/field_selector_field.scss (100%) rename src/plugins/wizard/public/application/{components => contributions/containers}/data_tab/field_selector_field.tsx (85%) rename src/plugins/wizard/public/application/{components => contributions/containers}/data_tab/index.scss (79%) rename src/plugins/wizard/public/application/{components => contributions/containers}/data_tab/index.tsx (84%) create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.scss create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.tsx create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/items/form_field.tsx create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/items/index.tsx create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/items/title.tsx create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/items/types.ts create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/index.ts create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_dropbox.tsx create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_form_field.tsx create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/utils/item_to_panel.tsx create mode 100644 src/plugins/wizard/public/application/contributions/containers/index.ts rename src/plugins/wizard/public/application/{components/style_tab.tsx => contributions/containers/style_tab/index.tsx} (82%) create mode 100644 src/plugins/wizard/public/application/contributions/index.ts create mode 100644 src/plugins/wizard/public/application/utils/drag_drop/types.ts create mode 100644 src/plugins/wizard/public/application/utils/use/index.ts create mode 100644 src/plugins/wizard/public/application/utils/use/use_visualization_type.ts create mode 100644 src/plugins/wizard/public/plugin.test.ts create mode 100644 src/plugins/wizard/public/services/type_service/type_service.test.ts create mode 100644 src/plugins/wizard/public/services/type_service/types.ts create mode 100644 src/plugins/wizard/public/services/type_service/utils/index.ts create mode 100644 src/plugins/wizard/public/services/type_service/utils/merge_array.test.ts create mode 100644 src/plugins/wizard/public/services/type_service/utils/merge_arrays.ts create mode 100644 src/plugins/wizard/public/services/type_service/visualization_type.test.tsx delete mode 100644 src/plugins/wizard/public/services/type_service/visualization_type.ts create mode 100644 src/plugins/wizard/public/services/type_service/visualization_type.tsx diff --git a/src/plugins/wizard/opensearch_dashboards.json b/src/plugins/wizard/opensearch_dashboards.json index 8dd00aae3890..5d4402106e0c 100644 --- a/src/plugins/wizard/opensearch_dashboards.json +++ b/src/plugins/wizard/opensearch_dashboards.json @@ -11,7 +11,8 @@ "savedObjects", "embeddable", "dashboard", - "visualizations" + "visualizations", + "opensearchUiShared" ], "optionalPlugins": [] } diff --git a/src/plugins/wizard/public/application/components/_util.scss b/src/plugins/wizard/public/application/_util.scss similarity index 100% rename from src/plugins/wizard/public/application/components/_util.scss rename to src/plugins/wizard/public/application/_util.scss diff --git a/src/plugins/wizard/public/application/app.scss b/src/plugins/wizard/public/application/app.scss index 2e1e93f44312..4f05c6ecd7a2 100644 --- a/src/plugins/wizard/public/application/app.scss +++ b/src/plugins/wizard/public/application/app.scss @@ -4,10 +4,10 @@ padding: 0; display: grid; grid-template-rows: min-content 1fr; - grid-template-columns: 420px 1fr; + grid-template-columns: 470px 1fr; grid-template-areas: "topNav topNav" "sideNav workspace" ; - height: calc(100vh - #{$osdHeaderOffset}); // TODO: update 190px to correct offset variable + height: calc(100vh - #{$osdHeaderOffset}); } diff --git a/src/plugins/wizard/public/application/components/data_tab/config_panel.scss b/src/plugins/wizard/public/application/components/data_tab/config_panel.scss deleted file mode 100644 index 7477dcfca813..000000000000 --- a/src/plugins/wizard/public/application/components/data_tab/config_panel.scss +++ /dev/null @@ -1,9 +0,0 @@ -.wizConfigPanel { - background: #f0f1f3; - border-left: $euiBorderThin; - padding: $euiSizeS; -} - -.wizConfigPanel__title { - margin-left: $euiSizeS; -} diff --git a/src/plugins/wizard/public/application/components/data_tab/config_panel.tsx b/src/plugins/wizard/public/application/components/data_tab/config_panel.tsx deleted file mode 100644 index ec910b7352de..000000000000 --- a/src/plugins/wizard/public/application/components/data_tab/config_panel.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { EuiForm, EuiTitle } from '@elastic/eui'; -import React from 'react'; -import { i18n } from '@osd/i18n'; -import { ConfigSection } from './config_section'; - -import './config_panel.scss'; -import { useTypedSelector } from '../../utils/state_management'; - -export function ConfigPanel() { - const { configSections } = useTypedSelector((state) => state.config); - - return ( - - -

- {i18n.translate('wizard.nav.dataTab.configPanel.title', { - defaultMessage: 'Configuration', - })} -

-
- {Object.entries(configSections).map(([sectionId, sectionProps], index) => ( - - ))} -
- ); -} diff --git a/src/plugins/wizard/public/application/components/data_tab/config_section.scss b/src/plugins/wizard/public/application/components/data_tab/config_section.scss deleted file mode 100644 index 79d0d3a913fd..000000000000 --- a/src/plugins/wizard/public/application/components/data_tab/config_section.scss +++ /dev/null @@ -1,23 +0,0 @@ -.wizConfigSection { - margin-top: $euiSize; - border-bottom: $euiBorderThin; - padding-bottom: $euiSize; - - &:last-child { - border-bottom: none; - } - - & .euiFormRow__labelWrapper { - margin-left: $euiSizeS; - } -} - -.wizConfigSection__dropTarget { - @include euiSlightShadow; - background: $euiColorEmptyShade; - border: $euiBorderThin; - box-shadow: 0px 2px 2px rgba(152, 162, 179, 0.15); - border-radius: $euiBorderRadius; - padding: $euiSizeS $euiSizeM; - color: $euiColorDarkShade; -} diff --git a/src/plugins/wizard/public/application/components/data_tab/config_section.tsx b/src/plugins/wizard/public/application/components/data_tab/config_section.tsx deleted file mode 100644 index 64f74824d71a..000000000000 --- a/src/plugins/wizard/public/application/components/data_tab/config_section.tsx +++ /dev/null @@ -1,83 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { EuiButtonIcon, EuiPanel, EuiText, EuiTitle } from '@elastic/eui'; -import { i18n } from '@osd/i18n'; -import React, { useCallback } from 'react'; -import { IndexPatternField } from 'src/plugins/data/common'; -import { useDrop } from '../../utils/drag_drop'; -import { useTypedDispatch, useTypedSelector } from '../../utils/state_management'; -import { - addConfigSectionField, - removeConfigSectionField, -} from '../../utils/state_management/config_slice'; - -import './config_section.scss'; - -interface ConfigSectionProps { - id: string; - title: string; -} - -export const ConfigSection = ({ title, id }: ConfigSectionProps) => { - const dispatch = useTypedDispatch(); - const { fields } = useTypedSelector((state) => state.config.configSections[id]); - - const dropHandler = useCallback( - (field: IndexPatternField) => { - dispatch( - addConfigSectionField({ - sectionId: id, - field, - }) - ); - }, - [dispatch, id] - ); - const [dropProps, { isValidDropTarget, dragData }] = useDrop('dataPlane', dropHandler); - - const dropTargetString = dragData - ? dragData.type - : i18n.translate('wizard.nav.dataTab.configPanel.dropTarget.placeholder', { - defaultMessage: 'Click or drop to add', - }); - - return ( -
- -

{title}

-
- {fields.length ? ( - fields.map((field, index) => ( - - - {field.displayName} - - - dispatch( - removeConfigSectionField({ - sectionId: id, - field, - }) - ) - } - /> - - )) - ) : ( -
- {dropTargetString} -
- )} -
- ); -}; diff --git a/src/plugins/wizard/public/application/components/data_tab/field_selector.scss b/src/plugins/wizard/public/application/components/data_tab/field_selector.scss deleted file mode 100644 index c05f75457b0b..000000000000 --- a/src/plugins/wizard/public/application/components/data_tab/field_selector.scss +++ /dev/null @@ -1,10 +0,0 @@ -@import "../util"; - -.wizFieldSelector { - @include scrollNavParent(auto 1fr); - padding: $euiSizeS; -} - -.wizFieldSelector__fieldGroups { - overflow-y: auto; -} diff --git a/src/plugins/wizard/public/application/components/side_nav.scss b/src/plugins/wizard/public/application/components/side_nav.scss index 88ff7ffb0e47..8da4b26d20e6 100644 --- a/src/plugins/wizard/public/application/components/side_nav.scss +++ b/src/plugins/wizard/public/application/components/side_nav.scss @@ -1,4 +1,4 @@ -@import "util"; +@import "../util"; .wizSidenav { @include scrollNavParent(auto 1fr); diff --git a/src/plugins/wizard/public/application/components/side_nav.tsx b/src/plugins/wizard/public/application/components/side_nav.tsx index 2f9eab83fad3..3bb1f1d76618 100644 --- a/src/plugins/wizard/public/application/components/side_nav.tsx +++ b/src/plugins/wizard/public/application/components/side_nav.tsx @@ -5,17 +5,13 @@ import React from 'react'; import { i18n } from '@osd/i18n'; - import { EuiFormLabel, EuiTabbedContent, EuiTabbedContentTab } from '@elastic/eui'; - -import { DataTab } from './data_tab'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { WizardServices } from '../../types'; -import { StyleTab } from './style_tab'; - import './side_nav.scss'; import { useTypedDispatch, useTypedSelector } from '../utils/state_management'; import { setIndexPattern } from '../utils/state_management/datasource_slice'; +import { useVisualizationType } from '../utils/use'; export const SideNav = () => { const { @@ -27,23 +23,15 @@ export const SideNav = () => { const { IndexPatternSelect } = data.ui; const { indexPattern } = useTypedSelector((state) => state.dataSource); const dispatch = useTypedDispatch(); + const { + contributions: { containers }, + } = useVisualizationType(); - const tabs: EuiTabbedContentTab[] = [ - { - id: 'data-tab', - name: i18n.translate('wizard.nav.dataTab.title', { - defaultMessage: 'Data', - }), - content: , - }, - { - id: 'style-tab', - name: i18n.translate('wizard.nav.styleTab.title', { - defaultMessage: 'Style', - }), - content: , - }, - ]; + const tabs: EuiTabbedContentTab[] = containers.sidePanel.map(({ id, name, Component }) => ({ + id, + name, + content: Component, + })); return (
diff --git a/src/plugins/wizard/public/application/components/workspace.scss b/src/plugins/wizard/public/application/components/workspace.scss index 94a97e881bf6..58bcd6f1e7cc 100644 --- a/src/plugins/wizard/public/application/components/workspace.scss +++ b/src/plugins/wizard/public/application/components/workspace.scss @@ -5,8 +5,8 @@ grid-gap: $euiSizeM; padding: $euiSizeM; background-color: $euiColorEmptyShade; -} -.wizWorkspace__empty { - height: 100%; + &__empty { + height: 100%; + } } diff --git a/src/plugins/wizard/public/application/components/workspace.tsx b/src/plugins/wizard/public/application/components/workspace.tsx index a6550a58fb80..6715c7fdf7c5 100644 --- a/src/plugins/wizard/public/application/components/workspace.tsx +++ b/src/plugins/wizard/public/application/components/workspace.tsx @@ -17,8 +17,9 @@ import { import React, { FC, useState, useMemo } from 'react'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { WizardServices } from '../../types'; -import { useTypedDispatch, useTypedSelector } from '../utils/state_management'; +import { useTypedDispatch } from '../utils/state_management'; import { setActiveVisualization } from '../utils/state_management/visualization_slice'; +import { useVisualizationType } from '../utils/use'; import './workspace.scss'; @@ -49,17 +50,12 @@ export const Workspace: FC = ({ children }) => { const TypeSelectorPopover = () => { const [isPopoverOpen, setPopover] = useState(false); - const { activeVisualization: activeVisualizationId } = useTypedSelector( - (state) => state.visualization - ); const { services: { types }, } = useOpenSearchDashboards(); const dispatch = useTypedDispatch(); - - // TODO: Error if no active visualization - const activeVisualization = types.get(activeVisualizationId || ''); const visualizationTypes = types.all(); + const activeVisualization = useVisualizationType(); const onButtonClick = () => { setPopover(!isPopoverOpen); diff --git a/src/plugins/wizard/public/application/contributions/constants.ts b/src/plugins/wizard/public/application/contributions/constants.ts new file mode 100644 index 000000000000..0a581cabec81 --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommonItemTypes } from './containers/common/items'; +import { DataTabItemTypes } from './containers/data_tab/items'; + +export const ItemTypes = { + ...CommonItemTypes, + ...DataTabItemTypes, +}; diff --git a/src/plugins/wizard/public/application/contributions/containers/common/items/index.tsx b/src/plugins/wizard/public/application/contributions/containers/common/items/index.tsx new file mode 100644 index 000000000000..eb081eb2932a --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/common/items/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './types'; + +export { Select } from './select'; +export { TextInput } from './text_input'; diff --git a/src/plugins/wizard/public/application/contributions/containers/common/items/select.tsx b/src/plugins/wizard/public/application/contributions/containers/common/items/select.tsx new file mode 100644 index 000000000000..cc6c0060c5b0 --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/common/items/select.tsx @@ -0,0 +1,47 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo } from 'react'; +import { EuiFormRow, EuiSuperSelect } from '@elastic/eui'; +import { WizardServices } from 'src/plugins/wizard/public'; +import { useOpenSearchDashboards } from '../../../../../../../opensearch_dashboards_react/public'; +import { useTypedSelector } from '../../../../utils/state_management'; +import { SelectContribution } from './types'; + +interface SelectProps extends Omit, 'type'> { + value: string; +} + +export const Select = ({ label, options, onChange, value, ...rest }: SelectProps) => { + const rootState = useTypedSelector((state) => state); + const { services } = useOpenSearchDashboards(); + const selectOptions = useMemo( + () => (typeof options === 'function' ? options(rootState, services) : options), + [options, rootState, services] + ); + // const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + return ( + + { + onChange?.(newValue); + }} + // isInvalid={isInvalid} + valueOfSelected={value || ''} + data-test-subj="select" + options={selectOptions} + /> + + ); +}; diff --git a/src/plugins/wizard/public/application/contributions/containers/common/items/text_input.tsx b/src/plugins/wizard/public/application/contributions/containers/common/items/text_input.tsx new file mode 100644 index 000000000000..91d34a16f072 --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/common/items/text_input.tsx @@ -0,0 +1,37 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiFormRow, EuiFieldText } from '@elastic/eui'; +import { InputContribution } from './types'; + +interface InputProps extends Omit { + value: string; +} + +export const TextInput = ({ label, onChange, value, ...rest }: InputProps) => { + // const { isInvalid, errorMessage } = getFieldValidityAndErrorMessage(field); + + return ( + + { + onChange?.(event.target.value); + }} + // isInvalid={isInvalid} + value={value || ''} + data-test-subj="text_input" + /> + + ); +}; diff --git a/src/plugins/wizard/public/application/contributions/containers/common/items/types.ts b/src/plugins/wizard/public/application/contributions/containers/common/items/types.ts new file mode 100644 index 000000000000..1761a4de2000 --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/common/items/types.ts @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiSuperSelectProps } from '@elastic/eui'; +import { WizardServices } from 'src/plugins/wizard/public'; +import { RootState } from '../../../../utils/state_management'; + +/** + * Types for contributions shared across various panels + */ +export enum ITEM_TYPES { + SELECT = 'select', + INPUT = 'input', +} + +export const CommonItemTypes = { + ...ITEM_TYPES, +}; + +export interface SelectContribution { + type: ITEM_TYPES.SELECT; + id: string; + label: string; + options: + | EuiSuperSelectProps['options'] + | ((state: RootState, services: WizardServices) => EuiSuperSelectProps['options']); + onChange?: (option: string) => void; + 'data-test-subj'?: string; + idAria?: string; +} + +export interface InputContribution { + type: ITEM_TYPES.INPUT; + id: string; + label: string; + onChange?: (value: string) => void; + 'data-test-subj'?: string; + idAria?: string; +} + +export type CommonItemContribution = SelectContribution | InputContribution; diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/config_panel.scss b/src/plugins/wizard/public/application/contributions/containers/data_tab/config_panel.scss new file mode 100644 index 000000000000..90dd89f1e3bb --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/config_panel.scss @@ -0,0 +1,34 @@ +.wizConfig { + background: $euiColorLightestShade; + border-left: $euiBorderThin; + position: relative; + overflow-x: hidden; + + &__section { + width: 100%; + transition: transform $euiAnimSpeedNormal 0s $euiAnimSlightResistance; + } + + &__title { + padding: $euiSizeS; + padding-bottom: 0; + + &.showDivider { + border-bottom: 1px solid $euiColorLightShade; + } + } + + &__content { + padding: $euiSizeS; + } + + &--secondary { + position: absolute; + top: 0; + left: 100%; + } + + &.showSecondary > .wizConfig__section { + transform: translateX(-100%); + } +} \ No newline at end of file diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/config_panel.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/config_panel.tsx new file mode 100644 index 000000000000..ad1f49a7ff26 --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/config_panel.tsx @@ -0,0 +1,91 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiForm } from '@elastic/eui'; +import React, { useMemo } from 'react'; +import { useVisualizationType } from '../../../utils/use'; +import { + DropboxContribution, + MainItemContribution, + SecondaryItemContribution, + TitleItemContribution, + ITEM_TYPES, +} from './items'; +import { useTypedSelector } from '../../../utils/state_management'; +import { mapItemToPanelComponents } from './utils/item_to_panel'; +import { ItemTypes } from '../../constants'; +import { SelectContribution } from '../common/items'; +import { INDEX_FIELD_KEY } from './items/use/use_form_field'; +import { DATA_TAB_ID } from '.'; +import './config_panel.scss'; + +const DEFAULT_ITEMS: MainItemContribution[] = [getTitleContribution()]; + +export function ConfigPanel() { + const { + contributions: { items }, + } = useVisualizationType(); + const activeItem = useTypedSelector((state) => state.config.activeItem); + const configItemState = useTypedSelector((state) => state.config.items[activeItem?.id || '']); + + const hydratedItems: MainItemContribution[] = [...(items?.[DATA_TAB_ID] ?? []), ...DEFAULT_ITEMS]; + + const mainPanel = useMemo(() => mapItemToPanelComponents(hydratedItems), [hydratedItems]); + const secondaryPanel = useMemo(() => { + if (!activeItem || !configItemState || typeof configItemState === 'string') return; + + // Generate each secondary panel base on active item type + if (activeItem.type === ITEM_TYPES.DROPBOX) { + const activeDropboxContribution = hydratedItems.find( + (item: MainItemContribution) => + item.type === ITEM_TYPES.DROPBOX && item?.id === activeItem?.id + ) as DropboxContribution | undefined; + + if (!activeDropboxContribution) return null; + + let itemsToRender: SecondaryItemContribution[] = [ + getTitleContribution(activeDropboxContribution.label), + getFieldSelectorContribution(), + ]; + + const dropboxFieldInstance = configItemState.instances.find( + ({ id }) => id === activeItem.instanceId + ); + if (dropboxFieldInstance && dropboxFieldInstance.properties.fieldName) { + itemsToRender = [...itemsToRender, ...activeDropboxContribution.items]; + } + + return mapItemToPanelComponents(itemsToRender, true); + } + }, [activeItem, configItemState, hydratedItems]); + + return ( + +
{mainPanel}
+
{secondaryPanel}
+
+ ); +} + +function getTitleContribution(title?: string): TitleItemContribution { + return { + type: ITEM_TYPES.TITLE, + title: [title, 'Configuration'].join(' '), + }; +} + +function getFieldSelectorContribution(): SelectContribution { + return { + type: ItemTypes.SELECT, + id: INDEX_FIELD_KEY, + label: 'Select a Field', + options: (state) => { + return state.dataSource.visualizableFields.map((field) => ({ + value: field.name, + inputDisplay: field.displayName, + })); + }, + }; +} diff --git a/src/plugins/wizard/public/application/components/data_tab/field_search.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/field_search.tsx similarity index 89% rename from src/plugins/wizard/public/application/components/data_tab/field_search.tsx rename to src/plugins/wizard/public/application/contributions/containers/data_tab/field_search.tsx index 2db8404c93c6..d086cfc1f362 100644 --- a/src/plugins/wizard/public/application/components/data_tab/field_search.tsx +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/field_search.tsx @@ -6,8 +6,8 @@ import React from 'react'; import { i18n } from '@osd/i18n'; import { EuiFieldSearch, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { setSearchField } from '../../utils/state_management/datasource_slice'; -import { useTypedDispatch } from '../../utils/state_management'; +import { setSearchField } from '../../../utils/state_management/datasource_slice'; +import { useTypedDispatch } from '../../../utils/state_management'; export interface Props { /** diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector.scss b/src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector.scss new file mode 100644 index 000000000000..e2c44387126a --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector.scss @@ -0,0 +1,22 @@ +@import "../../../util"; + +.wizFieldSelector { + @include scrollNavParent(auto 1fr); + padding: $euiSizeS; + + &__fieldGroups { + @include euiYScrollWithShadows; + overflow-y: auto; + margin-right: -$euiSizeS; + padding-right: $euiSizeS; + margin-top: $euiSizeS; + } + + &__fieldGroup { + margin-top: $euiSizeS; + + &:first-child { + margin-top: 0; + } + } +} diff --git a/src/plugins/wizard/public/application/components/data_tab/field_selector.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector.tsx similarity index 80% rename from src/plugins/wizard/public/application/components/data_tab/field_selector.tsx rename to src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector.tsx index 1464f31aabd9..9d1771fde2b5 100644 --- a/src/plugins/wizard/public/application/components/data_tab/field_selector.tsx +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector.tsx @@ -11,11 +11,11 @@ import { IndexPatternField, OPENSEARCH_FIELD_TYPES, OSD_FIELD_TYPES, -} from '../../../../../data/public'; +} from '../../../../../../data/public'; import { FieldSelectorField } from './field_selector_field'; import './field_selector.scss'; -import { useTypedSelector } from '../../utils/state_management'; +import { useTypedSelector } from '../../../utils/state_management'; interface IFieldCategories { categorical: IndexPatternField[]; @@ -85,29 +85,27 @@ interface FieldGroupProps { } const FieldGroup = ({ fields, header, id }: FieldGroupProps) => ( - <> - - {header} - - } - extraAction={ - - {fields?.length || 0} - - } - initialIsOpen - > - {fields?.map((field, i) => ( - - - - ))} - - - + + {header} + + } + extraAction={ + + {fields?.length || 0} + + } + initialIsOpen + > + {fields?.map((field, i) => ( + + + + ))} + ); function getFieldCategory(field: IndexPatternField): keyof IFieldCategories { diff --git a/src/plugins/wizard/public/application/components/data_tab/field_selector_field.scss b/src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector_field.scss similarity index 100% rename from src/plugins/wizard/public/application/components/data_tab/field_selector_field.scss rename to src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector_field.scss diff --git a/src/plugins/wizard/public/application/components/data_tab/field_selector_field.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector_field.tsx similarity index 85% rename from src/plugins/wizard/public/application/components/data_tab/field_selector_field.tsx rename to src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector_field.tsx index e545a6b33a63..f000f9395280 100644 --- a/src/plugins/wizard/public/application/components/data_tab/field_selector_field.tsx +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector_field.tsx @@ -30,8 +30,8 @@ import React, { useState } from 'react'; import { IndexPatternField } from 'src/plugins/data/public'; -import { FieldButton, FieldIcon } from '../../../../../opensearch_dashboards_react/public'; -import { useDrag } from '../../utils/drag_drop/drag_drop_context'; +import { FieldButton, FieldIcon } from '../../../../../../opensearch_dashboards_react/public'; +import { useDrag } from '../../../utils/drag_drop/drag_drop_context'; import './field_selector_field.scss'; @@ -39,12 +39,22 @@ export interface FieldSelectorFieldProps { field: IndexPatternField; } +export type FieldDragData = Pick; + // TODO: // 1. Add field sections (Available fields, popular fields from src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx) // 2. Add popover for fields stats from discover as well export const FieldSelectorField = ({ field }: FieldSelectorFieldProps) => { + const { displayName, type, name } = field; const [infoIsOpen, setOpen] = useState(false); - const [dragProps] = useDrag(field, `dataPlane`); + const [dragProps] = useDrag({ + namespace: 'field-data', + value: { + displayName, + name, + type, + }, + }); function togglePopover() { setOpen(!infoIsOpen); diff --git a/src/plugins/wizard/public/application/components/data_tab/index.scss b/src/plugins/wizard/public/application/contributions/containers/data_tab/index.scss similarity index 79% rename from src/plugins/wizard/public/application/components/data_tab/index.scss rename to src/plugins/wizard/public/application/contributions/containers/data_tab/index.scss index 1ba02bcc9879..773a12538430 100644 --- a/src/plugins/wizard/public/application/components/data_tab/index.scss +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/index.scss @@ -1,4 +1,4 @@ -@import "../util"; +@import "../../../util"; .wizDataTab { @include scrollNavParent; diff --git a/src/plugins/wizard/public/application/components/data_tab/index.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/index.tsx similarity index 84% rename from src/plugins/wizard/public/application/components/data_tab/index.tsx rename to src/plugins/wizard/public/application/contributions/containers/data_tab/index.tsx index dd062f3a787d..9fefef7d8ce9 100644 --- a/src/plugins/wizard/public/application/components/data_tab/index.tsx +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/index.tsx @@ -5,9 +5,11 @@ import React from 'react'; import { FieldSelector } from './field_selector'; -import { ConfigPanel } from './config_panel'; import './index.scss'; +import { ConfigPanel } from './config_panel'; + +export const DATA_TAB_ID = 'data_tab'; export const DataTab = () => { return ( @@ -17,3 +19,5 @@ export const DataTab = () => { ); }; + +export * from './items'; diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.scss b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.scss new file mode 100644 index 000000000000..b81ab2a07b33 --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.scss @@ -0,0 +1,55 @@ +.dropBox { + margin-top: $euiSize; + border-bottom: $euiBorderThin; + padding-bottom: $euiSize; + + &:first-child { + margin-top: 0; + } + + &:last-child { + border-bottom: none; + } + + &__container { + display: grid; + grid-gap: $euiSizeXS; + padding: $euiSizeS; + background-color: #E9EDF3; + border-radius: $euiBorderRadius; + } + + &__field { + display: grid; + grid-template-columns: auto 1fr auto; + grid-gap: $euiSizeS; + padding: $euiSizeS $euiSizeM; + align-items: center; + } + + &__draggable { + padding: 2px 0; + } + + &__field_text { + text-overflow: ellipsis; + overflow: hidden; + } + + &__dropTarget { + color: $euiColorDarkShade; + grid-template-columns: 1fr auto; + + &.validField { + background-color: #A8D9E7; + border-color: #A8D9E7; + + &.canDrop { + background-color: rgba(0, 161, 201, 0.3); + border-color: #006BB4; + border-style: dashed; + } + } + + } +} \ No newline at end of file diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.tsx new file mode 100644 index 000000000000..a9acf0c9ad71 --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.tsx @@ -0,0 +1,113 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButtonIcon, + EuiDragDropContext, + EuiDraggable, + EuiDroppable, + EuiFormRow, + EuiPanel, + EuiText, + euiDragDropReorder, +} from '@elastic/eui'; +import React, { useCallback } from 'react'; +import { FieldIcon } from '../../../../../../../opensearch_dashboards_react/public'; +import { IDropAttributes, IDropState } from '../../../../utils/drag_drop'; +import './dropbox.scss'; +import { DropboxContribution, DropboxDisplay } from './types'; +import { useDropbox } from './use'; + +interface DropboxProps extends IDropState { + id: string; + label: string; + fields: DropboxDisplay[]; + limit?: number; + onAddField: () => void; + onEditField: (id: string) => void; + onDeleteField: (id: string) => void; + onReorderField: (reorderedIds: string[]) => void; + dropProps: IDropAttributes; +} + +const DropboxComponent = ({ + id: dropboxId, + label: boxLabel, + fields, + onAddField, + onDeleteField, + onEditField, + onReorderField, + limit = 1, + isValidDropTarget, + canDrop, + dropProps, +}: DropboxProps) => { + const handleDragEnd = useCallback( + ({ source, destination }) => { + if (!source || !destination) return; + + const instanceIds = fields.map(({ id }) => id); + const reorderedIds = euiDragDropReorder(instanceIds, source.index, destination.index); + + onReorderField(reorderedIds); + }, + [fields, onReorderField] + ); + + return ( + + +
+ + {fields.map(({ id, label, icon }, index) => ( + + + + onEditField(id)}> + + {label} + + + onDeleteField(id)} + /> + + + ))} + + {fields.length < limit && ( + + Click or drop to add + onAddField()} + /> + + )} +
+
+
+ ); +}; + +const Dropbox = React.memo((dropBox: DropboxContribution) => { + const props = useDropbox(dropBox); + + return ; +}); + +export { Dropbox, DropboxComponent, DropboxProps }; diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/form_field.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/form_field.tsx new file mode 100644 index 000000000000..d068474eb267 --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/form_field.tsx @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React, { ComponentType } from 'react'; +import { Select, TextInput, ITEM_TYPES } from '../../common/items'; +import { FieldContributions } from './types'; +import { useFormField } from './use'; + +const mapItemToFormFieldComponent: { [key in ITEM_TYPES]: ComponentType } = { + [ITEM_TYPES.SELECT]: Select, + [ITEM_TYPES.INPUT]: TextInput, +}; + +export const FormField = ({ type, id, onChange, ...props }: FieldContributions) => { + const FieldComponent = mapItemToFormFieldComponent[type]; + const hookProps = useFormField(id, onChange); + + return ; +}; diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/index.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/index.tsx new file mode 100644 index 000000000000..72a3ad62df58 --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/index.tsx @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './types'; +export * from './dropbox'; +export { Title } from './title'; +export { FormField } from './form_field'; diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/title.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/title.tsx new file mode 100644 index 000000000000..84720ae6ff1c --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/title.tsx @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +import { TitleItemContribution } from './types'; +import { useTypedDispatch } from '../../../../utils/state_management'; +import { setActiveItem } from '../../../../utils/state_management/config_slice'; + +export interface TitleProps { + title: string; + icon?: React.ReactNode; + showDivider?: boolean; +} + +export const TitleComponent = ({ title, icon, showDivider = false }: TitleProps) => ( + <> +
+ + {icon && {icon}} + + +

{title}

+
+
+
+
+ {showDivider ? : } + +); + +interface TitleContributionProps extends TitleItemContribution { + isSecondary?: boolean; +} + +export const Title = ({ title, isSecondary }: TitleContributionProps) => { + const dispatch = useTypedDispatch(); + + return ( + dispatch(setActiveItem(null))} /> + } + showDivider={isSecondary} + /> + ); +}; diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/types.ts b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/types.ts new file mode 100644 index 000000000000..3133fd4f39be --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/types.ts @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IndexPatternField } from 'src/plugins/data/common'; +import { FieldIconProps } from '../../../../../../../opensearch_dashboards_react/public'; +import { SelectContribution, InputContribution } from '../../common/items'; + +/** + * Types for contributions that relate to the config panel + */ + +export enum ITEM_TYPES { + DROPBOX = 'dropbox', + TITLE = 'title', +} + +export const DataTabItemTypes = { + ...ITEM_TYPES, +}; + +export type FieldContributions = SelectContribution | InputContribution; +export type MainItemContribution = TitleItemContribution | DropboxContribution | FieldContributions; +export type SecondaryItemContribution = TitleItemContribution | FieldContributions; + +export interface TitleItemContribution { + type: ITEM_TYPES.TITLE; + title: string; +} + +export interface DropboxDisplay { + label: string; + icon: FieldIconProps['type']; + id: string; +} +export interface DropboxFieldProps { + fieldName?: string; + [itemId: string]: any; +} + +export interface DropboxContribution { + type: ITEM_TYPES.DROPBOX; + id: string; + label: string; + limit?: number; + items: SecondaryItemContribution[]; + // Define how the IndexPatternField should be displayed on the dropbox + display?: ( + indexField: IndexPatternField, + state: DropboxFieldProps + ) => Pick; + // Defines how the initial state of a field should be set when a field is dropped onto it + onDrop?: (field: IndexPatternField) => DropboxFieldProps; + isDroppable?: (field: IndexPatternField) => boolean; +} + +export interface InstanceState { + instances: Array<{ + id: string; + properties: T; + }>; +} + +export type DropboxState = InstanceState; +export type InstanceItemStates = DropboxState; +export type ConfigItemState = InstanceItemStates | string | undefined; diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/index.ts b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/index.ts new file mode 100644 index 000000000000..87b259c5a86e --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { useDropbox } from './use_dropbox'; +export { useFormField } from './use_form_field'; diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_dropbox.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_dropbox.tsx new file mode 100644 index 000000000000..250825f60d10 --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_dropbox.tsx @@ -0,0 +1,182 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useMemo } from 'react'; +import { IndexPatternField } from 'src/plugins/data/common'; +import { FieldDragDataType } from '../../../../../utils/drag_drop/types'; +import { useTypedDispatch, useTypedSelector } from '../../../../../utils/state_management'; +import { + addInstance, + reorderInstances, + setActiveItem, + updateInstance, +} from '../../../../../utils/state_management/config_slice'; +import { + DropboxContribution, + DropboxState, + ITEM_TYPES, + DropboxDisplay, + DropboxFieldProps, +} from '../types'; +import { DropboxProps } from '../dropbox'; +import { useDrop } from '../../../../../utils/drag_drop'; + +type DropboxInstanceState = DropboxState['instances'][number]; + +export const INITIAL_STATE: DropboxState = { + instances: [], +}; + +export const useDropbox = (dropboxContribution: DropboxContribution): DropboxProps => { + const { id: dropboxId, label, limit, display, onDrop, isDroppable } = dropboxContribution; + const dispatch = useTypedDispatch(); + const { items, availableFields } = useTypedSelector((state) => ({ + items: state.config.items, + availableFields: state.dataSource.visualizableFields, + })); + const configItemState = items[dropboxId]; + const dropboxState = + !configItemState || typeof configItemState === 'string' ? INITIAL_STATE : configItemState; + const filterPatrialInstances = useCallback( + ({ properties }: DropboxInstanceState) => !!properties.fieldName, + [] + ); + const mapInstanceToFieldDisplay = useCallback( + ({ id, properties }: DropboxInstanceState): DropboxDisplay => { + const indexPatternField = availableFields.find(({ name }) => name === properties.fieldName); + + if (!indexPatternField) throw new Error('Field to display missing in available fields'); + + return getDisplayField(id, indexPatternField, properties, display); + }, + [availableFields, display] + ); + + const displayFields: DropboxDisplay[] = useMemo( + () => dropboxState.instances.filter(filterPatrialInstances).map(mapInstanceToFieldDisplay), + [dropboxState.instances, filterPatrialInstances, mapInstanceToFieldDisplay] + ); + + // Event handlers for each dropbox action type + const onAddField = useCallback(() => { + dispatch(addInstance(dropboxId, ITEM_TYPES.DROPBOX)); + }, [dispatch, dropboxId]); + + const onEditField = useCallback( + (instanceId) => { + dispatch( + setActiveItem({ + id: dropboxId, + type: ITEM_TYPES.DROPBOX, + instanceId, + }) + ); + }, + [dispatch, dropboxId] + ); + + const onDeleteField = useCallback( + (instanceId) => { + dispatch( + updateInstance({ + id: dropboxId, + instanceId, + instanceState: null, + }) + ); + }, + [dispatch, dropboxId] + ); + + const onDropField = useCallback( + (data: FieldDragDataType['value']) => { + if (!data) return; + + const { name: fieldName } = data; + const indexField = getIndexPatternField(fieldName, availableFields); + + if (!indexField) return; + + if (isDroppable && !isDroppable(indexField)) return; + + const newState: DropboxFieldProps = { + ...onDrop?.(indexField), + fieldName, + }; + + dispatch(addInstance(dropboxId, ITEM_TYPES.DROPBOX, false, newState)); + }, + [availableFields, isDroppable, onDrop, dispatch, dropboxId] + ); + + const onReorderField = useCallback( + (reorderedInstanceIds: string[]) => { + dispatch( + reorderInstances({ + id: dropboxId, + reorderedInstanceIds, + }) + ); + }, + [dispatch, dropboxId] + ); + + const [dropProps, { isValidDropTarget, dragData, ...dropState }] = useDrop( + 'field-data', + onDropField + ); + + const isValidDropField = useMemo(() => { + if (!dragData) return false; + + const indexField = getIndexPatternField(dragData.name, availableFields); + + if (!indexField) return false; + + return isValidDropTarget && (isDroppable?.(indexField) ?? true); + }, [availableFields, dragData, isDroppable, isValidDropTarget]); + + return { + id: dropboxId, + label, + limit, + fields: displayFields, + onAddField, + onEditField, + onDeleteField, + onReorderField, + ...dropState, + dragData, + isValidDropTarget: isValidDropField, + dropProps, + }; +}; + +const getDisplayField = ( + instanceId: string, + indexField: IndexPatternField, + properties: DropboxFieldProps, + display: DropboxContribution['display'] +): DropboxDisplay => { + let displayField: DropboxDisplay = { + id: instanceId, + icon: indexField.type, + label: indexField.displayName, + }; + if (display) { + const { icon, label } = display(indexField, properties); + + displayField = { + ...displayField, + icon, + label, + }; + } + + return displayField; +}; + +const getIndexPatternField = (indexFieldName: string, availableFields: IndexPatternField[]) => + availableFields.find(({ name }) => name === indexFieldName); diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_form_field.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_form_field.tsx new file mode 100644 index 000000000000..8f650afb62f0 --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_form_field.tsx @@ -0,0 +1,77 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import produce from 'immer'; +import { useCallback, useMemo } from 'react'; +import { + ConfigState, + updateConfigItemState, + updateInstance, +} from '../../../../../utils/state_management/config_slice'; +import { useTypedSelector, useTypedDispatch } from '../../../../../utils/state_management'; +import { FieldContributions } from '../types'; + +export const INDEX_FIELD_KEY = 'fieldName'; + +interface FieldProps { + onChange: Function; + value: string; +} + +export const useFormField = (id: string, onChange: FieldContributions['onChange']): FieldProps => { + const configState = useTypedSelector((state) => state.config); + const { activeItem, items } = configState; + const dispatch = useTypedDispatch(); + + const instanceState = useMemo(() => getInstanceState(configState) ?? {}, [configState]); + + const handleChange = useCallback( + (newValue: string) => { + onChange?.(newValue); + + // is a MainPanel field value + if (!activeItem) { + dispatch( + updateConfigItemState({ + id, + itemState: newValue, + }) + ); + return; + } + + const newInstanceState = produce(instanceState, (draftState) => { + draftState[id] = newValue; + }); + + dispatch( + updateInstance({ + id: activeItem.id, + instanceId: activeItem.instanceId, + instanceState: newInstanceState, + }) + ); + }, + [activeItem, dispatch, id, instanceState, onChange] + ); + + return { + value: activeItem ? instanceState[id] : items[id], + onChange: handleChange, + }; +}; + +function getInstanceState({ items, activeItem }: ConfigState) { + const { id: parentItemId, instanceId } = activeItem ?? {}; + const configItem = items[parentItemId ?? '']; + + if (!configItem || typeof configItem === 'string') return; + + const instanceItem = configItem.instances.find(({ id }) => id === instanceId); + + if (!instanceItem) return; + + return instanceItem.properties; +} diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/utils/item_to_panel.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/utils/item_to_panel.tsx new file mode 100644 index 000000000000..db9dfdaf7dee --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/utils/item_to_panel.tsx @@ -0,0 +1,60 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { ItemTypes } from '../../../constants'; +import { + Title, + Dropbox, + FormField, + MainItemContribution, + SecondaryItemContribution, +} from '../items'; + +export const mapItemToPanelComponents = ( + items: Array, + isSecondary = false +) => { + const uniqueDict: { [key: string]: boolean } = {}; + + const [title, ...panelComponents] = items + .filter((item) => { + // Ensure unique item ID + const id = item.type !== ItemTypes.TITLE ? item.id : 'title'; + if (uniqueDict[id]) return false; + + uniqueDict[id] = true; + return true; + }) + .sort((itemA, itemB) => + // Ensure that the title is on top + itemA.type === ItemTypes.TITLE ? -1 : itemB.type === ItemTypes.TITLE ? 1 : 0 + ) + .map((item, index) => { + const { type } = item; + + switch (type) { + case ItemTypes.TITLE: + return ; + + case ItemTypes.DROPBOX: + return <Dropbox key={item.id} {...item} />; + + case ItemTypes.SELECT: + case ItemTypes.INPUT: + return <FormField key={item.id} {...item} />; + + default: + break; + } + }); + + return ( + <> + {title} + <div className="wizConfig__content">{panelComponents}</div> + </> + ); +}; diff --git a/src/plugins/wizard/public/application/contributions/containers/index.ts b/src/plugins/wizard/public/application/contributions/containers/index.ts new file mode 100644 index 000000000000..74d0a6b8f76e --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './data_tab'; +export * from './style_tab'; diff --git a/src/plugins/wizard/public/application/components/style_tab.tsx b/src/plugins/wizard/public/application/contributions/containers/style_tab/index.tsx similarity index 82% rename from src/plugins/wizard/public/application/components/style_tab.tsx rename to src/plugins/wizard/public/application/contributions/containers/style_tab/index.tsx index 3d1eb0d98b35..6da05f6715e7 100644 --- a/src/plugins/wizard/public/application/components/style_tab.tsx +++ b/src/plugins/wizard/public/application/contributions/containers/style_tab/index.tsx @@ -5,6 +5,8 @@ import React from 'react'; +export const STYLE_TAB_ID = 'style_tab'; + export const StyleTab = () => { return <div>TODO: Layout styles come here.</div>; }; diff --git a/src/plugins/wizard/public/application/contributions/index.ts b/src/plugins/wizard/public/application/contributions/index.ts new file mode 100644 index 000000000000..6ea0ec832393 --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +export * from './containers'; +export * from './constants'; diff --git a/src/plugins/wizard/public/application/utils/drag_drop/drag_drop_context.tsx b/src/plugins/wizard/public/application/utils/drag_drop/drag_drop_context.tsx index a89226885d5d..c0f8725a501a 100644 --- a/src/plugins/wizard/public/application/utils/drag_drop/drag_drop_context.tsx +++ b/src/plugins/wizard/public/application/utils/drag_drop/drag_drop_context.tsx @@ -3,31 +3,41 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { createContext, DragEvent, FC, ReactNode, useContext, useState } from 'react'; - -interface DrapDataType { - namespace: string; - value: any; -} +import React, { + createContext, + DragEvent, + FC, + ReactNode, + useContext, + useEffect, + useState, +} from 'react'; +import { DragDataType } from './types'; // TODO: Replace any with corret type // TODO: Split into separate files interface IDragDropContext { - data?: DrapDataType; - setData?: any; + data: DragDataType; + setData?: (data: DragDataType) => void; isDragging: boolean; setIsDragging?: any; } -const defaultContextProps = { +const EMPTY_DATA: DragDataType = { + namespace: null, + value: null, +}; + +const defaultContextProps: IDragDropContext = { isDragging: false, + data: EMPTY_DATA, }; const DragDropContext = createContext<IDragDropContext>(defaultContextProps); const DragDropProvider: FC<ReactNode> = ({ children }) => { const [isDragging, setIsDragging] = useState(false); - const [data, setData] = useState<DrapDataType>(); + const [data, setData] = useState<DragDataType>(EMPTY_DATA); return ( <DragDropContext.Provider value={{ @@ -45,41 +55,44 @@ const DragDropProvider: FC<ReactNode> = ({ children }) => { const useDragDropContext = () => useContext(DragDropContext); -const useDrag = (dragData: any, namespace: string) => { +function useDrag(dragData: DragDataType) { const { setData, setIsDragging } = useDragDropContext(); const dragElementProps = { draggable: true, onDragStart: (event: DragEvent) => { setIsDragging(true); - setData({ - namespace, - value: dragData, - }); + setData!(dragData); }, onDragEnd: (event: DragEvent) => { setIsDragging(false); - setData(null); + setData!({ + namespace: null, + value: null, + }); }, }; return [dragElementProps]; -}; +} -interface IDropAttributes { +export interface IDropAttributes { onDragOver: (event: DragEvent) => void; onDrop: (event: DragEvent) => void; onDragEnter: (event: DragEvent) => void; onDragLeave: (event: DragEvent) => void; } -interface IDropState { +export interface IDropState { isDragging: boolean; canDrop: boolean; isValidDropTarget: boolean; - dragData: any; + dragData: DragDataType['value']; } -const useDrop = (namespace: string, onDropCallback: Function): [IDropAttributes, IDropState] => { +const useDrop = ( + namespace: DragDataType['namespace'], + onDropCallback: (data: DragDataType['value']) => void +): [IDropAttributes, IDropState] => { const { data, isDragging, setIsDragging, setData } = useDragDropContext(); - const [canDrop, setCanDrop] = useState(false); + const [canDrop, setCanDrop] = useState(0); const dropAttributes: IDropAttributes = { onDragOver: (event) => { @@ -87,25 +100,34 @@ const useDrop = (namespace: string, onDropCallback: Function): [IDropAttributes, }, onDrop: (event) => { setIsDragging(false); - onDropCallback(data?.value); - setData(null); + setCanDrop(0); + onDropCallback(data.value); + setData!({ + namespace: null, + value: null, + }); }, onDragEnter: (event) => { if (data?.namespace === namespace) { - setCanDrop(true); + setCanDrop((state) => state + 1); } }, onDragLeave: (event) => { - setCanDrop(false); + setCanDrop((state) => state - 1); }, }; + + useEffect(() => { + if (!isDragging) setCanDrop(0); + }, [isDragging]); + return [ dropAttributes, { isDragging, - canDrop, + canDrop: canDrop > 0, isValidDropTarget: isDragging && data?.namespace === namespace, - dragData: data?.value, + dragData: data.value, }, ]; }; diff --git a/src/plugins/wizard/public/application/utils/drag_drop/types.ts b/src/plugins/wizard/public/application/utils/drag_drop/types.ts new file mode 100644 index 000000000000..0258a2315ec6 --- /dev/null +++ b/src/plugins/wizard/public/application/utils/drag_drop/types.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IndexPatternField } from 'src/plugins/data/common'; + +export interface EmptyDragDataType { + namespace: null; + value: null; +} +export interface FieldDragDataType { + namespace: 'field-data'; + value: Pick<IndexPatternField, 'name' | 'displayName' | 'type'> | null; +} + +export type DragDataType = EmptyDragDataType | FieldDragDataType; diff --git a/src/plugins/wizard/public/application/utils/state_management/config_slice.ts b/src/plugins/wizard/public/application/utils/state_management/config_slice.ts index 5d8908596104..dc0c2ec1cb0c 100644 --- a/src/plugins/wizard/public/application/utils/state_management/config_slice.ts +++ b/src/plugins/wizard/public/application/utils/state_management/config_slice.ts @@ -3,60 +3,174 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { htmlIdGenerator } from '@elastic/eui'; import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { IndexPatternField } from '../../../../../data/public'; +import { WizardServices } from '../../../types'; +import { + ConfigItemState, + DATA_TAB_ID, + InstanceState, + MainItemContribution, +} from '../../contributions'; -interface ConfigSections { - [id: string]: { - title: string; - fields: IndexPatternField[]; - }; +// TODO: Move this into contributions and register the slice from there for better code splitting +// TODO: Reorganize slice for better readability +export interface ActiveItem { + id: string; + type: MainItemContribution['type']; + instanceId: string; } -interface ConfigState { - configSections: ConfigSections; + +export interface ConfigState { + items: { + [id: string]: ConfigItemState; + }; + activeItem: ActiveItem | null; } -// TODO: Temp. Remove once visualizations can be refgistered and editor configs can be passed along -// TODO: this is a placeholder while the config section is iorned out const initialState: ConfigState = { - configSections: { - x: { - title: 'X Axis', - fields: [], - }, - y: { - title: 'Y Axis', - fields: [], - }, - }, + items: {}, + activeItem: null, }; -interface SectionField { - sectionId: string; - field: IndexPatternField; +export const getPreloadedState = async ({ types }: WizardServices): Promise<ConfigState> => { + const preloadedState = { ...initialState }; + + const defaultVisualizationType = types.all()[0]; + + if (defaultVisualizationType) { + preloadedState.items = defaultVisualizationType.contributions.items?.[DATA_TAB_ID].filter( + ({ id }) => !!id + ).reduce((acc, { id, type }) => ({ ...acc, [id]: null }), {}); + } + + return preloadedState; +}; + +interface UpdateConfigPayload { + id: string; + itemState: any; +} + +interface AddInstancePayload extends ActiveItem { + properties?: any; + setActive: boolean; +} + +interface UpdateInstancePayload { + id: string; + instanceId: string; + instanceState: any; +} + +interface ReorderInstancePayload { + id: string; + reorderedInstanceIds: string[]; } export const slice = createSlice({ name: 'configuration', initialState, reducers: { - addConfigSectionField: (state, action: PayloadAction<SectionField>) => { - const { field, sectionId } = action.payload; - if (state.configSections[sectionId]) { - state.configSections[sectionId].fields.push(field); + updateConfigItemState: (state, action: PayloadAction<UpdateConfigPayload>) => { + const { id, itemState } = action.payload; + + if (state.items.hasOwnProperty(id)) { + state.items[id] = itemState; } }, - removeConfigSectionField: (state, action: PayloadAction<SectionField>) => { - const { field, sectionId } = action.payload; - if (state.configSections[sectionId]) { - const fieldIndex = state.configSections[sectionId].fields.findIndex( - (configField) => configField === field - ); - if (fieldIndex !== -1) state.configSections[sectionId].fields.splice(fieldIndex, 1); + setActiveItem: (state, { payload }: PayloadAction<ActiveItem | null>) => { + // On closing secondary menu + if (!payload) { + state.activeItem = null; + return; } + + state.activeItem = payload; + }, + addInstance: { + reducer: (state, action: PayloadAction<AddInstancePayload>) => { + const { id, instanceId, properties, setActive } = action.payload; + + if (!state.items.hasOwnProperty(id)) return; + + if (!state.items[id]) { + state.items[id] = { + instances: [], + }; + } + + (state.items[id] as InstanceState<unknown>).instances.push({ + id: instanceId, + properties: properties ?? {}, + }); + + if (setActive) { + state.activeItem = action.payload; + } + }, + prepare: ( + id: string, + type: MainItemContribution['type'], + setActive: boolean = true, + properties?: any + ) => { + const instanceId = htmlIdGenerator()(); + + return { + payload: { + id, + type, + instanceId, + properties, + setActive, + }, + }; + }, + }, + updateInstance: (state, action: PayloadAction<UpdateInstancePayload>) => { + const { id: parentItemId, instanceId, instanceState } = action.payload; + if (!state.items.hasOwnProperty(parentItemId)) return; + + // Typescript complains if we use state.items[parentItemId] directly since it cannot resolve the type correctly + const configItem = state.items[parentItemId]; + if (!configItem || typeof configItem === 'string') return; + + const instanceIndex = configItem.instances.findIndex(({ id }) => id === instanceId); + + if (instanceIndex < 0) return; + + if (instanceState === null) { + configItem.instances.splice(instanceIndex, 1); + return; + } + + configItem.instances[instanceIndex].properties = instanceState; + }, + reorderInstances: (state, action: PayloadAction<ReorderInstancePayload>) => { + const { id: parentItemId, reorderedInstanceIds } = action.payload; + + if (!state.items.hasOwnProperty(parentItemId)) return; + + // Typescript complains if we use state.items[parentItemId] directly since it cannot resolve the type correctly + const configItem = state.items[parentItemId]; + if (!configItem || typeof configItem === 'string') return; + + const orderDict: { [id: string]: number } = {}; + reorderedInstanceIds.forEach((instanceId, index) => { + orderDict[instanceId] = index; + }); + + configItem.instances.sort((a, b) => orderDict[a.id] - orderDict[b.id]); }, }, }); export const { reducer } = slice; -export const { addConfigSectionField, removeConfigSectionField } = slice.actions; +export const { + updateConfigItemState, + setActiveItem, + addInstance, + updateInstance, + reorderInstances, +} = slice.actions; diff --git a/src/plugins/wizard/public/application/utils/state_management/preload.ts b/src/plugins/wizard/public/application/utils/state_management/preload.ts index ad78b642c23e..21ebd13ff82f 100644 --- a/src/plugins/wizard/public/application/utils/state_management/preload.ts +++ b/src/plugins/wizard/public/application/utils/state_management/preload.ts @@ -7,6 +7,7 @@ import { PreloadedState } from '@reduxjs/toolkit'; import { WizardServices } from '../../..'; import { getPreloadedState as getPreloadedDatasourceState } from './datasource_slice'; import { getPreloadedState as getPreloadedVisualizationState } from './visualization_slice'; +import { getPreloadedState as getPreloadedConfigState } from './config_slice'; import { RootState } from './store'; export const getPreloadedState = async ( @@ -14,9 +15,11 @@ export const getPreloadedState = async ( ): Promise<PreloadedState<RootState>> => { const dataSourceState = await getPreloadedDatasourceState(services); const visualizationState = await getPreloadedVisualizationState(services); + const configState = await getPreloadedConfigState(services); return { dataSource: dataSourceState, visualization: visualizationState, + config: configState, }; }; diff --git a/src/plugins/wizard/public/application/utils/use/index.ts b/src/plugins/wizard/public/application/utils/use/index.ts new file mode 100644 index 000000000000..d82ba978902d --- /dev/null +++ b/src/plugins/wizard/public/application/utils/use/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { useVisualizationType } from './use_visualization_type'; diff --git a/src/plugins/wizard/public/application/utils/use/use_visualization_type.ts b/src/plugins/wizard/public/application/utils/use/use_visualization_type.ts new file mode 100644 index 000000000000..fb88c11b49da --- /dev/null +++ b/src/plugins/wizard/public/application/utils/use/use_visualization_type.ts @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { VisualizationType } from '../../../services/type_service/visualization_type'; +import { WizardServices } from '../../../types'; +import { useTypedSelector } from '../state_management'; + +export const useVisualizationType = (): VisualizationType => { + const { activeVisualization } = useTypedSelector((state) => state.visualization); + const { + services: { types }, + } = useOpenSearchDashboards<WizardServices>(); + + const visualizationType = types.get(activeVisualization || ''); + + if (!visualizationType) { + throw new Error('Invalid visualization type ${activeVisualization}'); + } + + return visualizationType; +}; diff --git a/src/plugins/wizard/public/plugin.test.ts b/src/plugins/wizard/public/plugin.test.ts new file mode 100644 index 000000000000..c83f4c6a8458 --- /dev/null +++ b/src/plugins/wizard/public/plugin.test.ts @@ -0,0 +1,44 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock, savedObjectsServiceMock } from '../../../core/public/mocks'; +import { dashboardPluginMock } from '../../../plugins/dashboard/public/mocks'; +import { dataPluginMock } from '../../../plugins/data/public/mocks'; +import { embeddablePluginMock } from '../../../plugins/embeddable/public/mocks'; +import { navigationPluginMock } from '../../../plugins/navigation/public/mocks'; +import { visualizationsPluginMock } from '../../../plugins/visualizations/public/mocks'; +import { WizardPlugin } from './plugin'; + +describe('WizardPlugin', () => { + describe('setup', () => { + it('initializes the plugin correctly and registers it as an alias vizualization', () => { + const plugin = new WizardPlugin(coreMock.createPluginInitializerContext()); + const pluginStartContract = { + data: dataPluginMock.createStartContract(), + savedObject: savedObjectsServiceMock.createStartContract(), + navigation: navigationPluginMock.createStartContract(), + dashboard: dashboardPluginMock.createStartContract(), + }; + const coreSetup = coreMock.createSetup({ + pluginStartContract, + }) as any; + const setupDeps = { + visualizations: visualizationsPluginMock.createSetupContract(), + embeddable: embeddablePluginMock.createSetupContract(), + }; + + const setup = plugin.setup(coreSetup, setupDeps); + expect(setup).toHaveProperty('createVisualizationType'); + expect(setupDeps.visualizations.registerAlias).toHaveBeenCalledWith( + // TODO: Update this once the properties are final + expect.objectContaining({ + name: 'wizard', + title: 'Wizard', + aliasPath: '#/', + }) + ); + }); + }); +}); diff --git a/src/plugins/wizard/public/services/type_service/index.ts b/src/plugins/wizard/public/services/type_service/index.ts index 1fae953fb9b8..9baeb6dbc9f4 100644 --- a/src/plugins/wizard/public/services/type_service/index.ts +++ b/src/plugins/wizard/public/services/type_service/index.ts @@ -4,3 +4,4 @@ */ export * from './type_service'; +export * from './types'; diff --git a/src/plugins/wizard/public/services/type_service/type_service.test.ts b/src/plugins/wizard/public/services/type_service/type_service.test.ts new file mode 100644 index 000000000000..7edf38e7e5e0 --- /dev/null +++ b/src/plugins/wizard/public/services/type_service/type_service.test.ts @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VisualizationTypeOptions } from './types'; +import { TypeService } from './type_service'; + +const DEFAULT_VIZ_PROPS = { + name: 'some-name', + icon: 'some-icon', + title: 'Some Title', + contributions: {}, +}; + +describe('TypeService', () => { + const createVizType = (props?: Partial<VisualizationTypeOptions>): VisualizationTypeOptions => { + return { + ...DEFAULT_VIZ_PROPS, + ...props, + }; + }; + + let service: TypeService; + + beforeEach(() => { + service = new TypeService(); + }); + + describe('#setup', () => { + test('should throw an error if two visualizzations of the same id are registered', () => { + const { createVisualizationType } = service.setup(); + + createVisualizationType(createVizType({ name: 'viz-type-1' })); + + expect(() => { + createVisualizationType(createVizType({ name: 'viz-type-1' })); + }).toThrowErrorMatchingInlineSnapshot( + `"A visualization with this the name viz-type-1 already exists!"` + ); + }); + }); + + describe('#start', () => { + test('should return registered visualization if it exists', () => { + const { createVisualizationType } = service.setup(); + createVisualizationType(createVizType({ name: 'viz-type-1' })); + + const { get } = service.start(); + expect(get('viz-type-1')).toEqual(expect.objectContaining({ name: 'viz-type-1' })); + expect(get('viz-type-no-exists')).toBeUndefined(); + }); + + test('should return all registered visualizations', () => { + const { createVisualizationType } = service.setup(); + createVisualizationType(createVizType({ name: 'viz-type-1' })); + createVisualizationType(createVizType({ name: 'viz-type-2' })); + + const { all } = service.start(); + const allRegisteredVisualizations = all(); + expect(allRegisteredVisualizations.map(({ name }) => name)).toEqual([ + 'viz-type-1', + 'viz-type-2', + ]); + }); + }); +}); diff --git a/src/plugins/wizard/public/services/type_service/type_service.ts b/src/plugins/wizard/public/services/type_service/type_service.ts index d43d779f75ea..38c13ebcfb3c 100644 --- a/src/plugins/wizard/public/services/type_service/type_service.ts +++ b/src/plugins/wizard/public/services/type_service/type_service.ts @@ -28,19 +28,21 @@ * under the License. */ -import { VisualizationType, VisualizationTypeOptions } from './visualization_type'; +import { CoreService } from 'src/core/types'; +import { VisualizationTypeOptions } from './types'; +import { VisualizationType } from './visualization_type'; /** - * Vis Types Service + * Visualization Types Service * * @internal */ -export class TypeService { +export class TypeService implements CoreService<TypeServiceSetup, TypeServiceStart> { private types: Record<string, VisualizationType> = {}; private registerVisualizationType(visDefinition: VisualizationType) { if (this.types[visDefinition.name]) { - throw new Error('type already exists!'); + throw new Error(`A visualization with this the name ${visDefinition.name} already exists!`); } this.types[visDefinition.name] = visDefinition; } diff --git a/src/plugins/wizard/public/services/type_service/types.ts b/src/plugins/wizard/public/services/type_service/types.ts new file mode 100644 index 000000000000..d722bf90dbfa --- /dev/null +++ b/src/plugins/wizard/public/services/type_service/types.ts @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IconType } from '@elastic/eui'; + +export enum ContributionTypes { + CONTAINER = 'CONTAINER', + ITEM = 'ITEM', +} + +export enum ContainerLocations { + SIDE_PANEL = 'sidePanel', + TOOLBAR = 'toolbar', +} + +export interface ContainerContribution { + id: string; + name: string; + Component: JSX.Element; +} + +type ContainerSchema = any; + +export type ContainerLocationContribution = { [K in ContainerLocations]: ContainerContribution[] }; + +export interface VisualizationTypeOptions { + readonly name: string; + readonly title: string; + readonly description?: string; + readonly icon: IconType; + readonly stage?: 'beta' | 'production'; + readonly contributions: { + containers?: Partial<ContainerLocationContribution>; + items?: { + [containerId: string]: ContainerSchema[]; // schema that is used to render the container. Each container is responsible for deciding that for consistency + }; + }; + // pipeline: Expression; +} diff --git a/src/plugins/wizard/public/services/type_service/utils/index.ts b/src/plugins/wizard/public/services/type_service/utils/index.ts new file mode 100644 index 000000000000..4d0644b2d93e --- /dev/null +++ b/src/plugins/wizard/public/services/type_service/utils/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './merge_arrays'; diff --git a/src/plugins/wizard/public/services/type_service/utils/merge_array.test.ts b/src/plugins/wizard/public/services/type_service/utils/merge_array.test.ts new file mode 100644 index 000000000000..01a95f240d5f --- /dev/null +++ b/src/plugins/wizard/public/services/type_service/utils/merge_array.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mergeArrays } from './merge_arrays'; + +describe('mergeArrays', () => { + const arrayA = ['a', 'b', 'c'].map((x) => ({ id: x, value: `${x} in arrayA` })); + const arrayB = ['a', 'c', 'd'].map((x) => ({ id: x, value: `${x} in arrayB` })); + test('should merge two object arrays based on id in order without duplicates', () => { + const mergedArrays = mergeArrays(arrayA, arrayB, 'id'); + expect(mergedArrays.map((x) => x.id)).toEqual(['a', 'b', 'c', 'd']); + expect(mergedArrays[0].value).toEqual('a in arrayB'); + }); + + test('should throw an error if key is not a string or number', () => { + const arr = [{ id: {} }]; + + expect(() => mergeArrays(arrayA, arr, 'id')).toThrowErrorMatchingInlineSnapshot( + `"Can only merge arrays with keys of type number or string"` + ); + }); +}); diff --git a/src/plugins/wizard/public/services/type_service/utils/merge_arrays.ts b/src/plugins/wizard/public/services/type_service/utils/merge_arrays.ts new file mode 100644 index 000000000000..3b5f9b0adfb8 --- /dev/null +++ b/src/plugins/wizard/public/services/type_service/utils/merge_arrays.ts @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { uniq } from 'lodash'; + +export function mergeArrays<T extends object, K extends keyof T>(a: T[], b: T[], key: K): T[] { + const dict: Record<PropertyKey, T> = {}; + + const rawCombinedArrays = [...a, ...b]; + + // Create an ordered list of unique the array keys. Removing duplicates while keeping the order + const combinedOrder = uniq(rawCombinedArrays.map((entry) => entry[key])); + + // Create a map of all unique ID's and their values. + // If there is more than one entry with the same ID, the last entry is kept + rawCombinedArrays.forEach((entry) => { + if (!entry.hasOwnProperty(key)) { + throw new Error('Key not present in an object in one the arrays to merge'); + } + + const id = entry[key]; + + if (typeof id !== 'string' && typeof id !== 'number') { + throw new Error('Can only merge arrays with keys of type number or string'); + } + + dict[id] = entry; + }); + + // Return the combined array in order with unique keys keeping only the last entry for each unique id + return combinedOrder.map((entryId) => { + if (typeof entryId !== 'string' && typeof entryId !== 'number') { + throw new Error('Can only merge arrays with keys of type number or string'); + } + return dict[entryId]; + }); +} diff --git a/src/plugins/wizard/public/services/type_service/visualization_type.test.tsx b/src/plugins/wizard/public/services/type_service/visualization_type.test.tsx new file mode 100644 index 000000000000..830d598af754 --- /dev/null +++ b/src/plugins/wizard/public/services/type_service/visualization_type.test.tsx @@ -0,0 +1,95 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { VisualizationTypeOptions } from './types'; +import { VisualizationType, DEFAULT_CONTAINERS } from './visualization_type'; + +describe('VisualizationType', () => { + const DEFAULT_VIZ_PROPS = { + name: 'some-name', + icon: 'some-icon', + title: 'Some Title', + contributions: {}, + }; + + const createVizType = (props?: Partial<VisualizationTypeOptions>): VisualizationTypeOptions => { + return { + ...DEFAULT_VIZ_PROPS, + ...props, + }; + }; + + test('should have default container contributions if none are provided', () => { + const viz = new VisualizationType(createVizType()); + + expect(viz.contributions.containers).toEqual(DEFAULT_CONTAINERS); + }); + + test('should have replace default container contributions when provided', () => { + const defaultContainer = DEFAULT_CONTAINERS.sidePanel[0]; + const viz = new VisualizationType( + createVizType({ + contributions: { + containers: { + sidePanel: [ + { + id: defaultContainer.id, + name: 'Test', + Component: <div>Test</div>, + }, + ], + }, + }, + }) + ); + + const container = viz.contributions.containers.sidePanel.find( + ({ id }) => id === defaultContainer.id + ); + expect(container).toMatchInlineSnapshot(` + Object { + "Component": <div> + Test + </div>, + "id": "data_tab", + "name": "Test", + } + `); + }); + + test('should register new container if provided', () => { + const viz = new VisualizationType( + createVizType({ + contributions: { + containers: { + sidePanel: [ + { + id: 'test_id', + name: 'Test', + Component: <div>Test</div>, + }, + ], + }, + }, + }) + ); + + const container = viz.contributions.containers.sidePanel.find(({ id }) => id === 'test_id'); + const containerNames = viz.contributions.containers.sidePanel.map(({ name }) => name); + const defaultContainerNames = DEFAULT_CONTAINERS.sidePanel.map(({ name }) => name); + + expect(containerNames).toEqual([...defaultContainerNames, 'Test']); + expect(container).toMatchInlineSnapshot(` + Object { + "Component": <div> + Test + </div>, + "id": "test_id", + "name": "Test", + } + `); + }); +}); diff --git a/src/plugins/wizard/public/services/type_service/visualization_type.ts b/src/plugins/wizard/public/services/type_service/visualization_type.ts deleted file mode 100644 index cddb000f41db..000000000000 --- a/src/plugins/wizard/public/services/type_service/visualization_type.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { IconType } from '@elastic/eui'; - -export interface VisualizationTypeOptions { - readonly name: string; - readonly title: string; - readonly description?: string; - readonly icon: IconType; - readonly stage?: 'beta' | 'production'; - readonly contributions: { - containers?: { - // Define new or override existing view containers - name: string; - title: string; - location: 'panel' | 'toolbar'; - // render: (schemas: ContainerSchema[]) => {}; // recieves an array of items to render within the container - }; - items?: { - 'container-name': any[]; // schema that is used to render the container. Each container is responsible for deciding that for consistency - // 'container-name': ContainerSchema[]; // schema that is used to render the container. Each container is responsible for deciding that for consistency - }; - }; - // pipeline: Expression; -} - -export type IVisualizationType = Required<VisualizationTypeOptions>; - -export class VisualizationType implements IVisualizationType { - public readonly name; - public readonly title; - public readonly description; - public readonly icon; - public readonly stage; - public readonly contributions; - - constructor(options: VisualizationTypeOptions) { - this.name = options.name; - this.title = options.title; - this.description = options.description ?? ''; - this.icon = options.icon; - this.stage = options.stage ?? 'production'; - this.contributions = options.contributions; - } -} diff --git a/src/plugins/wizard/public/services/type_service/visualization_type.tsx b/src/plugins/wizard/public/services/type_service/visualization_type.tsx new file mode 100644 index 000000000000..873931db7e89 --- /dev/null +++ b/src/plugins/wizard/public/services/type_service/visualization_type.tsx @@ -0,0 +1,77 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { DATA_TAB_ID, DataTab, STYLE_TAB_ID, StyleTab } from '../../application/contributions'; +import { + ContainerLocationContribution, + ContainerLocations, + VisualizationTypeOptions, +} from './types'; +import { mergeArrays } from './utils'; + +export const DEFAULT_CONTAINERS: ContainerLocationContribution = { + sidePanel: [ + { + id: DATA_TAB_ID, + name: 'Data', + Component: <DataTab />, + }, + { + id: STYLE_TAB_ID, + name: 'Style', + Component: <StyleTab />, + }, + ], + toolbar: [], +}; + +interface IVisualizationType extends Required<VisualizationTypeOptions> { + contributions: { + containers: ContainerLocationContribution; + }; +} +export class VisualizationType implements IVisualizationType { + public readonly name; + public readonly title; + public readonly description; + public readonly icon; + public readonly stage; + public readonly contributions; + + private processContributions(contributions: VisualizationTypeOptions['contributions']) { + const uiContainers: ContainerLocationContribution = { + sidePanel: [], + toolbar: [], + }; + const { containers, items } = contributions; + + // Validate and populate containers for each container location + Object.keys(uiContainers).forEach((location) => { + const typedLocation = location as ContainerLocations; + const vizContainers = containers?.[typedLocation]; + + const mergedContainers = mergeArrays( + DEFAULT_CONTAINERS[typedLocation], + vizContainers || [], + 'id' + ); + uiContainers[typedLocation] = mergedContainers; + }); + + return { + containers: uiContainers, + items, + }; + } + + constructor(options: VisualizationTypeOptions) { + this.name = options.name; + this.title = options.title; + this.description = options.description ?? ''; + this.icon = options.icon; + this.stage = options.stage ?? 'production'; + this.contributions = this.processContributions(options.contributions); + } +} diff --git a/src/plugins/wizard/public/visualizations/bar_chart/index.ts b/src/plugins/wizard/public/visualizations/bar_chart/index.ts index cc05f790993f..b135edf48ef6 100644 --- a/src/plugins/wizard/public/visualizations/bar_chart/index.ts +++ b/src/plugins/wizard/public/visualizations/bar_chart/index.ts @@ -3,14 +3,90 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { VisualizationTypeOptions } from '../../services/type_service/visualization_type'; +import { ItemTypes } from '../../application/contributions/constants'; +import { + DATA_TAB_ID, + DropboxContribution, + MainItemContribution as ConfigPanelItem, +} from '../../application/contributions'; +import { VisualizationTypeOptions } from '../../services/type_service'; export const createBarChartConfig = (): VisualizationTypeOptions => { + const configPanelItems: ConfigPanelItem[] = [ + { + type: ItemTypes.TITLE, + title: 'Bar Chart Configuration', + }, + createDropboxContribution('x_axis', 'X Axis', {}), + createDropboxContribution('y_axis', 'Y Axis', { limit: 5 }), + // { + // type: ItemTypes.INPUT, + // id: 'testLabel', + // label: 'Test Label', + // }, + ]; + return { name: 'bar_chart', title: 'Bar Chart', icon: 'visBarVertical', description: 'This is a bar chart', - contributions: {}, + contributions: { + items: { + [DATA_TAB_ID]: configPanelItems, + }, + }, }; }; + +const createDropboxContribution = ( + id: string, + label: string, + props?: Pick<DropboxContribution, 'limit'> +): DropboxContribution => ({ + type: ItemTypes.DROPBOX, + id, + label, + items: [ + { + type: ItemTypes.SELECT, + id: 'aggregation', + label: 'Select a Function', + options: (state, services) => { + const { config } = state; + // config.items[] + const { buckets } = services.data.search.aggs.types.getAll(); + return buckets.map(({ name, title, type }) => ({ + value: name, + inputDisplay: title, + })); + }, + }, + { + type: ItemTypes.INPUT, + id: 'label', + label: 'Name', + }, + ], + display: (indexField, dropboxState) => { + const dropboxField = { + icon: indexField.type, + label: indexField.displayName, + }; + + if (dropboxState?.label) { + dropboxField.label = dropboxState.label; + } + + return dropboxField; + }, + onDrop: (indexField) => { + return { + // label: indexField.displayName, + }; + }, + // isDroppable: (indexField) => { + // return indexField.displayName === 'geo.srcdest'; + // }, + ...props, +}); diff --git a/src/plugins/wizard/public/visualizations/index.ts b/src/plugins/wizard/public/visualizations/index.ts index 604de170c8ab..cf3151cf1196 100644 --- a/src/plugins/wizard/public/visualizations/index.ts +++ b/src/plugins/wizard/public/visualizations/index.ts @@ -7,10 +7,10 @@ import type { TypeServiceSetup } from '../services/type_service'; import { createBarChartConfig } from './bar_chart'; import { createPieChartConfig } from './pie_chart'; -export function registerDefaultTypes(typeServieSetup: TypeServiceSetup) { +export function registerDefaultTypes(typeServiceSetup: TypeServiceSetup) { const visualizationTypes = [createBarChartConfig, createPieChartConfig]; visualizationTypes.forEach((createTypeConfig) => { - typeServieSetup.createVisualizationType(createTypeConfig()); + typeServiceSetup.createVisualizationType(createTypeConfig()); }); } diff --git a/src/plugins/wizard/public/visualizations/pie_chart/index.ts b/src/plugins/wizard/public/visualizations/pie_chart/index.ts index b47965bc1905..e71540852a32 100644 --- a/src/plugins/wizard/public/visualizations/pie_chart/index.ts +++ b/src/plugins/wizard/public/visualizations/pie_chart/index.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { VisualizationTypeOptions } from '../../services/type_service/visualization_type'; +import { VisualizationTypeOptions } from '../../services/type_service'; export const createPieChartConfig = (): VisualizationTypeOptions => { return { diff --git a/test/functional/apps/visualize/_chart_types.ts b/test/functional/apps/visualize/_chart_types.ts index 7ddeb6c9b9cd..63538541e813 100644 --- a/test/functional/apps/visualize/_chart_types.ts +++ b/test/functional/apps/visualize/_chart_types.ts @@ -65,6 +65,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'Timeline', 'Vega', 'Vertical Bar', + 'Wizard', // TODO: Update to final name when ready ]; if (!isOss) { expectedChartTypes.push('Maps', 'Lens');