From d51d037afd281dc2ce21872e49eeb186a69fe26c Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Tue, 14 Jun 2022 13:43:04 -0700 Subject: [PATCH] [D&D] Refactor to use AggService and introduce metric visualization (#1734) * partial progress Signed-off-by: Ashwin Pc * simle workign metric using aggShemas Signed-off-by: Ashwin Pc * updated VisualizationTypeOptions to be a generic Signed-off-by: Ashwin Pc * partially working metric style options Signed-off-by: Ashwin Pc * all state objects are serializeable Signed-off-by: Ashwin Pc * working delete and reorder Signed-off-by: Ashwin Pc * chore: cleanup agg service changes Signed-off-by: Ashwin Pc --- .../components/sidebar/state/reducers.ts | 1 + .../public/components/metric_vis_options.tsx | 2 +- src/plugins/vis_type_metric/public/index.ts | 3 + .../vis_type_metric/public/metric_vis_fn.ts | 2 +- .../public/legacy/build_pipeline.ts | 12 +- src/plugins/wizard/opensearch_dashboards.json | 6 +- .../application/components/side_nav.scss | 8 +- .../application/components/side_nav.tsx | 40 ++- .../public/application/components/top_nav.tsx | 4 +- .../application/components/workspace.tsx | 26 +- .../containers/data_tab/config_panel.tsx | 117 ++++---- .../containers/data_tab/field_search.tsx | 2 +- .../containers/data_tab/field_selector.tsx | 30 +- .../containers/data_tab/items/dropbox.scss | 17 +- .../containers/data_tab/items/dropbox.tsx | 29 +- .../containers/data_tab/items/title.tsx | 51 +--- .../data_tab/items/use/use_dropbox.tsx | 278 +++++++++++++----- .../data_tab/items/use/use_form_field.tsx | 40 +-- .../containers/data_tab/secondary_panel.tsx | 19 ++ .../data_tab/utils/schema_to_dropbox.tsx | 21 ++ .../utils/state_management/config_slice.ts | 176 ----------- .../state_management/datasource_slice.ts | 63 ---- .../utils/state_management/hooks.ts | 2 +- .../utils/state_management/preload.ts | 9 +- .../utils/state_management/store.ts | 6 +- .../utils/state_management/style_slice.ts | 47 +++ .../state_management/visualization_slice.ts | 81 ++++- .../public/application/utils/use/index.ts | 1 + .../utils/use/use_index_pattern.tsx | 30 ++ .../utils/use/use_visualization_type.ts | 4 +- src/plugins/wizard/public/plugin.ts | 27 +- src/plugins/wizard/public/plugin_services.ts | 15 + .../public/services/type_service/types.ts | 25 +- .../type_service/visualization_type.test.tsx | 143 ++++----- .../type_service/visualization_type.tsx | 62 +--- src/plugins/wizard/public/types.ts | 3 + .../wizard/public/visualizations/index.ts | 9 +- .../metric/components/metric_viz_options.tsx | 149 ++++++++++ .../public/visualizations/metric/index.ts | 6 + .../visualizations/metric/metric_viz_type.ts | 116 ++++++++ .../visualizations/metric/to_expression.ts | 177 +++++++++++ 41 files changed, 1223 insertions(+), 636 deletions(-) create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/secondary_panel.tsx create mode 100644 src/plugins/wizard/public/application/contributions/containers/data_tab/utils/schema_to_dropbox.tsx delete mode 100644 src/plugins/wizard/public/application/utils/state_management/config_slice.ts delete mode 100644 src/plugins/wizard/public/application/utils/state_management/datasource_slice.ts create mode 100644 src/plugins/wizard/public/application/utils/state_management/style_slice.ts create mode 100644 src/plugins/wizard/public/application/utils/use/use_index_pattern.tsx create mode 100644 src/plugins/wizard/public/plugin_services.ts create mode 100644 src/plugins/wizard/public/visualizations/metric/components/metric_viz_options.tsx create mode 100644 src/plugins/wizard/public/visualizations/metric/index.ts create mode 100644 src/plugins/wizard/public/visualizations/metric/metric_viz_type.ts create mode 100644 src/plugins/wizard/public/visualizations/metric/to_expression.ts diff --git a/src/plugins/vis_default_editor/public/components/sidebar/state/reducers.ts b/src/plugins/vis_default_editor/public/components/sidebar/state/reducers.ts index f41b07698df8..7d2f1cd0a9cd 100644 --- a/src/plugins/vis_default_editor/public/components/sidebar/state/reducers.ts +++ b/src/plugins/vis_default_editor/public/components/sidebar/state/reducers.ts @@ -57,6 +57,7 @@ const createEditorStateReducer = ({ !state.data.aggs!.aggs.find((agg) => agg.schema === schema.name) && schema.defaults ? (schema as any).defaults.slice(0, schema.max) : { schema: schema.name }; + const aggConfig = state.data.aggs!.createAggConfig(defaultConfig, { addToAggConfigs: false, }); diff --git a/src/plugins/vis_type_metric/public/components/metric_vis_options.tsx b/src/plugins/vis_type_metric/public/components/metric_vis_options.tsx index 76533eae4da3..02f7b6cafb45 100644 --- a/src/plugins/vis_type_metric/public/components/metric_vis_options.tsx +++ b/src/plugins/vis_type_metric/public/components/metric_vis_options.tsx @@ -97,7 +97,7 @@ function MetricVisOptions({ ); const setColorMode: EuiButtonGroupProps['onChange'] = useCallback( - (id) => setMetricValue('metricColorMode', id as ColorModes), + (id: string) => setMetricValue('metricColorMode', id as ColorModes), [setMetricValue] ); diff --git a/src/plugins/vis_type_metric/public/index.ts b/src/plugins/vis_type_metric/public/index.ts index 428b40f24acd..1b90e139b03c 100644 --- a/src/plugins/vis_type_metric/public/index.ts +++ b/src/plugins/vis_type_metric/public/index.ts @@ -34,3 +34,6 @@ import { MetricVisPlugin as Plugin } from './plugin'; export function plugin(initializerContext: PluginInitializerContext) { return new Plugin(initializerContext); } + +/* Public Types */ +export { MetricVisExpressionFunctionDefinition } from './metric_vis_fn'; diff --git a/src/plugins/vis_type_metric/public/metric_vis_fn.ts b/src/plugins/vis_type_metric/public/metric_vis_fn.ts index fcdb10b74e23..e03571089fa1 100644 --- a/src/plugins/vis_type_metric/public/metric_vis_fn.ts +++ b/src/plugins/vis_type_metric/public/metric_vis_fn.ts @@ -54,7 +54,7 @@ interface Arguments { colorRange: Range[]; font: Style; metric: any[]; // these aren't typed yet - bucket: any; // these aren't typed yet + bucket?: any; // these aren't typed yet } export interface MetricVisRenderValue { diff --git a/src/plugins/visualizations/public/legacy/build_pipeline.ts b/src/plugins/visualizations/public/legacy/build_pipeline.ts index e0f2ee7e1cca..ae7f3bbe7a29 100644 --- a/src/plugins/visualizations/public/legacy/build_pipeline.ts +++ b/src/plugins/visualizations/public/legacy/build_pipeline.ts @@ -373,7 +373,8 @@ export const buildVislibDimensions = async (vis: any, params: BuildPipelineParam splitColumn: schemas.split_column, }; if (schemas.segment) { - const xAgg = vis.data.aggs.getResponseAggs()[dimensions.x.accessor]; + const a = vis.data.aggs.getResponseAggs(); + const xAgg = a[dimensions.x.accessor]; if (xAgg.type.name === 'date_histogram') { dimensions.x.params.date = true; const { opensearchUnit, opensearchValue } = xAgg.buckets.getInterval(); @@ -423,10 +424,10 @@ export const buildPipeline = async (vis: Vis, params: BuildPipelineParams) => { // request handler if (vis.type.requestHandler === 'courier') { pipeline += `opensearchaggs - ${prepareString('index', indexPattern!.id)} - metricsAtAllLevels=${vis.isHierarchical()} - partialRows=${vis.params.showPartialRows || false} - ${prepareJson('aggConfigs', vis.data.aggs!.aggs)} | `; + ${prepareString('index', indexPattern!.id)} + metricsAtAllLevels=${vis.isHierarchical()} + partialRows=${vis.params.showPartialRows || false} + ${prepareJson('aggConfigs', vis.data.aggs!.aggs)} | `; } const schemas = getSchemas(vis, params); @@ -456,5 +457,6 @@ export const buildPipeline = async (vis: Vis, params: BuildPipelineParams) => { } } } + return pipeline; }; diff --git a/src/plugins/wizard/opensearch_dashboards.json b/src/plugins/wizard/opensearch_dashboards.json index 5d4402106e0c..020ca5f2ab9b 100644 --- a/src/plugins/wizard/opensearch_dashboards.json +++ b/src/plugins/wizard/opensearch_dashboards.json @@ -6,13 +6,17 @@ "ui": true, "requiredPlugins": [ "navigation", + "charts", "data", "opensearchDashboardsReact", + "opensearchDashboardsUtils", "savedObjects", "embeddable", + "expressions", "dashboard", "visualizations", - "opensearchUiShared" + "opensearchUiShared", + "visDefaultEditor" ], "optionalPlugins": [] } diff --git a/src/plugins/wizard/public/application/components/side_nav.scss b/src/plugins/wizard/public/application/components/side_nav.scss index 8da4b26d20e6..caa24e30b395 100644 --- a/src/plugins/wizard/public/application/components/side_nav.scss +++ b/src/plugins/wizard/public/application/components/side_nav.scss @@ -2,6 +2,7 @@ .wizSidenav { @include scrollNavParent(auto 1fr); + grid-area: sideNav; border-right: $euiBorderThin; } @@ -11,8 +12,13 @@ } .wizSidenavTabs { + .euiTab__content { + text-transform: capitalize; + } + @include scrollNavParent(min-content 1fr); - &>[role="tabpanel"] { + + & > [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 index 3bb1f1d76618..c74837ceab54 100644 --- a/src/plugins/wizard/public/application/components/side_nav.tsx +++ b/src/plugins/wizard/public/application/components/side_nav.tsx @@ -10,8 +10,10 @@ import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react import { WizardServices } from '../../types'; import './side_nav.scss'; import { useTypedDispatch, useTypedSelector } from '../utils/state_management'; -import { setIndexPattern } from '../utils/state_management/datasource_slice'; +import { setIndexPattern } from '../utils/state_management/visualization_slice'; import { useVisualizationType } from '../utils/use'; +import { DataTab } from '../contributions'; +import { StyleTabConfig } from '../../services/type_service'; export const SideNav = () => { const { @@ -21,17 +23,32 @@ export const SideNav = () => { }, } = useOpenSearchDashboards(); const { IndexPatternSelect } = data.ui; - const { indexPattern } = useTypedSelector((state) => state.dataSource); + const { indexPattern: indexPatternId } = useTypedSelector((state) => state.visualization); const dispatch = useTypedDispatch(); const { - contributions: { containers }, + ui: { containerConfig }, } = useVisualizationType(); - const tabs: EuiTabbedContentTab[] = containers.sidePanel.map(({ id, name, Component }) => ({ - id, - name, - content: Component, - })); + const tabs: EuiTabbedContentTab[] = Object.entries(containerConfig).map( + ([containerName, config]) => { + let content = null; + switch (containerName) { + case 'data': + content = ; + break; + + case 'style': + content = (config as StyleTabConfig).render(); + break; + } + + return { + id: containerName, + name: containerName, + content, + }; + } + ); return (
@@ -46,10 +63,13 @@ export const SideNav = () => { placeholder={i18n.translate('wizard.nav.dataSource.selector.placeholder', { defaultMessage: 'Select index pattern', })} - indexPatternId={indexPattern?.id || ''} + indexPatternId={indexPatternId || ''} onChange={async (newIndexPatternId: any) => { const newIndexPattern = await data.indexPatterns.get(newIndexPatternId); - dispatch(setIndexPattern(newIndexPattern)); + + if (newIndexPattern) { + dispatch(setIndexPattern(newIndexPatternId)); + } }} isClearable={false} /> diff --git a/src/plugins/wizard/public/application/components/top_nav.tsx b/src/plugins/wizard/public/application/components/top_nav.tsx index 5afa39f7bafd..d63ceedb302f 100644 --- a/src/plugins/wizard/public/application/components/top_nav.tsx +++ b/src/plugins/wizard/public/application/components/top_nav.tsx @@ -10,7 +10,7 @@ import { getTopNavconfig } from '../utils/get_top_nav_config'; import { WizardServices } from '../../types'; import './top_nav.scss'; -import { useTypedSelector } from '../utils/state_management'; +import { useIndexPattern } from '../utils/use'; export const TopNav = () => { const { services } = useOpenSearchDashboards(); @@ -22,7 +22,7 @@ export const TopNav = () => { } = services; const config = useMemo(() => getTopNavconfig(services), [services]); - const { indexPattern } = useTypedSelector((state) => state.dataSource); + const indexPattern = useIndexPattern(); return (
diff --git a/src/plugins/wizard/public/application/components/workspace.tsx b/src/plugins/wizard/public/application/components/workspace.tsx index 6715c7fdf7c5..7747eef57cba 100644 --- a/src/plugins/wizard/public/application/components/workspace.tsx +++ b/src/plugins/wizard/public/application/components/workspace.tsx @@ -14,16 +14,34 @@ import { EuiPanel, EuiPopover, } from '@elastic/eui'; -import React, { FC, useState, useMemo } from 'react'; +import React, { FC, useState, useMemo, useEffect } from 'react'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { WizardServices } from '../../types'; -import { useTypedDispatch } from '../utils/state_management'; +import { useTypedDispatch, useTypedSelector } from '../utils/state_management'; import { setActiveVisualization } from '../utils/state_management/visualization_slice'; import { useVisualizationType } from '../utils/use'; import './workspace.scss'; export const Workspace: FC = ({ children }) => { + const { + services: { + expressions: { ReactExpressionRenderer }, + }, + } = useOpenSearchDashboards(); + const { toExpression } = useVisualizationType(); + const [expression, setExpression] = useState(); + const rootState = useTypedSelector((state) => state); + + useEffect(() => { + async function loadExpression() { + const exp = await toExpression(rootState); + setExpression(exp); + } + + loadExpression(); + }, [rootState, toExpression]); + return (
@@ -32,8 +50,8 @@ export const Workspace: FC = ({ children }) => { - {children ? ( - children + {expression ? ( + ) : ( state.config.activeItem); - const configItemState = useTypedSelector((state) => state.config.items[activeItem?.id || '']); - - const hydratedItems: MainItemContribution[] = [...(items?.[DATA_TAB_ID] ?? []), ...DEFAULT_ITEMS]; - - const mainPanel = useMemo(() => mapItemToPanelComponents(hydratedItems), [hydratedItems]); - const secondaryPanel = useMemo(() => { - if (!activeItem || !configItemState || typeof configItemState === 'string') return; - - // Generate each secondary panel base on active item type - if (activeItem.type === ITEM_TYPES.DROPBOX) { - const activeDropboxContribution = hydratedItems.find( - (item: MainItemContribution) => - item.type === ITEM_TYPES.DROPBOX && item?.id === activeItem?.id - ) as DropboxContribution | undefined; - - if (!activeDropboxContribution) return null; - - let itemsToRender: SecondaryItemContribution[] = [ - getTitleContribution(activeDropboxContribution.label), - getFieldSelectorContribution(), - ]; - - const dropboxFieldInstance = configItemState.instances.find( - ({ id }) => id === activeItem.instanceId - ); - if (dropboxFieldInstance && dropboxFieldInstance.properties.fieldName) { - itemsToRender = [...itemsToRender, ...activeDropboxContribution.items]; - } - - return mapItemToPanelComponents(itemsToRender, true); - } - }, [activeItem, configItemState, hydratedItems]); + const vizType = useVisualizationType(); + const activeAgg = useTypedSelector((state) => state.visualization.activeVisualization?.activeAgg); + const schemas = vizType.ui.containerConfig.data.schemas; + + // TODO: Will cleanup when add and edit field support is re introduced + // const activeItem = useTypedSelector((state) => state.config.activeItem); + // const configItemState = useTypedSelector((state) => state.config.items[activeItem?.id || '']); + + // const hydratedItems: MainItemContribution[] = useMemo( + // () => [...(items?.[DATA_TAB_ID] ?? []), ...DEFAULT_ITEMS], + // [items] + // ); + + // const mainPanel = useMemo(() => mapItemToPanelComponents(hydratedItems), [hydratedItems]); + // const secondaryPanel = useMemo(() => { + // if (!activeItem || !configItemState || typeof configItemState === 'string') return; + + // // Generate each secondary panel base on active item type + // if (activeItem.type === ITEM_TYPES.DROPBOX) { + // const activeDropboxContribution = hydratedItems.find( + // (item: MainItemContribution) => + // item.type === ITEM_TYPES.DROPBOX && item?.id === activeItem?.id + // ) as DropboxContribution | undefined; + + // if (!activeDropboxContribution) return null; + + // let itemsToRender: SecondaryItemContribution[] = [ + // getTitleContribution(activeDropboxContribution.label), + // getFieldSelectorContribution(), + // ]; + + // const dropboxFieldInstance = configItemState.instances.find( + // ({ id }) => id === activeItem.instanceId + // ); + // if (dropboxFieldInstance && dropboxFieldInstance.properties.fieldName) { + // itemsToRender = [...itemsToRender, ...activeDropboxContribution.items]; + // } + + // return mapItemToPanelComponents(itemsToRender, true); + // } + // }, [activeItem, configItemState, hydratedItems]); + + if (!schemas) return null; + + const mainPanel = mapSchemaToAggPanel(schemas); return ( - +
{mainPanel}
-
{secondaryPanel}
+
); } @@ -76,16 +89,16 @@ function getTitleContribution(title?: string): TitleItemContribution { }; } -function getFieldSelectorContribution(): SelectContribution { - return { - type: ItemTypes.SELECT, - id: INDEX_FIELD_KEY, - label: 'Select a Field', - options: (state) => { - return state.dataSource.visualizableFields.map((field) => ({ - value: field.name, - inputDisplay: field.displayName, - })); - }, - }; -} +// function getFieldSelectorContribution(): SelectContribution { +// return { +// type: ItemTypes.SELECT, +// id: INDEX_FIELD_KEY, +// label: 'Select a Field', +// options: (state) => { +// return state.dataSource.visualizableFields.map((field) => ({ +// value: field.name, +// inputDisplay: field.displayName, +// })); +// }, +// }; +// } diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/field_search.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/field_search.tsx index d086cfc1f362..772e308bbc9b 100644 --- a/src/plugins/wizard/public/application/contributions/containers/data_tab/field_search.tsx +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/field_search.tsx @@ -6,7 +6,7 @@ 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 { setSearchField } from '../../../utils/state_management/visualization_slice'; import { useTypedDispatch } from '../../../utils/state_management'; export interface Props { diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector.tsx index 9d1771fde2b5..70dac772a190 100644 --- a/src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector.tsx +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/field_selector.tsx @@ -4,7 +4,7 @@ */ import React, { useCallback, useState, useEffect } from 'react'; -import { EuiFlexItem, EuiAccordion, EuiSpacer, EuiNotificationBadge, EuiTitle } from '@elastic/eui'; +import { EuiFlexItem, EuiAccordion, EuiNotificationBadge, EuiTitle } from '@elastic/eui'; import { FieldSearch } from './field_search'; import { @@ -16,6 +16,7 @@ import { FieldSelectorField } from './field_selector_field'; import './field_selector.scss'; import { useTypedSelector } from '../../../utils/state_management'; +import { useIndexPattern } from '../../../utils/use'; interface IFieldCategories { categorical: IndexPatternField[]; @@ -30,19 +31,32 @@ const META_FIELDS: string[] = [ OPENSEARCH_FIELD_TYPES._TYPE, ]; +const ALLOWED_FIELDS: string[] = [OSD_FIELD_TYPES.STRING, OSD_FIELD_TYPES.NUMBER]; + export const FieldSelector = () => { - const indexFields = useTypedSelector((state) => state.dataSource.visualizableFields); - const [filteredFields, setFilteredFields] = useState(indexFields); - const fieldSearchValue = useTypedSelector((state) => state.dataSource.searchField); + const indexPattern = useIndexPattern(); + const fieldSearchValue = useTypedSelector((state) => state.visualization.searchField); + const [filteredFields, setFilteredFields] = useState([]); + + // TODO: Temporary validate function + // Need to identify how to get fieldCounts to use the standard filter and group functions + const isVisualizable = useCallback((field: IndexPatternField): boolean => { + const isAggregatable = field.aggregatable === true; + const isNotScripted = !field.scripted; + const isAllowed = ALLOWED_FIELDS.includes(field.type); + + return isAggregatable && isNotScripted && isAllowed; + }, []); useEffect(() => { - const filteredSubset = indexFields.filter((field) => - field.displayName.includes(fieldSearchValue) - ); + const indexFields = indexPattern?.fields ?? []; + const filteredSubset = indexFields + .filter(isVisualizable) + .filter((field) => field.displayName.includes(fieldSearchValue)); setFilteredFields(filteredSubset); return; - }, [indexFields, fieldSearchValue]); + }, [fieldSearchValue, indexPattern?.fields, isVisualizable]); const fields = filteredFields?.reduce( (fieldGroups, currentField) => { diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.scss b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.scss index b81ab2a07b33..dd2ab9c9d980 100644 --- a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.scss +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.scss @@ -15,13 +15,15 @@ display: grid; grid-gap: $euiSizeXS; padding: $euiSizeS; - background-color: #E9EDF3; + background-color: #e9edf3; border-radius: $euiBorderRadius; } &__field { display: grid; - grid-template-columns: auto 1fr auto; + grid-template-columns: 1fr auto; + + // grid-template-columns: auto 1fr auto; grid-gap: $euiSizeS; padding: $euiSizeS $euiSizeM; align-items: center; @@ -41,15 +43,14 @@ grid-template-columns: 1fr auto; &.validField { - background-color: #A8D9E7; - border-color: #A8D9E7; + background-color: #a8d9e7; + border-color: #a8d9e7; &.canDrop { - background-color: rgba(0, 161, 201, 0.3); - border-color: #006BB4; + background-color: rgba(0, 161, 201, 30%); + border-color: #006bb4; border-style: dashed; } } - } -} \ No newline at end of file +} diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.tsx index a9acf0c9ad71..8639340c3a49 100644 --- a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.tsx +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/dropbox.tsx @@ -12,13 +12,15 @@ import { EuiPanel, EuiText, euiDragDropReorder, + DropResult, } from '@elastic/eui'; import React, { useCallback } from 'react'; import { FieldIcon } from '../../../../../../../opensearch_dashboards_react/public'; import { IDropAttributes, IDropState } from '../../../../utils/drag_drop'; import './dropbox.scss'; -import { DropboxContribution, DropboxDisplay } from './types'; +import { DropboxDisplay } from './types'; import { useDropbox } from './use'; +import { UseDropboxProps } from './use/use_dropbox'; interface DropboxProps extends IDropState { id: string; @@ -28,7 +30,13 @@ interface DropboxProps extends IDropState { onAddField: () => void; onEditField: (id: string) => void; onDeleteField: (id: string) => void; - onReorderField: (reorderedIds: string[]) => void; + onReorderField: ({ + sourceAggId, + destinationAggId, + }: { + sourceAggId: string; + destinationAggId: string; + }) => void; dropProps: IDropAttributes; } @@ -46,13 +54,13 @@ const DropboxComponent = ({ dropProps, }: DropboxProps) => { const handleDragEnd = useCallback( - ({ source, destination }) => { - if (!source || !destination) return; + ({ source, destination }: DropResult) => { + if (!destination) return; - const instanceIds = fields.map(({ id }) => id); - const reorderedIds = euiDragDropReorder(instanceIds, source.index, destination.index); - - onReorderField(reorderedIds); + onReorderField({ + sourceAggId: fields[source.index].id, + destinationAggId: fields[destination.index].id, + }); }, [fields, onReorderField] ); @@ -65,7 +73,8 @@ const DropboxComponent = ({ {fields.map(({ id, label, icon }, index) => ( - + {/* TODO: Verify if field icon makes sense here */} + {/* */} onEditField(id)}> {label} @@ -104,7 +113,7 @@ const DropboxComponent = ({ ); }; -const Dropbox = React.memo((dropBox: DropboxContribution) => { +const Dropbox = React.memo((dropBox: UseDropboxProps) => { const props = useDropbox(dropBox); return ; diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/title.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/title.tsx index 84720ae6ff1c..1f48db369669 100644 --- a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/title.tsx +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/title.tsx @@ -11,46 +11,27 @@ import { EuiSpacer, EuiTitle, } from '@elastic/eui'; -import { TitleItemContribution } from './types'; -import { useTypedDispatch } from '../../../../utils/state_management'; -import { setActiveItem } from '../../../../utils/state_management/config_slice'; - export interface TitleProps { title: string; - icon?: React.ReactNode; - showDivider?: boolean; -} - -export const TitleComponent = ({ title, icon, showDivider = false }: TitleProps) => ( - <> -
- - {icon && {icon}} - - -

{title}

-
-
-
-
- {showDivider ? : } - -); - -interface TitleContributionProps extends TitleItemContribution { isSecondary?: boolean; + closeMenu?: () => void; } -export const Title = ({ title, isSecondary }: TitleContributionProps) => { - const dispatch = useTypedDispatch(); - +export const Title = ({ title, isSecondary, closeMenu }: TitleProps) => { + const icon = isSecondary && ; return ( - dispatch(setActiveItem(null))} /> - } - showDivider={isSecondary} - /> + <> +
+ + {icon && {icon}} + + +

{title}

+
+
+
+
+ {isSecondary ? : } + ); }; diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_dropbox.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_dropbox.tsx index 250825f60d10..4ef732ad84bd 100644 --- a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_dropbox.tsx +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_dropbox.tsx @@ -4,15 +4,11 @@ */ import { useCallback, useMemo } from 'react'; -import { IndexPatternField } from 'src/plugins/data/common'; +import { cloneDeep } from 'lodash'; +import { CreateAggConfigParams, IndexPatternField } from 'src/plugins/data/common'; +import { Schema } from '../../../../../../../../vis_default_editor/public'; import { FieldDragDataType } from '../../../../../utils/drag_drop/types'; import { useTypedDispatch, useTypedSelector } from '../../../../../utils/state_management'; -import { - addInstance, - reorderInstances, - setActiveItem, - updateInstance, -} from '../../../../../utils/state_management/config_slice'; import { DropboxContribution, DropboxState, @@ -22,72 +18,86 @@ import { } from '../types'; import { DropboxProps } from '../dropbox'; import { useDrop } from '../../../../../utils/drag_drop'; - -type DropboxInstanceState = DropboxState['instances'][number]; +import { + createAggConfigParams, + reorderAggConfigParams, + updateAggConfigParams, +} from '../../../../../utils/state_management/visualization_slice'; +import { useIndexPattern } from '../../../../../../application/utils/use/use_index_pattern'; +import { useOpenSearchDashboards } from '../../../../../../../../opensearch_dashboards_react/public'; +import { WizardServices } from '../../../../../../types'; export const INITIAL_STATE: DropboxState = { instances: [], }; -export const useDropbox = (dropboxContribution: DropboxContribution): DropboxProps => { - const { id: dropboxId, label, limit, display, onDrop, isDroppable } = dropboxContribution; +export interface UseDropboxProps extends Pick { + schema: Schema; +} + +export const useDropbox = (props: UseDropboxProps): DropboxProps => { + const { id: dropboxId, label, schema } = props; const dispatch = useTypedDispatch(); - const { items, availableFields } = useTypedSelector((state) => ({ - items: state.config.items, - availableFields: state.dataSource.visualizableFields, - })); - const configItemState = items[dropboxId]; - const dropboxState = - !configItemState || typeof configItemState === 'string' ? INITIAL_STATE : configItemState; - const filterPatrialInstances = useCallback( - ({ properties }: DropboxInstanceState) => !!properties.fieldName, - [] + const indexPattern = useIndexPattern(); + const { + services: { + data: { + search: { aggs: aggService }, + }, + }, + } = useOpenSearchDashboards(); + const aggConfigParams = useTypedSelector( + (state) => state.visualization.activeVisualization?.aggConfigParams ); - const mapInstanceToFieldDisplay = useCallback( - ({ id, properties }: DropboxInstanceState): DropboxDisplay => { - const indexPatternField = availableFields.find(({ name }) => name === properties.fieldName); - if (!indexPatternField) throw new Error('Field to display missing in available fields'); + const aggConfigs = useMemo(() => { + return indexPattern && aggService.createAggConfigs(indexPattern, cloneDeep(aggConfigParams)); + }, [aggConfigParams, aggService, indexPattern]); - return getDisplayField(id, indexPatternField, properties, display); - }, - [availableFields, display] - ); + const aggs = aggConfigs?.aggs ?? []; + + const dropboxAggs = aggs.filter((agg) => agg.schema === schema.name); const displayFields: DropboxDisplay[] = useMemo( - () => dropboxState.instances.filter(filterPatrialInstances).map(mapInstanceToFieldDisplay), - [dropboxState.instances, filterPatrialInstances, mapInstanceToFieldDisplay] + () => + dropboxAggs?.map( + (agg): DropboxDisplay => ({ + id: agg.id, + icon: 'number', // TODO: Check if we still need an icon here + label: agg.makeLabel(), + }) + ) || [], + [dropboxAggs] ); // Event handlers for each dropbox action type const onAddField = useCallback(() => { - dispatch(addInstance(dropboxId, ITEM_TYPES.DROPBOX)); - }, [dispatch, dropboxId]); + const agg = aggConfigs?.createAggConfig( + { + type: (schema.defaults as any).aggType, + schema: schema.name, + }, + { + addToAggConfigs: false, + } + ); - const onEditField = useCallback( - (instanceId) => { - dispatch( - setActiveItem({ - id: dropboxId, - type: ITEM_TYPES.DROPBOX, - instanceId, - }) - ); - }, - [dispatch, dropboxId] - ); + if (agg) { + dispatch(createAggConfigParams(agg.serialize())); + } + }, [aggConfigs, dispatch, schema.defaults, schema.name]); + + const onEditField = useCallback((instanceId) => {}, []); const onDeleteField = useCallback( - (instanceId) => { - dispatch( - updateInstance({ - id: dropboxId, - instanceId, - instanceState: null, - }) - ); + (aggId: string) => { + const newAggs = aggConfigs?.aggs.filter((agg) => agg.id !== aggId); + + if (newAggs) { + dispatch(updateAggConfigParams(newAggs.map((agg) => agg.serialize()))); + } }, - [dispatch, dropboxId] + [aggConfigs?.aggs, dispatch] ); const onDropField = useCallback( @@ -95,32 +105,32 @@ export const useDropbox = (dropboxContribution: DropboxContribution): DropboxPro if (!data) return; const { name: fieldName } = data; - const indexField = getIndexPatternField(fieldName, availableFields); - - if (!indexField) return; - - if (isDroppable && !isDroppable(indexField)) return; - const newState: DropboxFieldProps = { - ...onDrop?.(indexField), - fieldName, - }; + aggConfigs?.createAggConfig({ + type: (schema.defaults as any).aggType, + schema: schema.name, + params: { + field: fieldName, + }, + }); - dispatch(addInstance(dropboxId, ITEM_TYPES.DROPBOX, false, newState)); + if (aggConfigs) { + dispatch(updateAggConfigParams(aggConfigs.aggs.map((agg) => agg.serialize()))); + } }, - [availableFields, isDroppable, onDrop, dispatch, dropboxId] + [aggConfigs, dispatch, schema.defaults, schema.name] ); const onReorderField = useCallback( - (reorderedInstanceIds: string[]) => { + ({ sourceAggId, destinationAggId }) => { dispatch( - reorderInstances({ - id: dropboxId, - reorderedInstanceIds, + reorderAggConfigParams({ + sourceId: sourceAggId, + destinationId: destinationAggId, }) ); }, - [dispatch, dropboxId] + [dispatch] ); const [dropProps, { isValidDropTarget, dragData, ...dropState }] = useDrop( @@ -131,17 +141,19 @@ export const useDropbox = (dropboxContribution: DropboxContribution): DropboxPro const isValidDropField = useMemo(() => { if (!dragData) return false; - const indexField = getIndexPatternField(dragData.name, availableFields); + const indexField = getIndexPatternField(dragData.name, indexPattern?.fields ?? []); if (!indexField) return false; - return isValidDropTarget && (isDroppable?.(indexField) ?? true); - }, [availableFields, dragData, isDroppable, isValidDropTarget]); + return isValidDropTarget; + // TODO: Validate if the field is droppable from schema ref : src/plugins/vis_default_editor/public/components/agg_params.tsx + // return isValidDropTarget && (isDroppable?.(indexField) ?? true); + }, [dragData, indexPattern?.fields, isValidDropTarget]); return { id: dropboxId, label, - limit, + limit: schema.max, fields: displayFields, onAddField, onEditField, @@ -152,6 +164,124 @@ export const useDropbox = (dropboxContribution: DropboxContribution): DropboxPro isValidDropTarget: isValidDropField, dropProps, }; + + // TODO: Will cleanup once add and edit field support is reintroduced + // const configItemState = items[dropboxId]; + // const dropboxState = + // !configItemState || typeof configItemState === 'string' ? INITIAL_STATE : configItemState; + // const filterPatrialInstances = useCallback( + // ({ properties }: DropboxInstanceState) => !!properties.fieldName, + // [] + // ); + // const mapInstanceToFieldDisplay = useCallback( + // ({ id, properties }: DropboxInstanceState): DropboxDisplay => { + // const indexPatternField = availableFields.find(({ name }) => name === properties.fieldName); + + // if (!indexPatternField) throw new Error('Field to display missing in available fields'); + + // return getDisplayField(id, indexPatternField, properties, display); + // }, + // [availableFields, display] + // ); + + // const displayFields: DropboxDisplay[] = useMemo( + // () => dropboxState.instances.filter(filterPatrialInstances).map(mapInstanceToFieldDisplay), + // [dropboxState.instances, filterPatrialInstances, mapInstanceToFieldDisplay] + // ); + + // Event handlers for each dropbox action type + // const onAddField = useCallback(() => { + // dispatch(addInstance(dropboxId, ITEM_TYPES.DROPBOX)); + // }, [dispatch, dropboxId]); + + // const onEditField = useCallback( + // (instanceId) => { + // dispatch( + // setActiveItem({ + // id: dropboxId, + // type: ITEM_TYPES.DROPBOX, + // instanceId, + // }) + // ); + // }, + // [dispatch, dropboxId] + // ); + + // const onDeleteField = useCallback( + // (instanceId) => { + // dispatch( + // updateInstance({ + // id: dropboxId, + // instanceId, + // instanceState: null, + // }) + // ); + // }, + // [dispatch, dropboxId] + // ); + + // const onDropField = useCallback( + // (data: FieldDragDataType['value']) => { + // if (!data) return; + + // const { name: fieldName } = data; + // const indexField = getIndexPatternField(fieldName, availableFields); + + // if (!indexField) return; + + // if (isDroppable && !isDroppable(indexField)) return; + + // const newState: DropboxFieldProps = { + // ...onDrop?.(indexField), + // fieldName, + // }; + + // dispatch(addInstance(dropboxId, ITEM_TYPES.DROPBOX, false, newState)); + // }, + // [availableFields, isDroppable, onDrop, dispatch, dropboxId] + // ); + + // const onReorderField = useCallback( + // (reorderedInstanceIds: string[]) => { + // dispatch( + // reorderInstances({ + // id: dropboxId, + // reorderedInstanceIds, + // }) + // ); + // }, + // [dispatch, dropboxId] + // ); + + // const [dropProps, { isValidDropTarget, dragData, ...dropState }] = useDrop( + // 'field-data', + // onDropField + // ); + + // const isValidDropField = useMemo(() => { + // if (!dragData) return false; + + // const indexField = getIndexPatternField(dragData.name, availableFields); + + // if (!indexField) return false; + + // return isValidDropTarget && (isDroppable?.(indexField) ?? true); + // }, [availableFields, dragData, isDroppable, isValidDropTarget]); + + // return { + // id: dropboxId, + // label, + // limit, + // fields: displayFields, + // onAddField, + // onEditField, + // onDeleteField, + // onReorderField, + // ...dropState, + // dragData, + // isValidDropTarget: isValidDropField, + // dropProps, + // }; }; const getDisplayField = ( diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_form_field.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_form_field.tsx index 8f650afb62f0..03d22dd0e7ff 100644 --- a/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_form_field.tsx +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/items/use/use_form_field.tsx @@ -5,11 +5,11 @@ import produce from 'immer'; import { useCallback, useMemo } from 'react'; -import { - ConfigState, - updateConfigItemState, - updateInstance, -} from '../../../../../utils/state_management/config_slice'; +// import { +// ConfigState, +// updateConfigItemState, +// updateInstance, +// } from '../../../../../utils/state_management/config_slice'; import { useTypedSelector, useTypedDispatch } from '../../../../../utils/state_management'; import { FieldContributions } from '../types'; @@ -31,14 +31,16 @@ export const useFormField = (id: string, onChange: FieldContributions['onChange' (newValue: string) => { onChange?.(newValue); + // TODO: Will cleanup once add and edit field support is reintroduced + // is a MainPanel field value if (!activeItem) { - dispatch( - updateConfigItemState({ - id, - itemState: newValue, - }) - ); + // dispatch( + // // updateConfigItemState({ + // // id, + // // itemState: newValue, + // // }) + // ); return; } @@ -46,15 +48,15 @@ export const useFormField = (id: string, onChange: FieldContributions['onChange' draftState[id] = newValue; }); - dispatch( - updateInstance({ - id: activeItem.id, - instanceId: activeItem.instanceId, - instanceState: newInstanceState, - }) - ); + // dispatch( + // updateInstance({ + // id: activeItem.id, + // instanceId: activeItem.instanceId, + // instanceState: newInstanceState, + // }) + // ); }, - [activeItem, dispatch, id, instanceState, onChange] + [activeItem, id, instanceState, onChange] ); return { diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/secondary_panel.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/secondary_panel.tsx new file mode 100644 index 000000000000..de808c4c5fdc --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/secondary_panel.tsx @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { useTypedSelector } from '../../../utils/state_management'; +import { Title } from './items'; + +export function SecondaryPanel() { + const activeAgg = useTypedSelector((state) => state.visualization.activeVisualization?.activeAgg); + + return ( +
+ + <div>{JSON.stringify(activeAgg)}</div> + </div> + ); +} diff --git a/src/plugins/wizard/public/application/contributions/containers/data_tab/utils/schema_to_dropbox.tsx b/src/plugins/wizard/public/application/contributions/containers/data_tab/utils/schema_to_dropbox.tsx new file mode 100644 index 000000000000..b14a8bd13d85 --- /dev/null +++ b/src/plugins/wizard/public/application/contributions/containers/data_tab/utils/schema_to_dropbox.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Schemas } from '../../../../../../../vis_default_editor/public'; +import { Title, Dropbox } from '../items'; + +export const mapSchemaToAggPanel = (schemas: Schemas) => { + const panelComponents = schemas.all.map((schema) => { + return <Dropbox key={schema.name} id={schema.name} label={schema.title} schema={schema} />; + }); + + return ( + <> + <Title title="Configuration" /> + <div className="wizConfig__content">{panelComponents}</div> + </> + ); +}; 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 deleted file mode 100644 index dc0c2ec1cb0c..000000000000 --- a/src/plugins/wizard/public/application/utils/state_management/config_slice.ts +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { htmlIdGenerator } from '@elastic/eui'; -import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { WizardServices } from '../../../types'; -import { - ConfigItemState, - DATA_TAB_ID, - InstanceState, - MainItemContribution, -} from '../../contributions'; - -// TODO: Move this into contributions and register the slice from there for better code splitting -// TODO: Reorganize slice for better readability -export interface ActiveItem { - id: string; - type: MainItemContribution['type']; - instanceId: string; -} - -export interface ConfigState { - items: { - [id: string]: ConfigItemState; - }; - activeItem: ActiveItem | null; -} - -const initialState: ConfigState = { - items: {}, - activeItem: null, -}; - -export const getPreloadedState = async ({ types }: WizardServices): Promise<ConfigState> => { - const preloadedState = { ...initialState }; - - const defaultVisualizationType = types.all()[0]; - - if (defaultVisualizationType) { - preloadedState.items = defaultVisualizationType.contributions.items?.[DATA_TAB_ID].filter( - ({ id }) => !!id - ).reduce((acc, { id, type }) => ({ ...acc, [id]: null }), {}); - } - - return preloadedState; -}; - -interface UpdateConfigPayload { - id: string; - itemState: any; -} - -interface AddInstancePayload extends ActiveItem { - properties?: any; - setActive: boolean; -} - -interface UpdateInstancePayload { - id: string; - instanceId: string; - instanceState: any; -} - -interface ReorderInstancePayload { - id: string; - reorderedInstanceIds: string[]; -} - -export const slice = createSlice({ - name: 'configuration', - initialState, - reducers: { - updateConfigItemState: (state, action: PayloadAction<UpdateConfigPayload>) => { - const { id, itemState } = action.payload; - - if (state.items.hasOwnProperty(id)) { - state.items[id] = itemState; - } - }, - setActiveItem: (state, { payload }: PayloadAction<ActiveItem | null>) => { - // On closing secondary menu - if (!payload) { - state.activeItem = null; - return; - } - - state.activeItem = payload; - }, - addInstance: { - reducer: (state, action: PayloadAction<AddInstancePayload>) => { - const { id, instanceId, properties, setActive } = action.payload; - - if (!state.items.hasOwnProperty(id)) return; - - if (!state.items[id]) { - state.items[id] = { - instances: [], - }; - } - - (state.items[id] as InstanceState<unknown>).instances.push({ - id: instanceId, - properties: properties ?? {}, - }); - - if (setActive) { - state.activeItem = action.payload; - } - }, - prepare: ( - id: string, - type: MainItemContribution['type'], - setActive: boolean = true, - properties?: any - ) => { - const instanceId = htmlIdGenerator()(); - - return { - payload: { - id, - type, - instanceId, - properties, - setActive, - }, - }; - }, - }, - updateInstance: (state, action: PayloadAction<UpdateInstancePayload>) => { - const { id: parentItemId, instanceId, instanceState } = action.payload; - if (!state.items.hasOwnProperty(parentItemId)) return; - - // Typescript complains if we use state.items[parentItemId] directly since it cannot resolve the type correctly - const configItem = state.items[parentItemId]; - if (!configItem || typeof configItem === 'string') return; - - const instanceIndex = configItem.instances.findIndex(({ id }) => id === instanceId); - - if (instanceIndex < 0) return; - - if (instanceState === null) { - configItem.instances.splice(instanceIndex, 1); - return; - } - - configItem.instances[instanceIndex].properties = instanceState; - }, - reorderInstances: (state, action: PayloadAction<ReorderInstancePayload>) => { - const { id: parentItemId, reorderedInstanceIds } = action.payload; - - if (!state.items.hasOwnProperty(parentItemId)) return; - - // Typescript complains if we use state.items[parentItemId] directly since it cannot resolve the type correctly - const configItem = state.items[parentItemId]; - if (!configItem || typeof configItem === 'string') return; - - const orderDict: { [id: string]: number } = {}; - reorderedInstanceIds.forEach((instanceId, index) => { - orderDict[instanceId] = index; - }); - - configItem.instances.sort((a, b) => orderDict[a.id] - orderDict[b.id]); - }, - }, -}); - -export const { reducer } = slice; -export const { - updateConfigItemState, - setActiveItem, - addInstance, - updateInstance, - reorderInstances, -} = 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 deleted file mode 100644 index d51d463d68ee..000000000000 --- a/src/plugins/wizard/public/application/utils/state_management/datasource_slice.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * 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<DataSourceState> => { - 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<IndexPattern>) => { - state.indexPattern = action.payload; - state.visualizableFields = action.payload.fields.filter(isVisualizable); - }, - setSearchField: (state, action: PayloadAction<string>) => { - 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 index 823c34528c90..607fe05b1623 100644 --- a/src/plugins/wizard/public/application/utils/state_management/hooks.ts +++ b/src/plugins/wizard/public/application/utils/state_management/hooks.ts @@ -6,6 +6,6 @@ import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; import type { RootState, AppDispatch } from './store'; -// Use throughout your app instead of plain `useDispatch` and `useSelector` +// Use throughout the app instead of plain `useDispatch` and `useSelector` export const useTypedDispatch = () => useDispatch<AppDispatch>(); export const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector; diff --git a/src/plugins/wizard/public/application/utils/state_management/preload.ts b/src/plugins/wizard/public/application/utils/state_management/preload.ts index 21ebd13ff82f..d9cefa21a064 100644 --- a/src/plugins/wizard/public/application/utils/state_management/preload.ts +++ b/src/plugins/wizard/public/application/utils/state_management/preload.ts @@ -5,21 +5,18 @@ import { PreloadedState } from '@reduxjs/toolkit'; import { WizardServices } from '../../..'; -import { getPreloadedState as getPreloadedDatasourceState } from './datasource_slice'; +import { getPreloadedState as getPreloadedStyleState } from './style_slice'; import { getPreloadedState as getPreloadedVisualizationState } from './visualization_slice'; -import { getPreloadedState as getPreloadedConfigState } from './config_slice'; import { RootState } from './store'; export const getPreloadedState = async ( services: WizardServices ): Promise<PreloadedState<RootState>> => { - const dataSourceState = await getPreloadedDatasourceState(services); + const styleState = await getPreloadedStyleState(services); const visualizationState = await getPreloadedVisualizationState(services); - const configState = await getPreloadedConfigState(services); return { - dataSource: dataSourceState, + style: styleState, visualization: visualizationState, - config: configState, }; }; diff --git a/src/plugins/wizard/public/application/utils/state_management/store.ts b/src/plugins/wizard/public/application/utils/state_management/store.ts index 4fa56c1a7c97..29af0e9d73b5 100644 --- a/src/plugins/wizard/public/application/utils/state_management/store.ts +++ b/src/plugins/wizard/public/application/utils/state_management/store.ts @@ -4,15 +4,13 @@ */ import { combineReducers, configureStore, PreloadedState } from '@reduxjs/toolkit'; -import { reducer as dataSourceReducer } from './datasource_slice'; -import { reducer as configReducer } from './config_slice'; +import { reducer as styleReducer } from './style_slice'; import { reducer as visualizationReducer } from './visualization_slice'; import { WizardServices } from '../../..'; import { getPreloadedState } from './preload'; const rootReducer = combineReducers({ - dataSource: dataSourceReducer, - config: configReducer, + style: styleReducer, visualization: visualizationReducer, }); diff --git a/src/plugins/wizard/public/application/utils/state_management/style_slice.ts b/src/plugins/wizard/public/application/utils/state_management/style_slice.ts new file mode 100644 index 000000000000..98a425184d45 --- /dev/null +++ b/src/plugins/wizard/public/application/utils/state_management/style_slice.ts @@ -0,0 +1,47 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { WizardServices } from '../../../types'; + +type StyleState<T = any> = T; + +const initialState = {} as StyleState; + +export const getPreloadedState = async ({ types, data }: WizardServices): Promise<StyleState> => { + let preloadedState = initialState; + + const defaultVisualization = types.all()[0]; + const defaultState = defaultVisualization.ui.containerConfig.style.defaults; + if (defaultState) { + preloadedState = defaultState; + } + + return preloadedState; +}; + +export const styleSlice = createSlice({ + name: 'style', + initialState, + reducers: { + setState<T>(state: T, action: PayloadAction<StyleState<T>>) { + return action.payload; + }, + updateState<T>(state: T, action: PayloadAction<Partial<StyleState<T>>>) { + state = { + ...state, + ...action.payload, + }; + }, + }, +}); + +// Exposing the state functions as generics +export const setState = styleSlice.actions.setState as <T>(payload: T) => PayloadAction<T>; +export const updateState = styleSlice.actions.updateState as <T>( + payload: Partial<T> +) => PayloadAction<Partial<T>>; + +export const { reducer } = styleSlice; 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 index 692f9434c8de..b5a4b4e93326 100644 --- a/src/plugins/wizard/public/application/utils/state_management/visualization_slice.ts +++ b/src/plugins/wizard/public/application/utils/state_management/visualization_slice.ts @@ -4,22 +4,39 @@ */ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { CreateAggConfigParams } from 'src/plugins/data/common'; import { WizardServices } from '../../../types'; interface VisualizationState { - activeVisualization: string | null; + indexPattern?: string; + searchField: string; + activeVisualization?: { + name: string; + aggConfigParams: CreateAggConfigParams[]; + activeAgg?: CreateAggConfigParams; + }; } const initialState: VisualizationState = { - activeVisualization: null, + searchField: '', }; -export const getPreloadedState = async ({ types }: WizardServices): Promise<VisualizationState> => { +export const getPreloadedState = async ({ + types, + data, +}: WizardServices): Promise<VisualizationState> => { const preloadedState = { ...initialState }; const defaultVisualization = types.all()[0]; - if (defaultVisualization) { - preloadedState.activeVisualization = defaultVisualization.name; + const defaultIndexPattern = await data.indexPatterns.getDefault(); + const name = defaultVisualization.name; + if (name && defaultIndexPattern) { + preloadedState.activeVisualization = { + name, + aggConfigParams: [], + }; + + preloadedState.indexPattern = defaultIndexPattern.id; } return preloadedState; @@ -29,11 +46,61 @@ export const slice = createSlice({ name: 'visualization', initialState, reducers: { - setActiveVisualization: (state, action: PayloadAction<string>) => { + setActiveVisualization: ( + state, + action: PayloadAction<VisualizationState['activeVisualization']> + ) => { state.activeVisualization = action.payload; }, + setIndexPattern: (state, action: PayloadAction<string>) => { + state.indexPattern = action.payload; + state.activeVisualization!.aggConfigParams = []; + }, + setSearchField: (state, action: PayloadAction<string>) => { + state.searchField = action.payload; + }, + createAggConfigParams: (state, action: PayloadAction<CreateAggConfigParams>) => { + state.activeVisualization!.activeAgg = action.payload; + }, + saveAggConfigParams: (state, action: PayloadAction<CreateAggConfigParams>) => { + delete state.activeVisualization!.activeAgg; + + // TODO: Impliment reducer + }, + reorderAggConfigParams: ( + state, + action: PayloadAction<{ + sourceId: string; + destinationId: string; + }> + ) => { + const { sourceId, destinationId } = action.payload; + const aggParams = state.activeVisualization!.aggConfigParams; + const newAggs = [...aggParams]; + const destinationIndex = newAggs.findIndex((agg) => agg.id === destinationId); + newAggs.splice( + destinationIndex, + 0, + newAggs.splice( + aggParams.findIndex((agg) => agg.id === sourceId), + 1 + )[0] + ); + + state.activeVisualization!.aggConfigParams = newAggs; + }, + updateAggConfigParams: (state, action: PayloadAction<CreateAggConfigParams[]>) => { + state.activeVisualization!.aggConfigParams = action.payload; + }, }, }); export const { reducer } = slice; -export const { setActiveVisualization } = slice.actions; +export const { + setActiveVisualization, + setIndexPattern, + setSearchField, + createAggConfigParams, + updateAggConfigParams, + reorderAggConfigParams, +} = slice.actions; diff --git a/src/plugins/wizard/public/application/utils/use/index.ts b/src/plugins/wizard/public/application/utils/use/index.ts index d82ba978902d..2893ab0d11ff 100644 --- a/src/plugins/wizard/public/application/utils/use/index.ts +++ b/src/plugins/wizard/public/application/utils/use/index.ts @@ -4,3 +4,4 @@ */ export { useVisualizationType } from './use_visualization_type'; +export { useIndexPattern } from './use_index_pattern'; diff --git a/src/plugins/wizard/public/application/utils/use/use_index_pattern.tsx b/src/plugins/wizard/public/application/utils/use/use_index_pattern.tsx new file mode 100644 index 000000000000..e02f543d6c15 --- /dev/null +++ b/src/plugins/wizard/public/application/utils/use/use_index_pattern.tsx @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { useCallback, useEffect, useState } from 'react'; +import { IndexPattern } from '../../../../../data/public'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { WizardServices } from '../../../types'; +import { useTypedSelector } from '../state_management'; + +export const useIndexPattern = (): IndexPattern | undefined => { + const { indexPattern: indexId = '' } = useTypedSelector((state) => state.visualization); + const [indexPattern, setIndexPattern] = useState<IndexPattern>(); + const { + services: { + data: { indexPatterns }, + }, + } = useOpenSearchDashboards<WizardServices>(); + + const handleIndexUpdate = useCallback(async () => { + const currentIndex = await indexPatterns.get(indexId); + setIndexPattern(currentIndex); + }, [indexId, indexPatterns]); + + useEffect(() => { + handleIndexUpdate(); + }, [handleIndexUpdate]); + + return indexPattern; +}; diff --git a/src/plugins/wizard/public/application/utils/use/use_visualization_type.ts b/src/plugins/wizard/public/application/utils/use/use_visualization_type.ts index fb88c11b49da..002c83759b3c 100644 --- a/src/plugins/wizard/public/application/utils/use/use_visualization_type.ts +++ b/src/plugins/wizard/public/application/utils/use/use_visualization_type.ts @@ -14,10 +14,10 @@ export const useVisualizationType = (): VisualizationType => { services: { types }, } = useOpenSearchDashboards<WizardServices>(); - const visualizationType = types.get(activeVisualization || ''); + const visualizationType = types.get(activeVisualization?.name ?? ''); if (!visualizationType) { - throw new Error('Invalid visualization type ${activeVisualization}'); + throw new Error(`Invalid visualization type ${activeVisualization}`); } return visualizationType; diff --git a/src/plugins/wizard/public/plugin.ts b/src/plugins/wizard/public/plugin.ts index 5b309080a872..67b6dea6dc7d 100644 --- a/src/plugins/wizard/public/plugin.ts +++ b/src/plugins/wizard/public/plugin.ts @@ -21,6 +21,7 @@ import { import { PLUGIN_NAME } from '../common'; import { TypeService } from './services/type_service'; import { getPreloadedStore } from './application/utils/state_management'; +import { setAggService, setIndexPatterns } from './plugin_services'; export class WizardPlugin implements @@ -42,12 +43,25 @@ export class WizardPlugin 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 { data, savedObjects, navigation, expressions } = pluginsStart; + + // 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(); + // Register plugin services + setAggService(data.search.aggs); + setIndexPatterns(data.indexPatterns); + + // Register Default Visualizations const { registerDefaultTypes } = await import('./visualizations'); - registerDefaultTypes(typeService.setup()); + registerDefaultTypes(typeService.setup(), pluginsStart); const services: WizardServices = { ...coreStart, @@ -55,17 +69,12 @@ export class WizardPlugin data, savedObjectsPublic: savedObjects, navigation, + expressions, 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(); - + // Instantiate the store const store = await getPreloadedStore(services); // Render the application diff --git a/src/plugins/wizard/public/plugin_services.ts b/src/plugins/wizard/public/plugin_services.ts new file mode 100644 index 000000000000..67b562d6eca9 --- /dev/null +++ b/src/plugins/wizard/public/plugin_services.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createGetterSetter } from '../../opensearch_dashboards_utils/common'; +import { DataPublicPluginStart } from '../../data/public'; + +export const [getAggService, setAggService] = createGetterSetter< + DataPublicPluginStart['search']['aggs'] +>('data.search.aggs'); + +export const [getIndexPatterns, setIndexPatterns] = createGetterSetter< + DataPublicPluginStart['indexPatterns'] +>('data.indexPatterns'); diff --git a/src/plugins/wizard/public/services/type_service/types.ts b/src/plugins/wizard/public/services/type_service/types.ts index d722bf90dbfa..fae6cdf1c093 100644 --- a/src/plugins/wizard/public/services/type_service/types.ts +++ b/src/plugins/wizard/public/services/type_service/types.ts @@ -2,8 +2,10 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ - +import { ReactElement } from 'react'; import { IconType } from '@elastic/eui'; +import { RootState } from '../../application/utils/state_management'; +import { Schemas } from '../../../../vis_default_editor/public'; export enum ContributionTypes { CONTAINER = 'CONTAINER', @@ -25,17 +27,26 @@ type ContainerSchema = any; export type ContainerLocationContribution = { [K in ContainerLocations]: ContainerContribution[] }; -export interface VisualizationTypeOptions { +export interface DataTabConfig { + schemas: Schemas; +} + +export interface StyleTabConfig<T = any> { + defaults: T; + render: () => ReactElement; +} + +export interface VisualizationTypeOptions<T = any> { readonly name: string; readonly title: string; readonly description?: string; readonly icon: IconType; readonly stage?: 'beta' | 'production'; - readonly contributions: { - containers?: Partial<ContainerLocationContribution>; - items?: { - [containerId: string]: ContainerSchema[]; // schema that is used to render the container. Each container is responsible for deciding that for consistency + readonly ui: { + containerConfig: { + data: DataTabConfig; + style: StyleTabConfig<T>; }; }; - // pipeline: Expression; + readonly toExpression: (state: RootState) => Promise<string | undefined>; } diff --git a/src/plugins/wizard/public/services/type_service/visualization_type.test.tsx b/src/plugins/wizard/public/services/type_service/visualization_type.test.tsx index 830d598af754..6d362a229d23 100644 --- a/src/plugins/wizard/public/services/type_service/visualization_type.test.tsx +++ b/src/plugins/wizard/public/services/type_service/visualization_type.test.tsx @@ -5,8 +5,9 @@ import React from 'react'; import { VisualizationTypeOptions } from './types'; -import { VisualizationType, DEFAULT_CONTAINERS } from './visualization_type'; +import { VisualizationType } from './visualization_type'; +// TODO: Update service tests describe('VisualizationType', () => { const DEFAULT_VIZ_PROPS = { name: 'some-name', @@ -15,81 +16,81 @@ describe('VisualizationType', () => { contributions: {}, }; - const createVizType = (props?: Partial<VisualizationTypeOptions>): VisualizationTypeOptions => { - return { - ...DEFAULT_VIZ_PROPS, - ...props, - }; - }; + // const createVizType = (props?: Partial<VisualizationTypeOptions>): VisualizationTypeOptions => { + // return { + // ...DEFAULT_VIZ_PROPS, + // ...props, + // }; + // }; - test('should have default container contributions if none are provided', () => { - const viz = new VisualizationType(createVizType()); + // test('should have default container contributions if none are provided', () => { + // const viz = new VisualizationType(createVizType()); - expect(viz.contributions.containers).toEqual(DEFAULT_CONTAINERS); - }); + // expect(viz.contributions.containers).toEqual(DEFAULT_CONTAINERS); + // }); - test('should have replace default container contributions when provided', () => { - const defaultContainer = DEFAULT_CONTAINERS.sidePanel[0]; - const viz = new VisualizationType( - createVizType({ - contributions: { - containers: { - sidePanel: [ - { - id: defaultContainer.id, - name: 'Test', - Component: <div>Test</div>, - }, - ], - }, - }, - }) - ); + // test('should have replace default container contributions when provided', () => { + // const defaultContainer = DEFAULT_CONTAINERS.sidePanel[0]; + // const viz = new VisualizationType( + // createVizType({ + // contributions: { + // containers: { + // sidePanel: [ + // { + // id: defaultContainer.id, + // name: 'Test', + // Component: <div>Test</div>, + // }, + // ], + // }, + // }, + // }) + // ); - const container = viz.contributions.containers.sidePanel.find( - ({ id }) => id === defaultContainer.id - ); - expect(container).toMatchInlineSnapshot(` - Object { - "Component": <div> - Test - </div>, - "id": "data_tab", - "name": "Test", - } - `); - }); + // const container = viz.contributions.containers.sidePanel.find( + // ({ id }) => id === defaultContainer.id + // ); + // expect(container).toMatchInlineSnapshot(` + // Object { + // "Component": <div> + // Test + // </div>, + // "id": "data_tab", + // "name": "Test", + // } + // `); + // }); - test('should register new container if provided', () => { - const viz = new VisualizationType( - createVizType({ - contributions: { - containers: { - sidePanel: [ - { - id: 'test_id', - name: 'Test', - Component: <div>Test</div>, - }, - ], - }, - }, - }) - ); + // test('should register new container if provided', () => { + // const viz = new VisualizationType( + // createVizType({ + // contributions: { + // containers: { + // sidePanel: [ + // { + // id: 'test_id', + // name: 'Test', + // Component: <div>Test</div>, + // }, + // ], + // }, + // }, + // }) + // ); - const container = viz.contributions.containers.sidePanel.find(({ id }) => id === 'test_id'); - const containerNames = viz.contributions.containers.sidePanel.map(({ name }) => name); - const defaultContainerNames = DEFAULT_CONTAINERS.sidePanel.map(({ name }) => name); + // const container = viz.contributions.containers.sidePanel.find(({ id }) => id === 'test_id'); + // const containerNames = viz.contributions.containers.sidePanel.map(({ name }) => name); + // const defaultContainerNames = DEFAULT_CONTAINERS.sidePanel.map(({ name }) => name); - expect(containerNames).toEqual([...defaultContainerNames, 'Test']); - expect(container).toMatchInlineSnapshot(` - Object { - "Component": <div> - Test - </div>, - "id": "test_id", - "name": "Test", - } - `); - }); + // expect(containerNames).toEqual([...defaultContainerNames, 'Test']); + // expect(container).toMatchInlineSnapshot(` + // Object { + // "Component": <div> + // Test + // </div>, + // "id": "test_id", + // "name": "Test", + // } + // `); + // }); }); diff --git a/src/plugins/wizard/public/services/type_service/visualization_type.tsx b/src/plugins/wizard/public/services/type_service/visualization_type.tsx index 873931db7e89..2ae1d25a4d96 100644 --- a/src/plugins/wizard/public/services/type_service/visualization_type.tsx +++ b/src/plugins/wizard/public/services/type_service/visualization_type.tsx @@ -2,69 +2,18 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { DATA_TAB_ID, DataTab, STYLE_TAB_ID, StyleTab } from '../../application/contributions'; -import { - ContainerLocationContribution, - ContainerLocations, - VisualizationTypeOptions, -} from './types'; -import { mergeArrays } from './utils'; +import { VisualizationTypeOptions } from './types'; -export const DEFAULT_CONTAINERS: ContainerLocationContribution = { - sidePanel: [ - { - id: DATA_TAB_ID, - name: 'Data', - Component: <DataTab />, - }, - { - id: STYLE_TAB_ID, - name: 'Style', - Component: <StyleTab />, - }, - ], - toolbar: [], -}; +type IVisualizationType = VisualizationTypeOptions; -interface IVisualizationType extends Required<VisualizationTypeOptions> { - contributions: { - containers: ContainerLocationContribution; - }; -} export class VisualizationType implements IVisualizationType { public readonly name; public readonly title; public readonly description; public readonly icon; public readonly stage; - public readonly contributions; - - private processContributions(contributions: VisualizationTypeOptions['contributions']) { - const uiContainers: ContainerLocationContribution = { - sidePanel: [], - toolbar: [], - }; - const { containers, items } = contributions; - - // Validate and populate containers for each container location - Object.keys(uiContainers).forEach((location) => { - const typedLocation = location as ContainerLocations; - const vizContainers = containers?.[typedLocation]; - - const mergedContainers = mergeArrays( - DEFAULT_CONTAINERS[typedLocation], - vizContainers || [], - 'id' - ); - uiContainers[typedLocation] = mergedContainers; - }); - - return { - containers: uiContainers, - items, - }; - } + public readonly ui; + public readonly toExpression; constructor(options: VisualizationTypeOptions) { this.name = options.name; @@ -72,6 +21,7 @@ export class VisualizationType implements IVisualizationType { this.description = options.description ?? ''; this.icon = options.icon; this.stage = options.stage ?? 'production'; - this.contributions = this.processContributions(options.contributions); + this.ui = options.ui; + this.toExpression = options.toExpression; } } diff --git a/src/plugins/wizard/public/types.ts b/src/plugins/wizard/public/types.ts index 07b1e5141c61..14f13d56bdf3 100644 --- a/src/plugins/wizard/public/types.ts +++ b/src/plugins/wizard/public/types.ts @@ -8,6 +8,7 @@ import { AppMountParameters, CoreStart, ToastsStart } from 'opensearch-dashboard import { EmbeddableSetup } from 'src/plugins/embeddable/public'; import { DashboardStart } from 'src/plugins/dashboard/public'; import { VisualizationsSetup } from 'src/plugins/visualizations/public'; +import { ExpressionsStart } from 'src/plugins/expressions/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; import { DataPublicPluginStart } from '../../data/public'; import { TypeServiceSetup, TypeServiceStart } from './services/type_service'; @@ -23,6 +24,7 @@ export interface WizardPluginStartDependencies { data: DataPublicPluginStart; savedObjects: SavedObjectsStart; dashboard: DashboardStart; + expressions: ExpressionsStart; } export interface WizardServices extends CoreStart { @@ -32,4 +34,5 @@ export interface WizardServices extends CoreStart { navigation: NavigationPublicPluginStart; data: DataPublicPluginStart; types: TypeServiceStart; + expressions: ExpressionsStart; } diff --git a/src/plugins/wizard/public/visualizations/index.ts b/src/plugins/wizard/public/visualizations/index.ts index cf3151cf1196..b9574fd9a771 100644 --- a/src/plugins/wizard/public/visualizations/index.ts +++ b/src/plugins/wizard/public/visualizations/index.ts @@ -4,11 +4,16 @@ */ import type { TypeServiceSetup } from '../services/type_service'; +import { createMetricConfig } from './metric'; import { createBarChartConfig } from './bar_chart'; import { createPieChartConfig } from './pie_chart'; +import { WizardPluginStartDependencies } from '../types'; -export function registerDefaultTypes(typeServiceSetup: TypeServiceSetup) { - const visualizationTypes = [createBarChartConfig, createPieChartConfig]; +export function registerDefaultTypes( + typeServiceSetup: TypeServiceSetup, + pluginsStart: WizardPluginStartDependencies +) { + const visualizationTypes = [createMetricConfig]; visualizationTypes.forEach((createTypeConfig) => { typeServiceSetup.createVisualizationType(createTypeConfig()); diff --git a/src/plugins/wizard/public/visualizations/metric/components/metric_viz_options.tsx b/src/plugins/wizard/public/visualizations/metric/components/metric_viz_options.tsx new file mode 100644 index 000000000000..acfaab045800 --- /dev/null +++ b/src/plugins/wizard/public/visualizations/metric/components/metric_viz_options.tsx @@ -0,0 +1,149 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { FormattedMessage } from 'react-intl'; +import produce from 'immer'; +import { Draft } from 'immer'; +import { + ColorRanges, + RangeOption, + SetColorRangeValue, + SwitchOption, +} from '../../../../../charts/public'; +import { useTypedDispatch, useTypedSelector } from '../../../application/utils/state_management'; +import { MetricOptionsDefaults } from '../metric_viz_type'; +import { setState } from '../../../application/utils/state_management/style_slice'; + +function MetricVizOptions() { + const styleState = useTypedSelector((state) => state.style) as MetricOptionsDefaults; + const dispatch = useTypedDispatch(); + const { metric } = styleState; + + const setOption = useCallback( + (callback: (draft: Draft<typeof styleState>) => void) => { + const newState = produce(styleState, callback); + dispatch(setState<MetricOptionsDefaults>(newState)); + }, + [dispatch, styleState] + ); + + return ( + <div> + <EuiPanel paddingSize="s"> + <EuiTitle size="xs"> + <h3> + <FormattedMessage id="visTypeMetric.params.settingsTitle" defaultMessage="Settings" /> + </h3> + </EuiTitle> + <EuiSpacer size="s" /> + + <SwitchOption + label={i18n.translate('visTypeMetric.params.percentageModeLabel', { + defaultMessage: 'Percentage mode', + })} + paramName="percentageMode" + value={metric.percentageMode} + setValue={(_, value) => + setOption((draft) => { + draft.metric.percentageMode = value; + }) + } + /> + + <SwitchOption + label={i18n.translate('visTypeMetric.params.showTitleLabel', { + defaultMessage: 'Show title', + })} + paramName="show" + value={metric.labels.show} + setValue={(_, value) => + setOption((draft) => { + draft.metric.labels.show = value; + }) + } + /> + </EuiPanel> + + <EuiSpacer size="s" /> + + {/* TODO: Reintroduce the other style properties */} + {/* <EuiPanel paddingSize="s"> + <EuiTitle size="xs"> + <h3> + <FormattedMessage id="visTypeMetric.params.rangesTitle" defaultMessage="Ranges" /> + </h3> + </EuiTitle> + <EuiSpacer size="s" /> + + <ColorRanges + data-test-subj="metricColorRange" + colorsRange={metric.colorsRange} + setValue={setMetric('labels') as SetColorRangeValue} + setTouched={setMetric('touched')} + setValidity={setMetric('validity')} + /> + + <EuiFormRow fullWidth display="rowCompressed" label={metricColorModeLabel}> + <EuiButtonGroup + buttonSize="compressed" + idSelected={metric.metricColorMode} + isDisabled={metric.colorsRange.length === 1} + isFullWidth={true} + legend={metricColorModeLabel} + options={vis.type.editorConfig.collections.metricColorMode} + onChange={setColorMode} + /> + </EuiFormRow> + + <ColorSchemaOptions + colorSchema={metric.colorSchema} + colorSchemas={vis.type.editorConfig.collections.colorSchemas} + disabled={ + metric.colorsRange.length === 1 || + metric.metricColorMode === ColorModes.NONE + } + invertColors={metric.invertColors} + setValue={setMetricValue as SetColorSchemaOptionsValue} + showHelpText={false} + uiState={uiState} + /> + </EuiPanel> + + <EuiSpacer size="s" /> */} + + <EuiPanel paddingSize="s"> + <EuiTitle size="xs"> + <h3> + <FormattedMessage id="visTypeMetric.params.style.styleTitle" defaultMessage="Style" /> + </h3> + </EuiTitle> + <EuiSpacer size="s" /> + + <RangeOption + label={i18n.translate('visTypeMetric.params.style.fontSizeLabel', { + defaultMessage: 'Metric font size in points', + })} + min={12} + max={120} + paramName="fontSize" + value={metric.style.fontSize} + setValue={(_, value) => + setOption((draft) => { + draft.metric.style.fontSize = value; + }) + } + showInput={true} + showLabels={true} + showValue={false} + /> + </EuiPanel> + </div> + ); +} + +export { MetricVizOptions }; diff --git a/src/plugins/wizard/public/visualizations/metric/index.ts b/src/plugins/wizard/public/visualizations/metric/index.ts new file mode 100644 index 000000000000..8efccb2639d7 --- /dev/null +++ b/src/plugins/wizard/public/visualizations/metric/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { createMetricConfig } from './metric_viz_type'; diff --git a/src/plugins/wizard/public/visualizations/metric/metric_viz_type.ts b/src/plugins/wizard/public/visualizations/metric/metric_viz_type.ts new file mode 100644 index 000000000000..eabd9df1cb2f --- /dev/null +++ b/src/plugins/wizard/public/visualizations/metric/metric_viz_type.ts @@ -0,0 +1,116 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { Schemas } from '../../../../vis_default_editor/public'; +import { AggGroupNames } from '../../../../data/public'; +import { ColorModes, ColorSchemas } from '../../../../charts/public'; +import { MetricVizOptions } from './components/metric_viz_options'; +import { VisualizationTypeOptions } from '../../services/type_service'; +import { toExpression } from './to_expression'; + +export interface MetricOptionsDefaults { + addTooltip: boolean; + addLegend: boolean; + type: 'metric'; + metric: { + percentageMode: boolean; + useRanges: boolean; + colorSchema: ColorSchemas; + metricColorMode: ColorModes; + colorsRange: [{ from: number; to: number }]; + labels: { + show: boolean; + }; + invertColors: boolean; + style: { + bgFill: string; + bgColor: boolean; + labelColor: boolean; + subText: string; + fontSize: number; + }; + }; +} + +export const createMetricConfig = (): VisualizationTypeOptions<MetricOptionsDefaults> => ({ + name: 'metric', + title: 'Metric', + icon: 'visMetric', + description: 'Display metric visualizations', + toExpression, + ui: { + containerConfig: { + data: { + schemas: new Schemas([ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypeMetric.schemas.metricTitle', { + defaultMessage: 'Metric', + }), + min: 1, + aggFilter: [ + '!std_dev', + '!geo_centroid', + '!derivative', + '!serial_diff', + '!moving_avg', + '!cumulative_sum', + '!geo_bounds', + ], + aggSettings: { + top_hits: { + allowStrings: true, + }, + }, + defaults: { + aggType: 'avg', + }, + }, + { + group: AggGroupNames.Buckets, + name: 'group', + title: i18n.translate('visTypeMetric.schemas.splitGroupTitle', { + defaultMessage: 'Split group', + }), + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + defaults: { + aggType: 'count', + }, + }, + ]), + }, + style: { + defaults: { + addTooltip: true, + addLegend: false, + type: 'metric', + metric: { + percentageMode: false, + useRanges: false, + colorSchema: ColorSchemas.GreenToRed, + metricColorMode: ColorModes.NONE, + colorsRange: [{ from: 0, to: 10000 }], + labels: { + show: true, + }, + invertColors: false, + style: { + bgFill: '#000', + bgColor: false, + labelColor: false, + subText: '', + fontSize: 60, + }, + }, + }, + render: MetricVizOptions, + }, + }, + }, +}); diff --git a/src/plugins/wizard/public/visualizations/metric/to_expression.ts b/src/plugins/wizard/public/visualizations/metric/to_expression.ts new file mode 100644 index 000000000000..f0614801493e --- /dev/null +++ b/src/plugins/wizard/public/visualizations/metric/to_expression.ts @@ -0,0 +1,177 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { cloneDeep } from 'lodash'; +import { SchemaConfig } from '../../../../visualizations/public'; +import { MetricVisExpressionFunctionDefinition } from '../../../../vis_type_metric/public'; +import { + AggConfigs, + IAggConfig, + OpenSearchaggsExpressionFunctionDefinition, +} from '../../../../data/common'; +import { buildExpression, buildExpressionFunction } from '../../../../expressions/public'; +import { RootState } from '../../application/utils/state_management'; +import { MetricOptionsDefaults } from './metric_viz_type'; +import { getAggService, getIndexPatterns } from '../../plugin_services'; + +const prepareDimension = (params: SchemaConfig) => { + const visdimension = buildExpressionFunction('visdimension', { accessor: params.accessor }); + + if (params.format) { + visdimension.addArgument('format', params.format.id); + visdimension.addArgument('formatParams', JSON.stringify(params.format.params)); + } + + return buildExpression([visdimension]); +}; + +// TODO: Update to the common getShemas from src/plugins/visualizations/public/legacy/build_pipeline.ts +// And move to a common location accessible by all the visualizations +const getVisSchemas = (aggConfigs: AggConfigs): any => { + const createSchemaConfig = (accessor: number, agg: IAggConfig): SchemaConfig => { + const hasSubAgg = [ + 'derivative', + 'moving_avg', + 'serial_diff', + 'cumulative_sum', + 'sum_bucket', + 'avg_bucket', + 'min_bucket', + 'max_bucket', + ].includes(agg.type.name); + + const formatAgg = hasSubAgg + ? agg.params.customMetric || agg.aggConfigs.getRequestAggById(agg.params.metricAgg) + : agg; + + const params = {}; + + const label = agg.makeLabel && agg.makeLabel(); + + return { + accessor, + format: formatAgg.toSerializedFieldFormat(), + params, + label, + aggType: agg.type.name, + }; + }; + + let cnt = 0; + const schemas: any = { + metric: [], + }; + + if (!aggConfigs) { + return schemas; + } + + const responseAggs = aggConfigs.getResponseAggs(); + responseAggs.forEach((agg) => { + const schemaName = agg.schema; + + if (!schemaName) { + cnt++; + return; + } + + if (!schemas[schemaName]) { + schemas[schemaName] = []; + } + + schemas[schemaName]!.push(createSchemaConfig(cnt++, agg)); + }); + + return schemas; +}; + +interface MetricRootState extends RootState { + style: MetricOptionsDefaults; +} + +export const toExpression = async ({ style: styleState, visualization }: MetricRootState) => { + const { activeVisualization, indexPattern: indexId = '' } = visualization; + const { aggConfigParams } = activeVisualization || {}; + + if (!aggConfigParams || !aggConfigParams.length) return; + + const indexPatternsService = getIndexPatterns(); + const indexPattern = await indexPatternsService.get(indexId); + const aggConfigs = getAggService().createAggConfigs(indexPattern, cloneDeep(aggConfigParams)); + + // soon this becomes: const opensearchaggs = vis.data.aggs!.toExpressionAst(); + const opensearchaggs = buildExpressionFunction<OpenSearchaggsExpressionFunctionDefinition>( + 'opensearchaggs', + { + index: indexId, + metricsAtAllLevels: false, + partialRows: false, + aggConfigs: JSON.stringify(aggConfigs.aggs), + includeFormatHints: false, + } + ); + + // TODO: Update to use the getVisSchemas function from the Visualizations plugin + // const schemas = getVisSchemas(vis, params); + + const { + percentageMode, + useRanges, + colorSchema, + metricColorMode, + colorsRange, + labels, + invertColors, + style, + } = styleState.metric; + + const schemas = getVisSchemas(aggConfigs); + + // fix formatter for percentage mode + if (percentageMode === true) { + schemas.metric.forEach((metric: SchemaConfig) => { + metric.format = { id: 'percent' }; + }); + } + + // TODO: ExpressionFunctionDefinitions mark all arguments as required even though the function marks most as optional + // Update buildExpressionFunction to correctly handle optional arguments + // @ts-expect-error + const metricVis = buildExpressionFunction<MetricVisExpressionFunctionDefinition>('metricVis', { + percentageMode, + colorSchema, + colorMode: metricColorMode, + useRanges, + invertColors, + showLabels: labels && labels.show, + }); + + if (style) { + metricVis.addArgument('bgFill', style.bgFill); + metricVis.addArgument('font', buildExpression(`font size=${style.fontSize}`)); + metricVis.addArgument('subText', style.subText); + } + + if (colorsRange) { + colorsRange.forEach((range: any) => { + metricVis.addArgument( + 'colorRange', + buildExpression(`range from=${range.from} to=${range.to}`) + ); + }); + } + + if (schemas.group) { + metricVis.addArgument('bucket', prepareDimension(schemas.group[0])); + } + + schemas.metric.forEach((metric: SchemaConfig) => { + metricVis.addArgument('metric', prepareDimension(metric)); + }); + + const ast = buildExpression([opensearchaggs, metricVis]); + + return ast.toString(); +};