diff --git a/package.json b/package.json index b18c6dc863a5..c83730e6a3a9 100644 --- a/package.json +++ b/package.json @@ -140,6 +140,7 @@ "@osd/std": "1.0.0", "@osd/ui-framework": "1.0.0", "@osd/ui-shared-deps": "1.0.0", + "@reduxjs/toolkit": "^1.6.2", "@types/yauzl": "^2.9.1", "JSONStream": "1.3.5", "abortcontroller-polyfill": "^1.4.0", diff --git a/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap b/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap index 0c72df9a5fe5..2685e7cc8d21 100644 --- a/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap +++ b/src/plugins/visualizations/public/wizard/__snapshots__/new_vis_modal.test.tsx.snap @@ -264,10 +264,9 @@ exports[`NewVisModal filter for visualization types should render as expected 1`
-

-

-

-

- - + + + +

- - + + + +

- - + + + +

- - + + + +

-

-

-

-

- - + + + +

- - + + + +

- - + + + +

- - + + + +

); diff --git a/src/plugins/wizard/.i18nrc.json b/src/plugins/wizard/.i18nrc.json new file mode 100644 index 000000000000..2b511494a460 --- /dev/null +++ b/src/plugins/wizard/.i18nrc.json @@ -0,0 +1,7 @@ +{ + "prefix": "wizard", + "paths": { + "wizard": "." + }, + "translations": ["translations/ja-JP.json"] +} diff --git a/src/plugins/wizard/README.md b/src/plugins/wizard/README.md new file mode 100755 index 000000000000..bcb362b374cb --- /dev/null +++ b/src/plugins/wizard/README.md @@ -0,0 +1,11 @@ +# wizard + +A OpenSearch Dashboards plugin + +--- + +## Development + +See the [OpenSearch Dashboards contributing +guide](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/master/CONTRIBUTING.md) for instructions +setting up your development environment. diff --git a/src/plugins/wizard/common/index.ts b/src/plugins/wizard/common/index.ts new file mode 100644 index 000000000000..4b3522fec709 --- /dev/null +++ b/src/plugins/wizard/common/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const PLUGIN_ID = 'wizard'; +export const PLUGIN_NAME = 'Wizard'; + +export { WizardSavedObjectAttributes, WIZARD_SAVED_OBJECT } from './wizard_saved_object_attributes'; diff --git a/src/plugins/wizard/common/wizard_saved_object_attributes.ts b/src/plugins/wizard/common/wizard_saved_object_attributes.ts new file mode 100644 index 000000000000..ff6c12417d24 --- /dev/null +++ b/src/plugins/wizard/common/wizard_saved_object_attributes.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectAttributes } from 'opensearch-dashboards/public'; + +export const WIZARD_SAVED_OBJECT = 'wizard'; + +export interface WizardSavedObjectAttributes extends SavedObjectAttributes { + title: string; + description?: string; + state: string; +} diff --git a/src/plugins/wizard/opensearch_dashboards.json b/src/plugins/wizard/opensearch_dashboards.json new file mode 100644 index 000000000000..8dd00aae3890 --- /dev/null +++ b/src/plugins/wizard/opensearch_dashboards.json @@ -0,0 +1,17 @@ +{ + "id": "wizard", + "version": "1.0.0", + "opensearchDashboardsVersion": "opensearchDashboards", + "server": true, + "ui": true, + "requiredPlugins": [ + "navigation", + "data", + "opensearchDashboardsReact", + "savedObjects", + "embeddable", + "dashboard", + "visualizations" + ], + "optionalPlugins": [] +} diff --git a/src/plugins/wizard/public/application/_variables.scss b/src/plugins/wizard/public/application/_variables.scss new file mode 100644 index 000000000000..c1b3646e8e49 --- /dev/null +++ b/src/plugins/wizard/public/application/_variables.scss @@ -0,0 +1,3 @@ +@import '@elastic/eui/src/global_styling/variables/header'; + +$osdHeaderOffset: $euiHeaderHeightCompensation * 2; \ No newline at end of file diff --git a/src/plugins/wizard/public/application/app.scss b/src/plugins/wizard/public/application/app.scss new file mode 100644 index 000000000000..2e1e93f44312 --- /dev/null +++ b/src/plugins/wizard/public/application/app.scss @@ -0,0 +1,13 @@ +@import "variables"; + +.wizLayout { + padding: 0; + display: grid; + grid-template-rows: min-content 1fr; + grid-template-columns: 420px 1fr; + grid-template-areas: + "topNav topNav" + "sideNav workspace" + ; + height: calc(100vh - #{$osdHeaderOffset}); // TODO: update 190px to correct offset variable +} diff --git a/src/plugins/wizard/public/application/app.tsx b/src/plugins/wizard/public/application/app.tsx new file mode 100644 index 000000000000..7d578ee77cda --- /dev/null +++ b/src/plugins/wizard/public/application/app.tsx @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { EuiPage } from '@elastic/eui'; +import { SideNav } from './components/side_nav'; +import { DragDropProvider } from './utils/drag_drop/drag_drop_context'; +import { Workspace } from './components/workspace'; + +import './app.scss'; +import { TopNav } from './components/top_nav'; + +export const WizardApp = () => { + // Render the application DOM. + return ( + + + + + + + + + + ); +}; diff --git a/src/plugins/wizard/public/application/components/_util.scss b/src/plugins/wizard/public/application/components/_util.scss new file mode 100644 index 000000000000..9a444c1fe091 --- /dev/null +++ b/src/plugins/wizard/public/application/components/_util.scss @@ -0,0 +1,8 @@ +@mixin scrollNavParent ($template-row: none) { + display: grid; + min-height: 0; + + @if $template-row != 'none' { + grid-template-rows: $template-row; + } +} \ No newline at end of file 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 new file mode 100644 index 000000000000..7477dcfca813 --- /dev/null +++ b/src/plugins/wizard/public/application/components/data_tab/config_panel.scss @@ -0,0 +1,9 @@ +.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 new file mode 100644 index 000000000000..ec910b7352de --- /dev/null +++ b/src/plugins/wizard/public/application/components/data_tab/config_panel.tsx @@ -0,0 +1,31 @@ +/* + * 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 new file mode 100644 index 000000000000..79d0d3a913fd --- /dev/null +++ b/src/plugins/wizard/public/application/components/data_tab/config_section.scss @@ -0,0 +1,23 @@ +.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 new file mode 100644 index 000000000000..64f74824d71a --- /dev/null +++ b/src/plugins/wizard/public/application/components/data_tab/config_section.tsx @@ -0,0 +1,83 @@ +/* + * 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_search.tsx b/src/plugins/wizard/public/application/components/data_tab/field_search.tsx new file mode 100644 index 000000000000..2db8404c93c6 --- /dev/null +++ b/src/plugins/wizard/public/application/components/data_tab/field_search.tsx @@ -0,0 +1,47 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +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'; + +export interface Props { + /** + * the input value of the user + */ + value?: string; +} + +/** + * Component is Wizard's side bar to search of available fields + * Additionally there's a button displayed that allows the user to show/hide more filter fields + */ +export function FieldSearch({ value }: Props) { + const searchPlaceholder = i18n.translate('wizard.fieldChooser.searchPlaceHolder', { + defaultMessage: 'Search field names', + }); + + const dispatch = useTypedDispatch(); + + return ( + + + + dispatch(setSearchField(event.currentTarget.value))} + placeholder={searchPlaceholder} + value={value} + /> + + + + ); +} 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 new file mode 100644 index 000000000000..c05f75457b0b --- /dev/null +++ b/src/plugins/wizard/public/application/components/data_tab/field_selector.scss @@ -0,0 +1,10 @@ +@import "../util"; + +.wizFieldSelector { + @include scrollNavParent(auto 1fr); + padding: $euiSizeS; +} + +.wizFieldSelector__fieldGroups { + overflow-y: auto; +} diff --git a/src/plugins/wizard/public/application/components/data_tab/field_selector.tsx b/src/plugins/wizard/public/application/components/data_tab/field_selector.tsx new file mode 100644 index 000000000000..1464f31aabd9 --- /dev/null +++ b/src/plugins/wizard/public/application/components/data_tab/field_selector.tsx @@ -0,0 +1,118 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useState, useEffect } from 'react'; +import { EuiFlexItem, EuiAccordion, EuiSpacer, EuiNotificationBadge, EuiTitle } from '@elastic/eui'; +import { FieldSearch } from './field_search'; + +import { + IndexPatternField, + OPENSEARCH_FIELD_TYPES, + OSD_FIELD_TYPES, +} from '../../../../../data/public'; +import { FieldSelectorField } from './field_selector_field'; + +import './field_selector.scss'; +import { useTypedSelector } from '../../utils/state_management'; + +interface IFieldCategories { + categorical: IndexPatternField[]; + numerical: IndexPatternField[]; + meta: IndexPatternField[]; +} + +const META_FIELDS: string[] = [ + OPENSEARCH_FIELD_TYPES._ID, + OPENSEARCH_FIELD_TYPES._INDEX, + OPENSEARCH_FIELD_TYPES._SOURCE, + OPENSEARCH_FIELD_TYPES._TYPE, +]; + +export const FieldSelector = () => { + const indexFields = useTypedSelector((state) => state.dataSource.visualizableFields); + const [filteredFields, setFilteredFields] = useState(indexFields); + const fieldSearchValue = useTypedSelector((state) => state.dataSource.searchField); + + useEffect(() => { + const filteredSubset = indexFields.filter((field) => + field.displayName.includes(fieldSearchValue) + ); + + setFilteredFields(filteredSubset); + return; + }, [indexFields, fieldSearchValue]); + + const fields = filteredFields?.reduce( + (fieldGroups, currentField) => { + const category = getFieldCategory(currentField); + fieldGroups[category].push(currentField); + + return fieldGroups; + }, + { + categorical: [], + numerical: [], + meta: [], + } + ); + + return ( +
+
+
+ + +
+
+ + + +
+
+ ); +}; + +interface FieldGroupProps { + fields?: IndexPatternField[]; + header: string; + id: string; +} + +const FieldGroup = ({ fields, header, id }: FieldGroupProps) => ( + <> + + {header} + + } + extraAction={ + + {fields?.length || 0} + + } + initialIsOpen + > + {fields?.map((field, i) => ( + + + + ))} + + + +); + +function getFieldCategory(field: IndexPatternField): keyof IFieldCategories { + if (META_FIELDS.includes(field.name)) return 'meta'; + if (field.type === OSD_FIELD_TYPES.NUMBER) return 'numerical'; + + return 'categorical'; +} diff --git a/src/plugins/wizard/public/application/components/data_tab/field_selector_field.scss b/src/plugins/wizard/public/application/components/data_tab/field_selector_field.scss new file mode 100644 index 000000000000..0ace9a914b37 --- /dev/null +++ b/src/plugins/wizard/public/application/components/data_tab/field_selector_field.scss @@ -0,0 +1,12 @@ +.wizFieldSelectorField { + @include euiBottomShadowSmall; + padding: $euiSizeXS; + background-color: $euiColorEmptyShade; + border: $euiBorderThin; + margin-top: $euiSizeS; + + & > button { + align-items: center; + gap: 4px; + } +} diff --git a/src/plugins/wizard/public/application/components/data_tab/field_selector_field.tsx b/src/plugins/wizard/public/application/components/data_tab/field_selector_field.tsx new file mode 100644 index 000000000000..e545a6b33a63 --- /dev/null +++ b/src/plugins/wizard/public/application/components/data_tab/field_selector_field.tsx @@ -0,0 +1,83 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +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 './field_selector_field.scss'; + +export interface FieldSelectorFieldProps { + field: IndexPatternField; +} + +// 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 [infoIsOpen, setOpen] = useState(false); + const [dragProps] = useDrag(field, `dataPlane`); + + function togglePopover() { + setOpen(!infoIsOpen); + } + + function wrapOnDot(str?: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str ? str.replace(/\./g, '.\u200B') : ''; + } + + const fieldName = ( + + {wrapOnDot(field.displayName)} + + ); + + return ( + } + // fieldAction={actionButton} + fieldName={fieldName} + {...dragProps} + /> + ); +}; diff --git a/src/plugins/wizard/public/application/components/data_tab/index.scss b/src/plugins/wizard/public/application/components/data_tab/index.scss new file mode 100644 index 000000000000..1ba02bcc9879 --- /dev/null +++ b/src/plugins/wizard/public/application/components/data_tab/index.scss @@ -0,0 +1,7 @@ +@import "../util"; + +.wizDataTab { + @include scrollNavParent; + display: grid; + grid-template-columns: 50% 50%; +} diff --git a/src/plugins/wizard/public/application/components/data_tab/index.tsx b/src/plugins/wizard/public/application/components/data_tab/index.tsx new file mode 100644 index 000000000000..dd062f3a787d --- /dev/null +++ b/src/plugins/wizard/public/application/components/data_tab/index.tsx @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { FieldSelector } from './field_selector'; +import { ConfigPanel } from './config_panel'; + +import './index.scss'; + +export const DataTab = () => { + return ( +
+ + +
+ ); +}; diff --git a/src/plugins/wizard/public/application/components/side_nav.scss b/src/plugins/wizard/public/application/components/side_nav.scss new file mode 100644 index 000000000000..88ff7ffb0e47 --- /dev/null +++ b/src/plugins/wizard/public/application/components/side_nav.scss @@ -0,0 +1,18 @@ +@import "util"; + +.wizSidenav { + @include scrollNavParent(auto 1fr); + grid-area: sideNav; + border-right: $euiBorderThin; +} + +.wizDatasourceSelector { + padding: $euiSize $euiSize 0 $euiSize; +} + +.wizSidenavTabs { + @include scrollNavParent(min-content 1fr); + &>[role="tabpanel"] { + @include scrollNavParent; + } +} diff --git a/src/plugins/wizard/public/application/components/side_nav.tsx b/src/plugins/wizard/public/application/components/side_nav.tsx new file mode 100644 index 000000000000..2f9eab83fad3 --- /dev/null +++ b/src/plugins/wizard/public/application/components/side_nav.tsx @@ -0,0 +1,72 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +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'; + +export const SideNav = () => { + const { + services: { + data, + savedObjects: { client: savedObjectsClient }, + }, + } = useOpenSearchDashboards(); + const { IndexPatternSelect } = data.ui; + const { indexPattern } = useTypedSelector((state) => state.dataSource); + const dispatch = useTypedDispatch(); + + 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: , + }, + ]; + + return ( +
+
+ + {i18n.translate('wizard.nav.dataSource.selector.title', { + defaultMessage: 'Index Pattern', + })} + + { + const newIndexPattern = await data.indexPatterns.get(newIndexPatternId); + dispatch(setIndexPattern(newIndexPattern)); + }} + isClearable={false} + /> +
+ +
+ ); +}; diff --git a/src/plugins/wizard/public/application/components/style_tab.tsx b/src/plugins/wizard/public/application/components/style_tab.tsx new file mode 100644 index 000000000000..3d1eb0d98b35 --- /dev/null +++ b/src/plugins/wizard/public/application/components/style_tab.tsx @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +export const StyleTab = () => { + return
TODO: Layout styles come here.
; +}; diff --git a/src/plugins/wizard/public/application/components/top_nav.scss b/src/plugins/wizard/public/application/components/top_nav.scss new file mode 100644 index 000000000000..f8e1d1d6cfa4 --- /dev/null +++ b/src/plugins/wizard/public/application/components/top_nav.scss @@ -0,0 +1,4 @@ +.wizTopNav { + grid-area: topNav; + border-bottom: $euiBorderThin; +} \ No newline at end of file diff --git a/src/plugins/wizard/public/application/components/top_nav.tsx b/src/plugins/wizard/public/application/components/top_nav.tsx new file mode 100644 index 000000000000..5afa39f7bafd --- /dev/null +++ b/src/plugins/wizard/public/application/components/top_nav.tsx @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo } from 'react'; +import { PLUGIN_ID } from '../../../common'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { getTopNavconfig } from '../utils/get_top_nav_config'; +import { WizardServices } from '../../types'; + +import './top_nav.scss'; +import { useTypedSelector } from '../utils/state_management'; + +export const TopNav = () => { + const { services } = useOpenSearchDashboards(); + const { + setHeaderActionMenu, + navigation: { + ui: { TopNavMenu }, + }, + } = services; + + const config = useMemo(() => getTopNavconfig(services), [services]); + const { indexPattern } = useTypedSelector((state) => state.dataSource); + + return ( +
+ +
+ ); +}; diff --git a/src/plugins/wizard/public/application/components/workspace.scss b/src/plugins/wizard/public/application/components/workspace.scss new file mode 100644 index 000000000000..94a97e881bf6 --- /dev/null +++ b/src/plugins/wizard/public/application/components/workspace.scss @@ -0,0 +1,12 @@ +.wizWorkspace { + display: grid; + grid-template-rows: auto 1fr; + grid-area: workspace; + grid-gap: $euiSizeM; + padding: $euiSizeM; + background-color: $euiColorEmptyShade; +} + +.wizWorkspace__empty { + height: 100%; +} diff --git a/src/plugins/wizard/public/application/components/workspace.tsx b/src/plugins/wizard/public/application/components/workspace.tsx new file mode 100644 index 000000000000..a6550a58fb80 --- /dev/null +++ b/src/plugins/wizard/public/application/components/workspace.tsx @@ -0,0 +1,113 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiContextMenu, + EuiContextMenuPanelItemDescriptor, + EuiEmptyPrompt, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPanel, + EuiPopover, +} from '@elastic/eui'; +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 { setActiveVisualization } from '../utils/state_management/visualization_slice'; + +import './workspace.scss'; + +export const Workspace: FC = ({ children }) => { + return ( +
+ + + + + + + {children ? ( + children + ) : ( + + Welcome to the wizard!} + body={

Drag some fields onto the panel to visualize some data.

} + /> +
+ )} +
+
+ ); +}; + +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 onButtonClick = () => { + setPopover(!isPopoverOpen); + }; + + const closePopover = () => { + setPopover(false); + }; + + const panels = useMemo( + () => [ + { + id: 0, + title: 'Chart types', + items: visualizationTypes.map( + ({ name, title, icon, description }): EuiContextMenuPanelItemDescriptor => ({ + name: title, + icon: , + onClick: () => { + closePopover(); + dispatch(setActiveVisualization(name)); + }, + toolTipContent: description, + toolTipPosition: 'right', + }) + ), + }, + ], + [dispatch, visualizationTypes] + ); + + const button = ( + + {activeVisualization?.title} + + ); + + return ( + + + + ); +}; diff --git a/src/plugins/wizard/public/application/index.tsx b/src/plugins/wizard/public/application/index.tsx new file mode 100644 index 000000000000..c451d082b153 --- /dev/null +++ b/src/plugins/wizard/public/application/index.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { BrowserRouter as Router } from 'react-router-dom'; +import { Provider as ReduxProvider } from 'react-redux'; +import { Store } from 'redux'; +import { AppMountParameters } from '../../../../core/public'; +import { WizardServices } from '../types'; +import { WizardApp } from './app'; +import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; + +export const renderApp = ( + { appBasePath, element }: AppMountParameters, + services: WizardServices, + store: Store +) => { + ReactDOM.render( + + + + + + + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/src/plugins/wizard/public/application/utils/async_search/index.ts b/src/plugins/wizard/public/application/utils/async_search/index.ts new file mode 100644 index 000000000000..9746cde24e4c --- /dev/null +++ b/src/plugins/wizard/public/application/utils/async_search/index.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CreateAggConfigParams } from 'src/plugins/data/common'; +import { DataPublicPluginStart, IndexPattern } from 'src/plugins/data/public'; + +interface IDoAsyncSearch { + data: DataPublicPluginStart; + indexPattern: IndexPattern | null; + aggs?: CreateAggConfigParams[]; +} + +export const doAsyncSearch = async ({ data, indexPattern, aggs }: IDoAsyncSearch) => { + if (!indexPattern || !aggs || !aggs.length) return; + + // Constuct the query portion of the search request + const query = data.query.getOpenSearchQuery(indexPattern); + + // Constuct the aggregations portion of the search request by using the `data.search.aggs` service. + // const aggs = [{ type: 'avg', params: { field: field.name } }]; + // const aggs = [ + // { type: 'terms', params: { field: 'day_of_week' } }, + // { type: 'avg', params: { field: field.name } }, + // { type: 'terms', params: { field: 'customer_gender' } }, + // ]; + const aggConfigs = data.search.aggs.createAggConfigs(indexPattern, aggs); + const aggsDsl = aggConfigs.toDsl(); + + const request = { + params: { + index: indexPattern.title, + body: { + aggs: aggsDsl, + query, + }, + }, + }; + + // Submit the search request using the `data.search` service. + const { rawResponse } = await data.search.search(request).toPromise(); + + return { + rawResponse, + aggConfigs, + }; +}; 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 new file mode 100644 index 000000000000..a89226885d5d --- /dev/null +++ b/src/plugins/wizard/public/application/utils/drag_drop/drag_drop_context.tsx @@ -0,0 +1,113 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { createContext, DragEvent, FC, ReactNode, useContext, useState } from 'react'; + +interface DrapDataType { + namespace: string; + value: any; +} + +// TODO: Replace any with corret type +// TODO: Split into separate files +interface IDragDropContext { + data?: DrapDataType; + setData?: any; + isDragging: boolean; + setIsDragging?: any; +} + +const defaultContextProps = { + isDragging: false, +}; + +const DragDropContext = createContext(defaultContextProps); + +const DragDropProvider: FC = ({ children }) => { + const [isDragging, setIsDragging] = useState(false); + const [data, setData] = useState(); + return ( + + {children} + + ); +}; + +const useDragDropContext = () => useContext(DragDropContext); + +const useDrag = (dragData: any, namespace: string) => { + const { setData, setIsDragging } = useDragDropContext(); + const dragElementProps = { + draggable: true, + onDragStart: (event: DragEvent) => { + setIsDragging(true); + setData({ + namespace, + value: dragData, + }); + }, + onDragEnd: (event: DragEvent) => { + setIsDragging(false); + setData(null); + }, + }; + return [dragElementProps]; +}; + +interface IDropAttributes { + onDragOver: (event: DragEvent) => void; + onDrop: (event: DragEvent) => void; + onDragEnter: (event: DragEvent) => void; + onDragLeave: (event: DragEvent) => void; +} + +interface IDropState { + isDragging: boolean; + canDrop: boolean; + isValidDropTarget: boolean; + dragData: any; +} +const useDrop = (namespace: string, onDropCallback: Function): [IDropAttributes, IDropState] => { + const { data, isDragging, setIsDragging, setData } = useDragDropContext(); + const [canDrop, setCanDrop] = useState(false); + + const dropAttributes: IDropAttributes = { + onDragOver: (event) => { + event.preventDefault(); + }, + onDrop: (event) => { + setIsDragging(false); + onDropCallback(data?.value); + setData(null); + }, + onDragEnter: (event) => { + if (data?.namespace === namespace) { + setCanDrop(true); + } + }, + onDragLeave: (event) => { + setCanDrop(false); + }, + }; + return [ + dropAttributes, + { + isDragging, + canDrop, + isValidDropTarget: isDragging && data?.namespace === namespace, + dragData: data?.value, + }, + ]; +}; + +export { DragDropContext, DragDropProvider, useDragDropContext, useDrag, useDrop }; diff --git a/src/plugins/wizard/public/application/utils/drag_drop/index.ts b/src/plugins/wizard/public/application/utils/drag_drop/index.ts new file mode 100644 index 000000000000..3799a2eb6052 --- /dev/null +++ b/src/plugins/wizard/public/application/utils/drag_drop/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './drag_drop_context'; diff --git a/src/plugins/wizard/public/application/utils/get_top_nav_config.tsx b/src/plugins/wizard/public/application/utils/get_top_nav_config.tsx new file mode 100644 index 000000000000..725f7f2baa92 --- /dev/null +++ b/src/plugins/wizard/public/application/utils/get_top_nav_config.tsx @@ -0,0 +1,127 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { TopNavMenuData } from '../../../../navigation/public'; +import { + OnSaveProps, + SavedObjectSaveModalOrigin, + showSaveModal, +} from '../../../../saved_objects/public'; +import { WizardServices } from '../..'; + +export const getTopNavconfig = ({ + savedObjects: { client: savedObjectsClient }, + toastNotifications, + i18n: { Context: I18nContext }, +}: WizardServices) => { + const topNavConfig: TopNavMenuData[] = [ + { + id: 'save', + iconType: 'save', + emphasize: true, + label: 'Save', + testId: 'wizardSaveButton', + run: (anchorElement) => { + const onSave = async ({ + // TODO: Figure out what the other props here do + newTitle, + newCopyOnSave, + isTitleDuplicateConfirmed, + onTitleDuplicate, + newDescription, + returnToOrigin, + }: OnSaveProps & { returnToOrigin: boolean }) => { + // TODO: Save the actual state of the wizard + const wizardSavedObject = await savedObjectsClient.create('wizard', { + title: newTitle, + description: newDescription, + state: JSON.stringify({}), + }); + + try { + const id = await wizardSavedObject.save(); + + if (id) { + toastNotifications.addSuccess({ + title: i18n.translate( + 'wizard.topNavMenu.saveVisualization.successNotificationText', + { + defaultMessage: `Saved '{visTitle}'`, + values: { + visTitle: newTitle, + }, + } + ), + 'data-test-subj': 'saveVisualizationSuccess', + }); + + return { id }; + } + + throw new Error('Saved but no id returned'); + } catch (error: any) { + // eslint-disable-next-line no-console + console.error(error); + + toastNotifications.addDanger({ + title: i18n.translate( + 'visualize.topNavMenu.saveVisualization.failureNotificationText', + { + defaultMessage: `Error on saving '{visTitle}'`, + values: { + visTitle: newTitle, + }, + } + ), + text: error.message, + 'data-test-subj': 'saveVisualizationError', + }); + return { error }; + } + }; + + const saveModal = ( + {}} + /> + ); + + showSaveModal(saveModal, I18nContext); + }, + }, + ]; + + return topNavConfig; +}; 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 new file mode 100644 index 000000000000..5d8908596104 --- /dev/null +++ b/src/plugins/wizard/public/application/utils/state_management/config_slice.ts @@ -0,0 +1,62 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { IndexPatternField } from '../../../../../data/public'; + +interface ConfigSections { + [id: string]: { + title: string; + fields: IndexPatternField[]; + }; +} +interface ConfigState { + configSections: ConfigSections; +} + +// 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: [], + }, + }, +}; + +interface SectionField { + sectionId: string; + field: IndexPatternField; +} + +export const slice = createSlice({ + name: 'configuration', + initialState, + reducers: { + addConfigSectionField: (state, action: PayloadAction) => { + const { field, sectionId } = action.payload; + if (state.configSections[sectionId]) { + state.configSections[sectionId].fields.push(field); + } + }, + removeConfigSectionField: (state, action: PayloadAction) => { + 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); + } + }, + }, +}); + +export const { reducer } = slice; +export const { addConfigSectionField, removeConfigSectionField } = slice.actions; diff --git a/src/plugins/wizard/public/application/utils/state_management/datasource_slice.ts b/src/plugins/wizard/public/application/utils/state_management/datasource_slice.ts new file mode 100644 index 000000000000..d51d463d68ee --- /dev/null +++ b/src/plugins/wizard/public/application/utils/state_management/datasource_slice.ts @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { IndexPattern } from 'src/plugins/data/common'; +import { WizardServices } from '../../../types'; + +import { IndexPatternField, OSD_FIELD_TYPES } from '../../../../../data/public'; + +const ALLOWED_FIELDS: string[] = [OSD_FIELD_TYPES.STRING, OSD_FIELD_TYPES.NUMBER]; + +interface DataSourceState { + indexPattern: IndexPattern | null; + visualizableFields: IndexPatternField[]; + searchField: string; +} + +const initialState: DataSourceState = { + indexPattern: null, + visualizableFields: [], + searchField: '', +}; + +export const getPreloadedState = async ({ data }: WizardServices): Promise => { + const preloadedState = { ...initialState }; + + const defaultIndexPattern = await data.indexPatterns.getDefault(); + if (defaultIndexPattern) { + preloadedState.indexPattern = defaultIndexPattern; + preloadedState.visualizableFields = defaultIndexPattern.fields.filter(isVisualizable); + } + + return preloadedState; +}; + +export const slice = createSlice({ + name: 'dataSource', + initialState, + reducers: { + setIndexPattern: (state, action: PayloadAction) => { + state.indexPattern = action.payload; + state.visualizableFields = action.payload.fields.filter(isVisualizable); + }, + setSearchField: (state, action: PayloadAction) => { + state.searchField = action.payload; + }, + }, +}); + +export const { reducer } = slice; +export const { setIndexPattern, setSearchField } = slice.actions; + +// TODO: Temporary validate function +// Need to identify how to get fieldCounts to use the standard filter and group functions +function isVisualizable(field: IndexPatternField): boolean { + const isAggregatable = field.aggregatable === true; + const isNotScripted = !field.scripted; + const isAllowed = ALLOWED_FIELDS.includes(field.type); + + return isAggregatable && isNotScripted && isAllowed; +} diff --git a/src/plugins/wizard/public/application/utils/state_management/hooks.ts b/src/plugins/wizard/public/application/utils/state_management/hooks.ts new file mode 100644 index 000000000000..823c34528c90 --- /dev/null +++ b/src/plugins/wizard/public/application/utils/state_management/hooks.ts @@ -0,0 +1,11 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; +import type { RootState, AppDispatch } from './store'; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useTypedDispatch = () => useDispatch(); +export const useTypedSelector: TypedUseSelectorHook = useSelector; diff --git a/src/plugins/wizard/public/application/utils/state_management/index.ts b/src/plugins/wizard/public/application/utils/state_management/index.ts new file mode 100644 index 000000000000..edb5c2a17184 --- /dev/null +++ b/src/plugins/wizard/public/application/utils/state_management/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './store'; +export * from './hooks'; diff --git a/src/plugins/wizard/public/application/utils/state_management/preload.ts b/src/plugins/wizard/public/application/utils/state_management/preload.ts new file mode 100644 index 000000000000..ad78b642c23e --- /dev/null +++ b/src/plugins/wizard/public/application/utils/state_management/preload.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PreloadedState } from '@reduxjs/toolkit'; +import { WizardServices } from '../../..'; +import { getPreloadedState as getPreloadedDatasourceState } from './datasource_slice'; +import { getPreloadedState as getPreloadedVisualizationState } from './visualization_slice'; +import { RootState } from './store'; + +export const getPreloadedState = async ( + services: WizardServices +): Promise> => { + const dataSourceState = await getPreloadedDatasourceState(services); + const visualizationState = await getPreloadedVisualizationState(services); + + return { + dataSource: dataSourceState, + visualization: visualizationState, + }; +}; diff --git a/src/plugins/wizard/public/application/utils/state_management/store.ts b/src/plugins/wizard/public/application/utils/state_management/store.ts new file mode 100644 index 000000000000..4fa56c1a7c97 --- /dev/null +++ b/src/plugins/wizard/public/application/utils/state_management/store.ts @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { combineReducers, configureStore, PreloadedState } from '@reduxjs/toolkit'; +import { reducer as dataSourceReducer } from './datasource_slice'; +import { reducer as configReducer } from './config_slice'; +import { reducer as visualizationReducer } from './visualization_slice'; +import { WizardServices } from '../../..'; +import { getPreloadedState } from './preload'; + +const rootReducer = combineReducers({ + dataSource: dataSourceReducer, + config: configReducer, + visualization: visualizationReducer, +}); + +export const configurePreloadedStore = (preloadedState: PreloadedState) => { + return configureStore({ + reducer: rootReducer, + preloadedState, + }); +}; + +export const getPreloadedStore = async (services: WizardServices) => { + const preloadedState = await getPreloadedState(services); + return configurePreloadedStore(preloadedState); +}; + +// Infer the `RootState` and `AppDispatch` types from the store itself +export type RootState = ReturnType; +type Store = ReturnType; +export type AppDispatch = Store['dispatch']; diff --git a/src/plugins/wizard/public/application/utils/state_management/visualization_slice.ts b/src/plugins/wizard/public/application/utils/state_management/visualization_slice.ts new file mode 100644 index 000000000000..692f9434c8de --- /dev/null +++ b/src/plugins/wizard/public/application/utils/state_management/visualization_slice.ts @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { WizardServices } from '../../../types'; + +interface VisualizationState { + activeVisualization: string | null; +} + +const initialState: VisualizationState = { + activeVisualization: null, +}; + +export const getPreloadedState = async ({ types }: WizardServices): Promise => { + const preloadedState = { ...initialState }; + + const defaultVisualization = types.all()[0]; + if (defaultVisualization) { + preloadedState.activeVisualization = defaultVisualization.name; + } + + return preloadedState; +}; + +export const slice = createSlice({ + name: 'visualization', + initialState, + reducers: { + setActiveVisualization: (state, action: PayloadAction) => { + state.activeVisualization = action.payload; + }, + }, +}); + +export const { reducer } = slice; +export const { setActiveVisualization } = slice.actions; diff --git a/src/plugins/wizard/public/index.ts b/src/plugins/wizard/public/index.ts new file mode 100644 index 000000000000..97f9007549a0 --- /dev/null +++ b/src/plugins/wizard/public/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginInitializerContext } from '../../../core/public'; +import { WizardPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, OpenSearch Dashboards Platform `plugin()` initializer. +export function plugin(initializerContext: PluginInitializerContext) { + return new WizardPlugin(initializerContext); +} +export { WizardServices, WizardPluginStartDependencies } from './types'; diff --git a/src/plugins/wizard/public/plugin.ts b/src/plugins/wizard/public/plugin.ts new file mode 100644 index 000000000000..5b309080a872 --- /dev/null +++ b/src/plugins/wizard/public/plugin.ts @@ -0,0 +1,98 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { + AppMountParameters, + AppNavLinkStatus, + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, +} from '../../../core/public'; +import { + WizardPluginSetupDependencies, + WizardPluginStartDependencies, + WizardServices, + WizardSetup, +} from './types'; +import { PLUGIN_NAME } from '../common'; +import { TypeService } from './services/type_service'; +import { getPreloadedStore } from './application/utils/state_management'; + +export class WizardPlugin + implements + Plugin { + private typeService = new TypeService(); + + constructor(public initializerContext: PluginInitializerContext) {} + + public setup( + core: CoreSetup, + { visualizations }: WizardPluginSetupDependencies + ) { + const typeService = this.typeService; + // Register the plugin to core + core.application.register({ + id: 'wizard', + title: PLUGIN_NAME, + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + // Load application bundle + const { renderApp } = await import('./application'); + // Get start services as specified in opensearch_dashboards.json + const [coreStart, pluginsStart] = await core.getStartServices(); + const { data, savedObjects, navigation } = pluginsStart; + + const { registerDefaultTypes } = await import('./visualizations'); + registerDefaultTypes(typeService.setup()); + + const services: WizardServices = { + ...coreStart, + toastNotifications: coreStart.notifications.toasts, + data, + savedObjectsPublic: savedObjects, + navigation, + setHeaderActionMenu: params.setHeaderActionMenu, + types: typeService.start(), + }; + + // make sure the index pattern list is up to date + data.indexPatterns.clearCache(); + // make sure a default index pattern exists + // if not, the page will be redirected to management and visualize won't be rendered + // TODO: Add the redirect + await pluginsStart.data.indexPatterns.ensureDefaultIndexPattern(); + + const store = await getPreloadedStore(services); + + // Render the application + return renderApp(params, services, store); + }, + }); + + // Register the plugin as an alias to create visualization + visualizations.registerAlias({ + name: 'wizard', + title: 'Wizard', + description: i18n.translate('wizard.vizPicker.description', { + defaultMessage: 'TODO...', + }), + // TODO: Replace with actual icon once available + icon: 'vector', + stage: 'beta', + aliasApp: 'wizard', + aliasPath: '#/', + }); + + return { + ...typeService.setup(), + }; + } + + public start(core: CoreStart) {} + + public stop() {} +} diff --git a/src/plugins/wizard/public/services/type_service/index.ts b/src/plugins/wizard/public/services/type_service/index.ts new file mode 100644 index 000000000000..1fae953fb9b8 --- /dev/null +++ b/src/plugins/wizard/public/services/type_service/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './type_service'; diff --git a/src/plugins/wizard/public/services/type_service/type_service.ts b/src/plugins/wizard/public/services/type_service/type_service.ts new file mode 100644 index 000000000000..d43d779f75ea --- /dev/null +++ b/src/plugins/wizard/public/services/type_service/type_service.ts @@ -0,0 +1,86 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { VisualizationType, VisualizationTypeOptions } from './visualization_type'; + +/** + * Vis Types Service + * + * @internal + */ +export class TypeService { + private types: Record = {}; + + private registerVisualizationType(visDefinition: VisualizationType) { + if (this.types[visDefinition.name]) { + throw new Error('type already exists!'); + } + this.types[visDefinition.name] = visDefinition; + } + + public setup() { + return { + /** + * registers a visualization type + * @param config - visualization type definition + */ + createVisualizationType: (config: VisualizationTypeOptions): void => { + const vis = new VisualizationType(config); + this.registerVisualizationType(vis); + }, + }; + } + + public start() { + return { + /** + * returns specific visualization or undefined if not found + * @param {string} visualization - id of visualization to return + */ + get: (visualization: string): VisualizationType | undefined => { + return this.types[visualization]; + }, + /** + * returns all registered visualization types + */ + all: (): VisualizationType[] => { + return [...Object.values(this.types)]; + }, + }; + } + + public stop() { + // nothing to do here yet + } +} + +/** @internal */ +export type TypeServiceSetup = ReturnType; +export type TypeServiceStart = ReturnType; diff --git a/src/plugins/wizard/public/services/type_service/visualization_type.ts b/src/plugins/wizard/public/services/type_service/visualization_type.ts new file mode 100644 index 000000000000..cddb000f41db --- /dev/null +++ b/src/plugins/wizard/public/services/type_service/visualization_type.ts @@ -0,0 +1,48 @@ +/* + * 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; + +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/types.ts b/src/plugins/wizard/public/types.ts new file mode 100644 index 000000000000..07b1e5141c61 --- /dev/null +++ b/src/plugins/wizard/public/types.ts @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsStart } from 'src/plugins/saved_objects/public'; +import { AppMountParameters, CoreStart, ToastsStart } from 'opensearch-dashboards/public'; +import { EmbeddableSetup } from 'src/plugins/embeddable/public'; +import { DashboardStart } from 'src/plugins/dashboard/public'; +import { VisualizationsSetup } from 'src/plugins/visualizations/public'; +import { NavigationPublicPluginStart } from '../../navigation/public'; +import { DataPublicPluginStart } from '../../data/public'; +import { TypeServiceSetup, TypeServiceStart } from './services/type_service'; + +export type WizardSetup = TypeServiceSetup; + +export interface WizardPluginSetupDependencies { + embeddable: EmbeddableSetup; + visualizations: VisualizationsSetup; +} +export interface WizardPluginStartDependencies { + navigation: NavigationPublicPluginStart; + data: DataPublicPluginStart; + savedObjects: SavedObjectsStart; + dashboard: DashboardStart; +} + +export interface WizardServices extends CoreStart { + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + toastNotifications: ToastsStart; + savedObjectsPublic: SavedObjectsStart; + navigation: NavigationPublicPluginStart; + data: DataPublicPluginStart; + types: TypeServiceStart; +} diff --git a/src/plugins/wizard/public/visualizations/bar_chart/index.ts b/src/plugins/wizard/public/visualizations/bar_chart/index.ts new file mode 100644 index 000000000000..cc05f790993f --- /dev/null +++ b/src/plugins/wizard/public/visualizations/bar_chart/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VisualizationTypeOptions } from '../../services/type_service/visualization_type'; + +export const createBarChartConfig = (): VisualizationTypeOptions => { + return { + name: 'bar_chart', + title: 'Bar Chart', + icon: 'visBarVertical', + description: 'This is a bar chart', + contributions: {}, + }; +}; diff --git a/src/plugins/wizard/public/visualizations/index.ts b/src/plugins/wizard/public/visualizations/index.ts new file mode 100644 index 000000000000..604de170c8ab --- /dev/null +++ b/src/plugins/wizard/public/visualizations/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { TypeServiceSetup } from '../services/type_service'; +import { createBarChartConfig } from './bar_chart'; +import { createPieChartConfig } from './pie_chart'; + +export function registerDefaultTypes(typeServieSetup: TypeServiceSetup) { + const visualizationTypes = [createBarChartConfig, createPieChartConfig]; + + visualizationTypes.forEach((createTypeConfig) => { + typeServieSetup.createVisualizationType(createTypeConfig()); + }); +} diff --git a/src/plugins/wizard/public/visualizations/pie_chart/index.ts b/src/plugins/wizard/public/visualizations/pie_chart/index.ts new file mode 100644 index 000000000000..b47965bc1905 --- /dev/null +++ b/src/plugins/wizard/public/visualizations/pie_chart/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VisualizationTypeOptions } from '../../services/type_service/visualization_type'; + +export const createPieChartConfig = (): VisualizationTypeOptions => { + return { + name: 'pie_chart', + title: 'Pie Chart', + icon: 'visPie', + contributions: {}, + }; +}; diff --git a/src/plugins/wizard/server/index.ts b/src/plugins/wizard/server/index.ts new file mode 100644 index 000000000000..e995ea17b4a7 --- /dev/null +++ b/src/plugins/wizard/server/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginInitializerContext } from '../../../core/server'; +import { WizardPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, OpenSearch Dashboards Platform `plugin()` initializer. + +export function plugin(initializerContext: PluginInitializerContext) { + return new WizardPlugin(initializerContext); +} + +export { WizardPluginSetup, WizardPluginStart } from './types'; diff --git a/src/plugins/wizard/server/plugin.ts b/src/plugins/wizard/server/plugin.ts new file mode 100644 index 000000000000..d45e4081cce9 --- /dev/null +++ b/src/plugins/wizard/server/plugin.ts @@ -0,0 +1,44 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '../../../core/server'; + +import { WizardPluginSetup, WizardPluginStart } from './types'; +import { defineRoutes } from './routes'; +import { wizardApp } from './saved_objects'; + +export class WizardPlugin implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup({ http, savedObjects }: CoreSetup) { + this.logger.debug('wizard: Setup'); + const router = http.createRouter(); + + // Register server side APIs + defineRoutes(router); + + // Register saved object types + savedObjects.registerType(wizardApp); + + return {}; + } + + public start(core: CoreStart) { + this.logger.debug('wizard: Started'); + return {}; + } + + public stop() {} +} diff --git a/src/plugins/wizard/server/routes/index.ts b/src/plugins/wizard/server/routes/index.ts new file mode 100644 index 000000000000..f6268695e838 --- /dev/null +++ b/src/plugins/wizard/server/routes/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IRouter } from '../../../../core/server'; + +export function defineRoutes(router: IRouter) { + router.get( + { + path: '/api/wizard/example', + validate: false, + }, + async (context, request, response) => { + return response.ok({ + body: { + time: new Date().toISOString(), + }, + }); + } + ); +} diff --git a/src/plugins/wizard/server/saved_objects/index.ts b/src/plugins/wizard/server/saved_objects/index.ts new file mode 100644 index 000000000000..aa90fcea911b --- /dev/null +++ b/src/plugins/wizard/server/saved_objects/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { wizardApp } from './wizard_app'; diff --git a/src/plugins/wizard/server/saved_objects/wizard_app.ts b/src/plugins/wizard/server/saved_objects/wizard_app.ts new file mode 100644 index 000000000000..138bea03b22a --- /dev/null +++ b/src/plugins/wizard/server/saved_objects/wizard_app.ts @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsType } from 'src/core/server'; +import { WIZARD_SAVED_OBJECT } from '../../common'; + +export const wizardApp: SavedObjectsType = { + name: WIZARD_SAVED_OBJECT, + hidden: false, + namespaceType: 'single', + management: { + icon: 'visVisualBuilder', // TODO: Need a custom icon here + defaultSearchField: 'title', + importableAndExportable: true, + getTitle: (obj: { attributes: { title: string } }) => obj.attributes.title, + // getInAppUrl: TODO: Enable once editing is supported + }, + migrations: {}, + mappings: { + properties: { + title: { + type: 'text', + }, + description: { + type: 'text', + }, + // TODO: Determine what needs to be pulled out of state and added directly into the mapping + state: { + type: 'text', + index: false, + }, + }, + }, +}; diff --git a/src/plugins/wizard/server/types.ts b/src/plugins/wizard/server/types.ts new file mode 100644 index 000000000000..5d26185a0374 --- /dev/null +++ b/src/plugins/wizard/server/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface WizardPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface WizardPluginStart {} diff --git a/yarn.lock b/yarn.lock index 5804cdfa640c..ef19a747860b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2544,6 +2544,16 @@ colors "~1.2.1" string-argv "~0.3.1" +"@reduxjs/toolkit@^1.6.2": + version "1.6.2" + resolved "https://registry.yarnpkg.com/@reduxjs/toolkit/-/toolkit-1.6.2.tgz#2f2b5365df77dd6697da28fdf44f33501ed9ba37" + integrity sha512-HbfI/hOVrAcMGAYsMWxw3UJyIoAS9JTdwddsjlr5w3S50tXhWb+EMyhIw+IAvCVCLETkzdjgH91RjDSYZekVBA== + dependencies: + immer "^9.0.6" + redux "^4.1.0" + redux-thunk "^2.3.0" + reselect "^4.0.0" + "@samverschueren/stream-to-observable@^0.3.0": version "0.3.1" resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.1.tgz#a21117b19ee9be70c379ec1877537ef2e1c63301" @@ -14983,6 +14993,13 @@ redux@^4.0.0, redux@^4.0.4, redux@^4.0.5: dependencies: "@babel/runtime" "^7.9.2" +redux@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/redux/-/redux-4.1.2.tgz#140f35426d99bb4729af760afcf79eaaac407104" + integrity sha512-SH8PglcebESbd/shgf6mii6EIoRM0zrQyjcuQ+ojmfxjTtE0z9Y8pa62iA/OJ58qjP6j27uyW4kUF4jl/jd6sw== + dependencies: + "@babel/runtime" "^7.9.2" + reflect.ownkeys@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz#749aceec7f3fdf8b63f927a04809e90c5c0b3460"