From f19a9caa01c4c400a28693606dab060296db2bdb Mon Sep 17 00:00:00 2001 From: ananzh Date: Tue, 21 Nov 2023 07:11:30 +0000 Subject: [PATCH] a Signed-off-by: ananzh --- .../components/config_panel/dropbox.scss | 106 +++++++++ .../components/config_panel/dropbox.tsx | 157 ++++++++++++++ .../components/config_panel/index.scss | 48 +++++ .../components/config_panel/index.tsx | 30 +++ .../config_panel/schema_to_dropbox.tsx | 22 ++ .../config_panel/secondary_panel.tsx | 201 ++++++++++++++++++ .../components/config_panel/title.tsx | 39 ++++ .../application/components/right_nav.tsx | 127 +++++++++++ .../application/components/top_nav.scss | 8 + .../public/application/components/top_nav.tsx | 95 +++++++++ .../application/components/workspace.scss | 59 +++++ .../application/components/workspace.tsx | 159 ++++++++++++++ .../public/application/utils/use/index.ts | 2 +- .../utils/use/use_visualization_type.ts | 24 +++ .../view_components/canvas/index.tsx | 53 ++++- 15 files changed, 1127 insertions(+), 3 deletions(-) create mode 100644 src/plugins/vis_builder_new/public/application/components/config_panel/dropbox.scss create mode 100644 src/plugins/vis_builder_new/public/application/components/config_panel/dropbox.tsx create mode 100644 src/plugins/vis_builder_new/public/application/components/config_panel/index.scss create mode 100644 src/plugins/vis_builder_new/public/application/components/config_panel/index.tsx create mode 100644 src/plugins/vis_builder_new/public/application/components/config_panel/schema_to_dropbox.tsx create mode 100644 src/plugins/vis_builder_new/public/application/components/config_panel/secondary_panel.tsx create mode 100644 src/plugins/vis_builder_new/public/application/components/config_panel/title.tsx create mode 100644 src/plugins/vis_builder_new/public/application/components/right_nav.tsx create mode 100644 src/plugins/vis_builder_new/public/application/components/top_nav.scss create mode 100644 src/plugins/vis_builder_new/public/application/components/top_nav.tsx create mode 100644 src/plugins/vis_builder_new/public/application/components/workspace.scss create mode 100644 src/plugins/vis_builder_new/public/application/components/workspace.tsx create mode 100644 src/plugins/vis_builder_new/public/application/utils/use/use_visualization_type.ts diff --git a/src/plugins/vis_builder_new/public/application/components/config_panel/dropbox.scss b/src/plugins/vis_builder_new/public/application/components/config_panel/dropbox.scss new file mode 100644 index 000000000000..b50152f0983d --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/config_panel/dropbox.scss @@ -0,0 +1,106 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +.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: calc($euiSizeXS / 2); + padding: calc($euiSizeS - ($euiSizeXS / 2)) $euiSizeS $euiSizeS $euiSizeS; + background-color: $euiColorLightShade; + border-radius: $euiBorderRadius; + } + + &__field { + display: grid; + grid-template-columns: 1fr auto; + grid-gap: $euiSizeS; + padding: $euiSizeS $euiSizeM; + align-items: center; + } + + &__draggable { + padding: calc($euiSizeXS / 2) 0; + animation: pop-in $euiAnimSpeedSlow $euiAnimSlightResistance forwards; + transform-origin: bottom; + + &.closing { + animation: pop-out $euiAnimSpeedSlow $euiAnimSlightResistance forwards; // Also update speed in dropbox.tsx + } + } + + &__field_text { + text-overflow: ellipsis; + overflow: hidden; + } + + &__dropTarget { + color: $euiColorDarkShade; + grid-template-columns: 1fr auto; + transform-origin: top; + animation: pop-in $euiAnimSpeedFast $euiAnimSlightResistance forwards; + + &.validField { + background-color: tintOrShade($euiColorPrimary, 80%, 70%); + border-color: tintOrShade($euiColorPrimary, 80%, 70%); + + &.canDrop { + background-color: tintOrShade($euiColorPrimary, 60%, 40%); + border-color: tintOrShade($euiColorPrimary, 30%, 20%); + border-style: dashed; + } + } + } +} + +@keyframes pop-in { + from { + max-height: 0; + opacity: 0; + } + + to { + max-height: 1000px; + opacity: 1; + } +} + +@keyframes pop-out { + from { + max-height: 1000px; + opacity: 1; + } + + to { + max-height: 0; + opacity: 0; + } +} + +@media (prefers-reduced-motion) { + .dropBox { + &__draggable { + animation: none; + + &.closing { + animation: none; + } + } + + &__dropTarget { + animation: none; + } + } +} diff --git a/src/plugins/vis_builder_new/public/application/components/config_panel/dropbox.tsx b/src/plugins/vis_builder_new/public/application/components/config_panel/dropbox.tsx new file mode 100644 index 000000000000..70b43a2c6014 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/config_panel/dropbox.tsx @@ -0,0 +1,157 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { + EuiButtonIcon, + EuiDragDropContext, + EuiDraggable, + EuiDroppable, + EuiFormRow, + EuiPanel, + EuiText, + DropResult, +} from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; +import { IDropAttributes, IDropState } from '../../utils/drag_drop'; +import './dropbox.scss'; +import { useDropbox } from './use'; +import { UseDropboxProps } from './use/use_dropbox'; +import { usePrefersReducedMotion } from './use/use_prefers_reduced_motion'; + +export interface DropboxDisplay { + label: string; + id: string; +} + +interface DropboxProps extends IDropState { + id: string; + label: string; + fields: DropboxDisplay[]; + limit?: number; + onAddField: () => void; + onEditField: (id: string) => void; + onDeleteField: (id: string) => void; + onReorderField: ({ + sourceAggId, + destinationAggId, + }: { + sourceAggId: string; + destinationAggId: string; + }) => void; + dropProps: IDropAttributes; +} + +const DropboxComponent = ({ + id: dropboxId, + label: boxLabel, + fields, + onAddField, + onDeleteField, + onEditField, + onReorderField, + limit = 1, + isValidDropTarget, + canDrop, + dropProps, +}: DropboxProps) => { + const prefersReducedMotion = usePrefersReducedMotion(); + const [closing, setClosing] = useState(false); + const handleDragEnd = useCallback( + ({ source, destination }: DropResult) => { + if (!destination) return; + + onReorderField({ + sourceAggId: fields[source.index].id, + destinationAggId: fields[destination.index].id, + }); + }, + [fields, onReorderField] + ); + + const animateDelete = useCallback( + (id: string) => { + setClosing(id); + setTimeout( + () => { + onDeleteField(id); + setClosing(false); + }, + prefersReducedMotion ? 0 : 350 // Also update speed in dropbox.scss + ); + }, + [onDeleteField, prefersReducedMotion] + ); + + return ( + + +
+ + {fields.map(({ id, label }, index) => ( + + + onEditField(id)}> + + {label} + + + animateDelete(id)} + data-test-subj="dropBoxRemoveBtn" + /> + + + ))} + + {fields.length < limit && ( + + + {i18n.translate('visBuilder.dropbox.addField.title', { + defaultMessage: 'Click or drop to add', + })} + + onAddField()} + data-test-subj="dropBoxAddBtn" + /> + + )} +
+
+
+ ); +}; + +const Dropbox = React.memo((dropBox: UseDropboxProps) => { + const props = useDropbox(dropBox); + + return ; +}); + +export { Dropbox, DropboxComponent, DropboxProps }; diff --git a/src/plugins/vis_builder_new/public/application/components/config_panel/index.scss b/src/plugins/vis_builder_new/public/application/components/config_panel/index.scss new file mode 100644 index 000000000000..ba5ee2e401cb --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/config_panel/index.scss @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +.vbConfig { + @include euiYScrollWithShadows; + + 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; + } + + &__aggEditor { + padding: 0 $euiSizeM; + } + + &--secondary { + position: absolute; + top: 0; + left: 100%; + + .visEditorAggParam--half { + margin: $euiSize 0; + } + } + + &.showSecondary > .vbConfig__section { + transform: translateX(-100%); + } +} diff --git a/src/plugins/vis_builder_new/public/application/components/config_panel/index.tsx b/src/plugins/vis_builder_new/public/application/components/config_panel/index.tsx new file mode 100644 index 000000000000..74a20781655b --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/config_panel/index.tsx @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiForm } from '@elastic/eui'; +import React from 'react'; +import { useVisualizationType } from '../../utils/use'; +import { useSelector } from '../../utils/state_management'; +import { mapSchemaToAggPanel } from './schema_to_dropbox'; +import { SecondaryPanel } from './secondary_panel'; + +import './index.scss'; + +export function ConfigPanel() { + const vizType = useVisualizationType(); + const editingState = useSelector((state) => state.vbVisualization.activeVisualization?.draftAgg); + const schemas = vizType.ui.containerConfig.data.schemas; + + if (!schemas) return null; + + const mainPanel = mapSchemaToAggPanel(schemas); + + return ( + +
{mainPanel}
+ +
+ ); +} diff --git a/src/plugins/vis_builder_new/public/application/components/config_panel/schema_to_dropbox.tsx b/src/plugins/vis_builder_new/public/application/components/config_panel/schema_to_dropbox.tsx new file mode 100644 index 000000000000..518a6ae7af2f --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/config_panel/schema_to_dropbox.tsx @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Schemas } from '../../../../../vis_default_editor/public'; +import { Dropbox } from './dropbox'; +import { Title } from './title'; + +export const mapSchemaToAggPanel = (schemas: Schemas) => { + const panelComponents = schemas.all.map((schema) => { + return ; + }); + + return ( + <> + + <div className="vbConfig__content">{panelComponents}</div> + </> + ); +}; diff --git a/src/plugins/vis_builder_new/public/application/components/config_panel/secondary_panel.tsx b/src/plugins/vis_builder_new/public/application/components/config_panel/secondary_panel.tsx new file mode 100644 index 000000000000..3cc0e1b4d5d8 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/config_panel/secondary_panel.tsx @@ -0,0 +1,201 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Component, useCallback, useMemo, useState } from 'react'; +import { cloneDeep, get } from 'lodash'; +import { useDebounce } from 'react-use'; +import { i18n } from '@osd/i18n'; +import { EuiCallOut } from '@elastic/eui'; +import { useTypedDispatch, useSelector } from '../../utils/state_management'; +import { DefaultEditorAggParams } from '../../../../../vis_default_editor/public'; +import { Title } from './title'; +import { useVisualizationType } from '../../utils/use'; +import { + OpenSearchDashboardsContextProvider, + useOpenSearchDashboards, +} from '../../../../../opensearch_dashboards_react/public'; +import { useVisBuilderContext } from '../../view_components/context'; +import { VisBuilderServices } from '../../../types'; +import { AggParam, IAggType, IFieldParamType } from '../../../../../data/public'; +import { saveDraftAgg, editDraftAgg } from '../../utils/state_management/visualization_slice'; +import { setError } from '../../utils/state_management/editor_slice'; +import { Storage } from '../../../../../opensearch_dashboards_utils/public'; + +const PANEL_KEY = 'SECONDARY_PANEL'; + +export function SecondaryPanel() { + const { draftAgg, aggConfigParams } = useSelector( + (state) => state.vbVisualization.activeVisualization! + ); + const isEditorValid = useSelector((state) => !state.vbEditor.errors[PANEL_KEY]); + const [touched, setTouched] = useState(false); + const dispatch = useTypedDispatch(); + const vizType = useVisualizationType(); + const { indexPattern } = useVisBuilderContext(); + const { services } = useOpenSearchDashboards<VisBuilderServices>(); + const { + data: { + search: { aggs: aggService }, + }, + } = services; + const schemas = vizType.ui.containerConfig.data.schemas.all; + + const aggConfigs = useMemo(() => { + return ( + indexPattern && draftAgg && aggService.createAggConfigs(indexPattern, [cloneDeep(draftAgg)]) + ); + }, [draftAgg, aggService, indexPattern]); + const aggConfig = aggConfigs?.aggs[0]; + + const metricAggs = useMemo( + () => + indexPattern + ? aggService.createAggConfigs( + indexPattern, + cloneDeep( + aggConfigParams.filter((aggConfigParam) => aggConfigParam.schema === 'metric') + ) + ).aggs + : [], + [aggConfigParams, aggService, indexPattern] + ); + + const selectedSchema = useMemo( + () => schemas.find((schema) => schema.name === aggConfig?.schema), + [aggConfig?.schema, schemas] + ); + + const showAggParamEditor = !!(aggConfig && indexPattern); + + const closeMenu = useCallback(() => { + dispatch(editDraftAgg(undefined)); + }, [dispatch]); + + const handleSetValid = useCallback( + (isValid: boolean) => { + // Set validity state globally + dispatch( + setError({ + key: PANEL_KEY, + error: !isValid, + }) + ); + }, + [dispatch] + ); + + // Autosave is agg value has changed and edits are valid + useDebounce( + () => { + if (isEditorValid) { + dispatch(saveDraftAgg()); + } else { + // To indicate that an invalid edit was made + setTouched(true); + } + }, + 200, + [draftAgg, isEditorValid] + ); + + return ( + <div className="vbConfig__section vbConfig--secondary"> + <Title title={selectedSchema?.title ?? 'Edit'} isSecondary closeMenu={closeMenu} /> + {showAggParamEditor && ( + <OpenSearchDashboardsContextProvider + services={{ + ...services, + storage: new Storage(window.localStorage), // This is necessary for filters + }} + > + <EditorErrorBoundary> + <DefaultEditorAggParams + className="vbConfig__aggEditor" + agg={aggConfig!} + indexPattern={indexPattern!} + setValidity={handleSetValid} + setTouched={setTouched} + schemas={schemas} + formIsTouched={touched} + groupName={selectedSchema?.group ?? 'none'} + metricAggs={metricAggs} + state={{ + data: {}, + description: '', + title: '', + }} + setAggParamValue={function <T extends string | number | symbol>( + aggId: string, + paramName: T, + value: any + ): void { + aggConfig.params[paramName] = value; + dispatch(editDraftAgg(aggConfig.serialize())); + }} + onAggTypeChange={function (aggId: string, aggType: IAggType): void { + aggConfig.type = aggType; + + // Persist field if the new agg type supports the existing field + const fieldParam = (aggType.params as AggParam[]).find( + ({ type }) => type === 'field' + ); + if (fieldParam) { + const availableFields = (fieldParam as IFieldParamType).getAvailableFields( + aggConfig + ); + const indexField = availableFields.find( + ({ name }) => name === get(draftAgg, 'params.field') + ); + + if (indexField) { + aggConfig.params.field = indexField; + } + } + + dispatch(editDraftAgg(aggConfig.serialize())); + }} + /> + </EditorErrorBoundary> + </OpenSearchDashboardsContextProvider> + )} + </div> + ); +} + +class EditorErrorBoundary extends Component<{}, { error?: any }> { + state = { + error: undefined, + }; + + static getDerivedStateFromError(error: any) { + return { error }; + } + + componentDidCatch(error) { + // eslint-disable-next-line no-console + console.error(error); + } + + render() { + if (this.state.error) { + return ( + <EuiCallOut + title={i18n.translate('visBuilder.aggParamsEditor.errorTitle', { + defaultMessage: 'Error', + })} + color="danger" + iconType="alert" + > + <p> + {i18n.translate('visBuilder.aggParamsEditor.errorMsg', { + defaultMessage: 'Something went wrong while editing the aggregation', + })} + </p> + </EuiCallOut> + ); + } + return this.props.children; + } +} diff --git a/src/plugins/vis_builder_new/public/application/components/config_panel/title.tsx b/src/plugins/vis_builder_new/public/application/components/config_panel/title.tsx new file mode 100644 index 000000000000..0d50aad0fb47 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/config_panel/title.tsx @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiSpacer, + EuiTitle, +} from '@elastic/eui'; +export interface TitleProps { + title: string; + isSecondary?: boolean; + closeMenu?: () => void; +} + +export const Title = ({ title, isSecondary, closeMenu }: TitleProps) => { + const icon = isSecondary && ( + <EuiIcon type="arrowLeft" onClick={closeMenu} data-test-subj="panelCloseBtn" /> + ); + return ( + <> + <div className="vbConfig__title"> + <EuiFlexGroup gutterSize="s" alignItems="center"> + {icon && <EuiFlexItem grow={false}>{icon}</EuiFlexItem>} + <EuiFlexItem> + <EuiTitle size="xxs"> + <h2>{title}</h2> + </EuiTitle> + </EuiFlexItem> + </EuiFlexGroup> + </div> + {isSecondary ? <EuiHorizontalRule margin="s" /> : <EuiSpacer size="s" />} + </> + ); +}; diff --git a/src/plugins/vis_builder_new/public/application/components/right_nav.tsx b/src/plugins/vis_builder_new/public/application/components/right_nav.tsx new file mode 100644 index 000000000000..fde4f3110d1c --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/right_nav.tsx @@ -0,0 +1,127 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo, useState } from 'react'; +import { + EuiSuperSelect, + EuiSuperSelectOption, + EuiIcon, + IconType, + EuiConfirmModal, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import { useVisualizationType } from '../utils/use'; +import './side_nav.scss'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { VisBuilderServices } from '../../types'; +import { + ActiveVisPayload, + setActiveVisualization, + useTypedDispatch, + useTypedSelector, +} from '../utils/state_management'; +import { getPersistedAggParams } from '../utils/get_persisted_agg_params'; + +export const RightNavUI = () => { + const { ui, name: activeVisName } = useVisualizationType(); + const [confirmAggs, setConfirmAggs] = useState<ActiveVisPayload | undefined>(); + const { + services: { types }, + } = useOpenSearchDashboards<VisBuilderServices>(); + const dispatch = useTypedDispatch(); + const StyleSection = ui.containerConfig.style.render; + + const { activeVisualization } = useTypedSelector((state) => state.visualization); + const aggConfigParams = useMemo(() => activeVisualization?.aggConfigParams ?? [], [ + activeVisualization, + ]); + + const handleVisTypeChange = useCallback( + (newVisName) => { + const currentVisSchemas = types.get(activeVisName)?.ui.containerConfig.data.schemas.all ?? []; + const newVisSchemas = types.get(newVisName)?.ui.containerConfig.data.schemas.all ?? []; + const persistedAggParams = getPersistedAggParams( + aggConfigParams, + currentVisSchemas, + newVisSchemas + ); + + const newVis = { + name: newVisName, + aggConfigParams: persistedAggParams, + style: types.get(newVisName)?.ui.containerConfig.style.defaults, + }; + + if (persistedAggParams.length < aggConfigParams.length) return setConfirmAggs(newVis); + + dispatch(setActiveVisualization(newVis)); + }, + [activeVisName, aggConfigParams, dispatch, types] + ); + + const options: Array<EuiSuperSelectOption<string>> = types.all().map(({ name, icon, title }) => ({ + value: name, + inputDisplay: <OptionItem icon={icon} title={title} />, + dropdownDisplay: <OptionItem icon={icon} title={title} />, + 'data-test-subj': `visType-${name}`, + })); + + return ( + <section className="vbSidenav right"> + <div className="vbSidenav__header"> + <EuiSuperSelect + options={options} + valueOfSelected={activeVisName} + onChange={handleVisTypeChange} + fullWidth + data-test-subj="chartPicker" + /> + </div> + <div className="vbSidenav__style"> + <StyleSection /> + </div> + {confirmAggs && ( + <EuiConfirmModal + title={i18n.translate('visBuilder.rightNav.changeVisType.modalTitle', { + defaultMessage: 'Change visualization type', + })} + confirmButtonText={i18n.translate('visBuilder.rightNav.changeVisType.confirmText', { + defaultMessage: 'Change type', + })} + cancelButtonText={i18n.translate('visBuilder.rightNav.changeVisType.cancelText', { + defaultMessage: 'Cancel', + })} + onCancel={() => setConfirmAggs(undefined)} + onConfirm={() => { + dispatch(setActiveVisualization(confirmAggs)); + + setConfirmAggs(undefined); + }} + maxWidth="300px" + data-test-subj="confirmVisChangeModal" + > + <p> + <FormattedMessage + id="visBuilder.rightNav.changeVisType.modalDescription" + defaultMessage="Certain field configurations may be lost when changing visualization types and you may need to reconfigure those fields. Do you want to continue?" + /> + </p> + </EuiConfirmModal> + )} + </section> + ); +}; + +const OptionItem = ({ icon, title }: { icon: IconType; title: string }) => ( + <> + <EuiIcon type={icon} className="vbTypeSelector__icon" /> + <span>{title}</span> + </> +); + +// The app uses EuiResizableContainer that triggers a rerender for every mouseover action. +// To prevent this child component from unnecessarily rerendering in that instance, it needs to be memoized +export const RightNav = React.memo(RightNavUI); diff --git a/src/plugins/vis_builder_new/public/application/components/top_nav.scss b/src/plugins/vis_builder_new/public/application/components/top_nav.scss new file mode 100644 index 000000000000..36349ed8cdba --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/top_nav.scss @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +.vbTopNav { + grid-area: topNav; + border-bottom: $euiBorderThin; +} diff --git a/src/plugins/vis_builder_new/public/application/components/top_nav.tsx b/src/plugins/vis_builder_new/public/application/components/top_nav.tsx new file mode 100644 index 000000000000..768f2db35465 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/top_nav.tsx @@ -0,0 +1,95 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { useUnmount } from 'react-use'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { getTopNavConfig } from '../utils/get_top_nav_config'; +import { VisBuilderServices } from '../../types'; + +import './top_nav.scss'; +import { useIndexPatterns, useSavedVisBuilderVis } from '../utils/use'; +import { useTypedSelector, useTypedDispatch } from '../utils/state_management'; +import { setEditorState } from '../utils/state_management/metadata_slice'; +import { useCanSave } from '../utils/use/use_can_save'; +import { saveStateToSavedObject } from '../../saved_visualizations/transforms'; +import { TopNavMenuData } from '../../../../navigation/public'; +import { opensearchFilters, connectStorageToQueryState } from '../../../../data/public'; + +export const TopNav = () => { + // id will only be set for the edit route + const { id: visualizationIdFromUrl } = useParams<{ id: string }>(); + const { services } = useOpenSearchDashboards<VisBuilderServices>(); + const { + setHeaderActionMenu, + navigation: { + ui: { TopNavMenu }, + }, + appName, + } = services; + const rootState = useTypedSelector((state) => state); + const dispatch = useTypedDispatch(); + + const saveDisabledReason = useCanSave(); + const savedVisBuilderVis = useSavedVisBuilderVis(visualizationIdFromUrl); + connectStorageToQueryState(services.data.query, services.osdUrlStateStorage, { + filters: opensearchFilters.FilterStateStore.APP_STATE, + query: true, + }); + const { selected: indexPattern } = useIndexPatterns(); + const [config, setConfig] = useState<TopNavMenuData[] | undefined>(); + const originatingApp = useTypedSelector((state) => { + return state.metadata.originatingApp; + }); + + useEffect(() => { + const getConfig = () => { + if (!savedVisBuilderVis || !indexPattern) return; + + return getTopNavConfig( + { + visualizationIdFromUrl, + savedVisBuilderVis: saveStateToSavedObject(savedVisBuilderVis, rootState, indexPattern), + saveDisabledReason, + dispatch, + originatingApp, + }, + services + ); + }; + + setConfig(getConfig()); + }, [ + rootState, + savedVisBuilderVis, + services, + visualizationIdFromUrl, + saveDisabledReason, + dispatch, + indexPattern, + originatingApp, + ]); + + // reset validity before component destroyed + useUnmount(() => { + dispatch(setEditorState({ state: 'loading' })); + }); + + return ( + <div className="vbTopNav"> + <TopNavMenu + appName={appName} + config={config} + setMenuMountPoint={setHeaderActionMenu} + indexPatterns={indexPattern ? [indexPattern] : []} + showDatePicker={!!indexPattern?.timeFieldName ?? true} + showSearchBar + showSaveQuery + useDefaultBehaviors + /> + </div> + ); +}; diff --git a/src/plugins/vis_builder_new/public/application/components/workspace.scss b/src/plugins/vis_builder_new/public/application/components/workspace.scss new file mode 100644 index 000000000000..09285ab14985 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/workspace.scss @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +$animation-time: 3; +$animation-multiplier: 5; +$total-duartion: $animation-time * $animation-multiplier; +$keyframe-multiplier: calc(1 / $animation-multiplier); + +.vbWorkspace { + display: grid; + -ms-grid-rows: auto $euiSizeM 1fr; + grid-template-rows: auto 1fr; + grid-area: workspace; + grid-gap: $euiSizeM; + padding: $euiSizeM; + background-color: $euiColorEmptyShade; + height: 100%; + + &__empty { + height: 100%; + } + + &__container { + position: relative; + } + + &__handFieldSvg { + animation: vbDragAnimation #{$total-duartion}s ease-in-out infinite forwards; + position: absolute; + top: 34.5%; + width: 50% !important; + } +} + +@media (prefers-reduced-motion) { + .vbWorkspace__handFieldSvg { + animation: none; + } +} + +@keyframes vbDragAnimation { + 0% { + transform: none; + } + + #{$keyframe-multiplier * 50%} { + transform: translate(116%, -80%); + } + + #{$keyframe-multiplier * 100%} { + transform: none; + } + + 100% { + transform: none; + } +} diff --git a/src/plugins/vis_builder_new/public/application/components/workspace.tsx b/src/plugins/vis_builder_new/public/application/components/workspace.tsx new file mode 100644 index 000000000000..31880e93bb7f --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/workspace.tsx @@ -0,0 +1,159 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { EuiEmptyPrompt, EuiFlexGroup, EuiFlexItem, EuiIcon, EuiPanel } from '@elastic/eui'; +import React, { useState, useMemo, useEffect, useLayoutEffect } from 'react'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { IExpressionLoaderParams } from '../../../../expressions/public'; +import { VisBuilderServices } from '../../types'; +import { validateSchemaState, validateAggregations } from '../utils/validations'; +import { useTypedDispatch, useTypedSelector, setUIStateState } from '../utils/state_management'; +import { useAggs, useVisualizationType } from '../utils/use'; +import { PersistedState } from '../../../../visualizations/public'; + +import hand_field from '../../assets/hand_field.svg'; +import fields_bg from '../../assets/fields_bg.svg'; + +import './workspace.scss'; +import { ExperimentalInfo } from './experimental_info'; +import { handleVisEvent } from '../utils/handle_vis_event'; + +export const WorkspaceUI = () => { + const { + services: { + expressions: { ReactExpressionRenderer }, + notifications: { toasts }, + data, + uiActions, + }, + } = useOpenSearchDashboards<VisBuilderServices>(); + const { toExpression, ui } = useVisualizationType(); + const { aggConfigs, indexPattern } = useAggs(); + const [expression, setExpression] = useState<string>(); + const [searchContext, setSearchContext] = useState<IExpressionLoaderParams['searchContext']>({ + query: data.query.queryString.getQuery(), + filters: data.query.filterManager.getFilters(), + timeRange: data.query.timefilter.timefilter.getTime(), + }); + const rootState = useTypedSelector((state) => state); + const dispatch = useTypedDispatch(); + // Visualizations require the uiState object to persist even when the expression changes + // eslint-disable-next-line react-hooks/exhaustive-deps + const uiState = useMemo(() => new PersistedState(rootState.ui), []); + + useEffect(() => { + if (rootState.metadata.editor.state === 'loaded') { + uiState.setSilent(rootState.ui); + } + // To update uiState once saved object data is loaded + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [rootState.metadata.editor.state, uiState]); + + useEffect(() => { + uiState.on('change', (args) => { + // Store changes to UI state + dispatch(setUIStateState(uiState.toJSON())); + }); + }, [dispatch, uiState]); + + useEffect(() => { + async function loadExpression() { + const schemas = ui.containerConfig.data.schemas; + + const noAggs = (aggConfigs?.aggs?.length ?? 0) === 0; + const schemaValidation = validateSchemaState(schemas, rootState.visualization); + const aggValidation = validateAggregations(aggConfigs?.aggs || []); + + if (!aggValidation.valid || !schemaValidation.valid) { + setExpression(undefined); + if (noAggs) return; // don't show error when there are no active aggregations + + const err = schemaValidation.errorMsg || aggValidation.errorMsg; + + if (err) + toasts.addWarning({ + id: 'vb_expression_validation', + title: err, + }); + + return; + } + + const exp = await toExpression(rootState, searchContext); + setExpression(exp); + } + + loadExpression(); + }, [rootState, toExpression, toasts, ui.containerConfig.data.schemas, searchContext, aggConfigs]); + + useLayoutEffect(() => { + const subscription = data.query.state$.subscribe(({ state }) => { + setSearchContext({ + query: state.query, + timeRange: state.time, + filters: state.filters, + }); + }); + + return () => { + subscription.unsubscribe(); + }; + }, [data.query.state$]); + + return ( + <section className="vbWorkspace"> + <EuiFlexGroup className="vbCanvasControls"> + <EuiFlexItem> + <ExperimentalInfo /> + </EuiFlexItem> + </EuiFlexGroup> + <EuiPanel className="vbCanvas" data-test-subj="visualizationLoader"> + {expression ? ( + <ReactExpressionRenderer + expression={expression} + searchContext={searchContext} + uiState={uiState} + onEvent={(event) => handleVisEvent(event, uiActions, indexPattern?.timeFieldName)} + /> + ) : ( + <EuiFlexItem className="vbWorkspace__empty" data-test-subj="emptyWorkspace"> + <EuiEmptyPrompt + title={ + <h2> + {i18n.translate('visBuilder.workSpace.empty.title', { + defaultMessage: 'Add a field to start', + })} + </h2> + } + body={ + <> + <p> + {i18n.translate('visBuilder.workSpace.empty.description', { + defaultMessage: + 'Drag a field to the configuration panel to generate a visualization.', + })} + </p> + <div className="vbWorkspace__container"> + <EuiIcon className="vbWorkspace__fieldSvg" type={fields_bg} size="original" /> + <EuiIcon + className="vbWorkspace__handFieldSvg" + type={hand_field} + size="original" + /> + </div> + </> + } + /> + </EuiFlexItem> + )} + </EuiPanel> + </section> + ); +}; + +// The app uses EuiResizableContainer that triggers a rerender for every mouseover action. +// To prevent this child component from unnecessarily rerendering in that instance, it needs to be memoized +export const Workspace = React.memo(WorkspaceUI); diff --git a/src/plugins/vis_builder_new/public/application/utils/use/index.ts b/src/plugins/vis_builder_new/public/application/utils/use/index.ts index 024fc1e545cb..d5ffbe3cd7e9 100644 --- a/src/plugins/vis_builder_new/public/application/utils/use/index.ts +++ b/src/plugins/vis_builder_new/public/application/utils/use/index.ts @@ -8,4 +8,4 @@ export { useIndexPattern } from './use_index_pattern'; export { useOnAddFilter } from './use_on_add_filter'; export { useSampleHits } from './use_sample_hits'; // export { useSavedVisBuilderVis } from './use_saved_vis_builder_vis'; -// export { useVisualizationType } from './use_visualization_type'; +export { useVisualizationType } from './use_visualization_type'; diff --git a/src/plugins/vis_builder_new/public/application/utils/use/use_visualization_type.ts b/src/plugins/vis_builder_new/public/application/utils/use/use_visualization_type.ts new file mode 100644 index 000000000000..2785f51a924d --- /dev/null +++ b/src/plugins/vis_builder_new/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 { VisBuilderServices } from '../../../types'; +import { useTypedSelector } from '../state_management'; + +export const useVisualizationType = (): VisualizationType => { + const { activeVisualization } = useTypedSelector((state) => state.visualization); + const { + services: { types }, + } = useOpenSearchDashboards<VisBuilderServices>(); + + const visualizationType = types.get(activeVisualization?.name ?? ''); + + if (!visualizationType) { + throw new Error(`Invalid visualization type ${activeVisualization}`); + } + + return visualizationType; +}; diff --git a/src/plugins/vis_builder_new/public/application/view_components/canvas/index.tsx b/src/plugins/vis_builder_new/public/application/view_components/canvas/index.tsx index b3b3062e14c8..033ea9f7d57c 100644 --- a/src/plugins/vis_builder_new/public/application/view_components/canvas/index.tsx +++ b/src/plugins/vis_builder_new/public/application/view_components/canvas/index.tsx @@ -4,10 +4,59 @@ */ import React from 'react'; -import { EuiLoadingSpinner } from '@elastic/eui'; +import { EuiPage, EuiResizableContainer } from '@elastic/eui'; +import { I18nProvider } from '@osd/i18n/react'; import { ViewProps } from '../../../../../data_explorer/public'; +import { DragDropProvider } from '../../utils/drag_drop/drag_drop_context'; +import { TopNav } from '../../components/top_nav'; +import { Workspace } from '../../components/workspace'; +import { RightNav } from '../../components/right_nav'; +import { ConfigPanel } from '../../components/config_panel'; // eslint-disable-next-line import/no-default-export export default function VisBuilderCanvas(props: ViewProps) { - return <EuiLoadingSpinner size="l" />; + return ( + <I18nProvider> + <DragDropProvider> + <EuiPage className="vbLayout"> + <TopNav /> + <EuiResizableContainer className="vbLayout__resizeContainer"> + {(EuiResizablePanel, EuiResizableButton) => ( + <> + <EuiResizablePanel + className="vbLayout__configPanelResize" + paddingSize="none" + initialSize={20} + minSize="250px" + mode="main" + > + <ConfigPanel /> + </EuiResizablePanel> + <EuiResizableButton className="vbLayout__resizeButton" /> + <EuiResizablePanel + className="vbLayout__workspaceResize" + paddingSize="none" + initialSize={60} + minSize="300px" + mode="main" + > + <Workspace /> + </EuiResizablePanel> + <EuiResizableButton className="vbLayout__resizeButton" /> + <EuiResizablePanel + className="vbLayout__rightNavResize" + paddingSize="none" + initialSize={20} + minSize="250px" + mode="main" + > + <RightNav /> + </EuiResizablePanel> + </> + )} + </EuiResizableContainer> + </EuiPage> + </DragDropProvider> + </I18nProvider> + ); }