diff --git a/CHANGELOG.md b/CHANGELOG.md index b9734f3d1de6..9879054c5ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Make build scripts find and use the latest version of Node.js that satisfies `engines.node` ([#3467](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3467)) - [Multiple DataSource] Refactor test connection to support SigV4 auth type ([#3456](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3456)) - [Darwin] Add support for Darwin for running OpenSearch snapshots with `yarn opensearch snapshot` ([#3537](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3537)) +- [Vis Builder] Add metric to metric, bucket to bucket aggregation persistence ([#3495](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/3495)) ### 🐛 Bug Fixes diff --git a/src/plugins/vis_builder/public/application/components/right_nav.tsx b/src/plugins/vis_builder/public/application/components/right_nav.tsx index d7e5586b3e94..c98638da28f1 100644 --- a/src/plugins/vis_builder/public/application/components/right_nav.tsx +++ b/src/plugins/vis_builder/public/application/components/right_nav.tsx @@ -17,7 +17,12 @@ import { useVisualizationType } from '../utils/use'; import './side_nav.scss'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { VisBuilderServices } from '../../types'; -import { setActiveVisualization, useTypedDispatch } from '../utils/state_management'; +import { + setActiveVisualization, + useTypedDispatch, + useTypedSelector, +} from '../utils/state_management'; +import { usePersistedAggParams } from '../utils/use/use_persisted_agg_params'; export const RightNav = () => { const [newVisType, setNewVisType] = useState(); @@ -28,6 +33,15 @@ export const RightNav = () => { const dispatch = useTypedDispatch(); const StyleSection = ui.containerConfig.style.render; + const { activeVisualization } = useTypedSelector((state) => state.visualization); + const aggConfigParams = activeVisualization?.aggConfigParams ?? []; + const persistedAggParams = usePersistedAggParams( + types, + aggConfigParams, + activeVisName, + newVisType + ); + const options: Array> = types.all().map(({ name, icon, title }) => ({ value: name, inputDisplay: , @@ -68,6 +82,7 @@ export const RightNav = () => { setActiveVisualization({ name: newVisType, style: types.get(newVisType)?.ui.containerConfig.style.defaults, + aggConfigParams: persistedAggParams, }) ); diff --git a/src/plugins/vis_builder/public/application/utils/mocks.ts b/src/plugins/vis_builder/public/application/utils/mocks.ts index 6feb98aaa930..25b9847986de 100644 --- a/src/plugins/vis_builder/public/application/utils/mocks.ts +++ b/src/plugins/vis_builder/public/application/utils/mocks.ts @@ -55,6 +55,7 @@ export const createVisBuilderServicesMock = () => { }, }, ], + get: jest.fn(), }, }; diff --git a/src/plugins/vis_builder/public/application/utils/state_management/shared_actions.ts b/src/plugins/vis_builder/public/application/utils/state_management/shared_actions.ts index f1dc6ee027b8..ebff4f91803c 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/shared_actions.ts +++ b/src/plugins/vis_builder/public/application/utils/state_management/shared_actions.ts @@ -4,11 +4,13 @@ */ import { createAction } from '@reduxjs/toolkit'; +import { CreateAggConfigParams } from '../../../../../data/common'; import { VisualizationType } from '../../../services/type_service/visualization_type'; interface ActiveVisPayload { name: VisualizationType['name']; style: VisualizationType['ui']['containerConfig']['style']['defaults']; + aggConfigParams: CreateAggConfigParams[]; } export const setActiveVisualization = createAction('setActiveVisualzation'); diff --git a/src/plugins/vis_builder/public/application/utils/state_management/visualization_slice.ts b/src/plugins/vis_builder/public/application/utils/state_management/visualization_slice.ts index ece2618cbbe1..6662f9f43d71 100644 --- a/src/plugins/vis_builder/public/application/utils/state_management/visualization_slice.ts +++ b/src/plugins/vis_builder/public/application/utils/state_management/visualization_slice.ts @@ -123,7 +123,7 @@ export const slice = createSlice({ builder.addCase(setActiveVisualization, (state, action) => { state.activeVisualization = { name: action.payload.name, - aggConfigParams: [], + aggConfigParams: action.payload.aggConfigParams, }; }); }, diff --git a/src/plugins/vis_builder/public/application/utils/use/use_persisted_agg_params.test.tsx b/src/plugins/vis_builder/public/application/utils/use/use_persisted_agg_params.test.tsx new file mode 100644 index 000000000000..6011ac33f71f --- /dev/null +++ b/src/plugins/vis_builder/public/application/utils/use/use_persisted_agg_params.test.tsx @@ -0,0 +1,352 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CreateAggConfigParams } from '../../../../../data/common'; +import { AggGroupNames } from '../../../../../data/common'; +import { Schema } from '../../../../../vis_default_editor/public'; +import { VisBuilderServices } from '../../../types'; +import { createVisBuilderServicesMock } from '../mocks'; +import { + AggMapping, + getSchemaMapping, + updateAggParams, + usePersistedAggParams, +} from './use_persisted_agg_params'; + +describe('new usePersistedAggParams', () => { + const types = { + get: jest.fn(), + }; + let schemaMetricTemplate: Schema; + let schemaBucketTemplate: Schema; + let oldVisualizationType: Schema[]; + let newVisualizationType: Schema[]; + let aggConfigParam: CreateAggConfigParams; + let aggConfigParams: CreateAggConfigParams[]; + + beforeEach(() => { + schemaMetricTemplate = { + aggFilter: [], + editor: '', + group: AggGroupNames.Metrics, + max: 1, + min: 1, + name: '', + params: [], + title: '', + defaults: '', + }; + schemaBucketTemplate = { + aggFilter: [], + editor: '', + group: AggGroupNames.Buckets, + max: 1, + min: 1, + name: '', + params: [], + title: '', + defaults: '', + }; + aggConfigParam = { + type: '', + schema: '', + }; + }); + + test('return the correct metric-to-metric, bucket-to-bucket mapping and correct persisted aggregations', () => { + oldVisualizationType = [ + { ...schemaMetricTemplate, name: 'old metric 1' }, + { ...schemaBucketTemplate, name: 'old bucket 1' }, + { ...schemaMetricTemplate, name: 'old metric 2' }, + { ...schemaBucketTemplate, name: 'old bucket 2' }, + { ...schemaBucketTemplate, name: 'old bucket 3' }, + ]; + newVisualizationType = [ + { ...schemaMetricTemplate, name: 'new metric 1', max: 1 }, + { ...schemaMetricTemplate, name: 'new metric 2', max: 2 }, + { ...schemaBucketTemplate, name: 'new bucket 1', max: 4 }, + { ...schemaBucketTemplate, name: 'new bucket 2', max: 5 }, + { ...schemaBucketTemplate, name: 'new bucket 3', max: 6 }, + ]; + types.get + .mockReturnValueOnce({ + ui: { + containerConfig: { + data: { + schemas: { + all: oldVisualizationType, + }, + }, + }, + }, + }) + .mockReturnValueOnce({ + ui: { + containerConfig: { + data: { + schemas: { + all: newVisualizationType, + }, + }, + }, + }, + }); + aggConfigParams = [ + { ...aggConfigParam, schema: 'old metric 1' }, + { ...aggConfigParam, schema: 'old bucket 1' }, + { ...aggConfigParam, schema: 'old metric 2' }, + { ...aggConfigParam, schema: 'old bucket 2' }, + { ...aggConfigParam, schema: 'old bucket 3' }, + { ...aggConfigParam, schema: 'old metric 2' }, + ]; + + const mappingResult = getSchemaMapping(oldVisualizationType, newVisualizationType); + expect(mappingResult).toMatchInlineSnapshot(` + Map { + "old metric 1" => Object { + "currentCount": 0, + "maxCount": 1, + "name": "new metric 1", + }, + "old metric 2" => Object { + "currentCount": 0, + "maxCount": 2, + "name": "new metric 2", + }, + "old bucket 1" => Object { + "currentCount": 0, + "maxCount": 4, + "name": "new bucket 1", + }, + "old bucket 2" => Object { + "currentCount": 0, + "maxCount": 5, + "name": "new bucket 2", + }, + "old bucket 3" => Object { + "currentCount": 0, + "maxCount": 6, + "name": "new bucket 3", + }, + } + `); + const persistResult = usePersistedAggParams( + types, + aggConfigParams, + 'old vis type', + 'new vis type' + ); + expect(persistResult).toMatchInlineSnapshot(` + Array [ + Object { + "schema": "new metric 1", + "type": "", + }, + Object { + "schema": "new bucket 1", + "type": "", + }, + Object { + "schema": "new metric 2", + "type": "", + }, + Object { + "schema": "new bucket 2", + "type": "", + }, + Object { + "schema": "new bucket 3", + "type": "", + }, + Object { + "schema": "new metric 2", + "type": "", + }, + ] + `); + }); + + test('drop the schema fields when it can not be mapped or do not belong to either metric or bucket group', () => { + oldVisualizationType = [ + { ...schemaMetricTemplate, name: 'old metric 1' }, + { ...schemaMetricTemplate, name: 'old metric 2' }, + { ...schemaBucketTemplate, name: 'old bucket 1' }, + { ...schemaMetricTemplate, name: 'undefined group', group: AggGroupNames.None }, + ]; + newVisualizationType = [ + { ...schemaMetricTemplate, name: 'new metric 1', max: 1 }, + { ...schemaBucketTemplate, name: 'new bucket 2', max: 1 }, + ]; + types.get + .mockReturnValueOnce({ + ui: { + containerConfig: { + data: { + schemas: { + all: oldVisualizationType, + }, + }, + }, + }, + }) + .mockReturnValueOnce({ + ui: { + containerConfig: { + data: { + schemas: { + all: newVisualizationType, + }, + }, + }, + }, + }); + aggConfigParams = [ + { ...aggConfigParam, schema: 'old metric 1' }, + { ...aggConfigParam, schema: 'old bucket 1' }, + ]; + + const mappingResult = getSchemaMapping(oldVisualizationType, newVisualizationType); + expect(mappingResult).toMatchInlineSnapshot(` + Map { + "old metric 1" => Object { + "currentCount": 0, + "maxCount": 1, + "name": "new metric 1", + }, + "old bucket 1" => Object { + "currentCount": 0, + "maxCount": 1, + "name": "new bucket 2", + }, + } + `); + const persistResult = usePersistedAggParams( + types, + aggConfigParams, + 'old vis type', + 'new vis type' + ); + expect(persistResult).toMatchInlineSnapshot(` + Array [ + Object { + "schema": "new metric 1", + "type": "", + }, + Object { + "schema": "new bucket 2", + "type": "", + }, + ] + `); + }); + + test('aggregations with undefined schema remain undefined; schema will be set to undefined if aggregations that exceeds the max amount', () => { + oldVisualizationType = [{ ...schemaMetricTemplate, name: 'old metric 1' }]; + newVisualizationType = [{ ...schemaMetricTemplate, name: 'new metric 1', max: 1 }]; + types.get + .mockReturnValueOnce({ + ui: { + containerConfig: { + data: { + schemas: { + all: oldVisualizationType, + }, + }, + }, + }, + }) + .mockReturnValueOnce({ + ui: { + containerConfig: { + data: { + schemas: { + all: newVisualizationType, + }, + }, + }, + }, + }); + aggConfigParams = [ + { ...aggConfigParam, schema: undefined }, + { ...aggConfigParam, schema: 'old metric 1' }, + { ...aggConfigParam, schema: 'old metric 1' }, + ]; + + const mappingResult = getSchemaMapping(oldVisualizationType, newVisualizationType); + expect(mappingResult).toMatchInlineSnapshot(` + Map { + "old metric 1" => Object { + "currentCount": 0, + "maxCount": 1, + "name": "new metric 1", + }, + } + `); + const persistResult = usePersistedAggParams( + types, + aggConfigParams, + 'old vis type', + 'new vis type' + ); + expect(persistResult).toMatchInlineSnapshot(` + Array [ + Object { + "schema": undefined, + "type": "", + }, + Object { + "schema": "new metric 1", + "type": "", + }, + Object { + "schema": undefined, + "type": "", + }, + ] + `); + }); + + test('return an empty array when there are no aggregations for persistence', () => { + oldVisualizationType = [{ ...schemaMetricTemplate, name: 'old metric 1' }]; + newVisualizationType = [{ ...schemaMetricTemplate, name: 'new metric 1', max: 1 }]; + types.get + .mockReturnValueOnce({ + ui: { + containerConfig: { + data: { + schemas: { + all: oldVisualizationType, + }, + }, + }, + }, + }) + .mockReturnValueOnce({ + ui: { + containerConfig: { + data: { + schemas: { + all: newVisualizationType, + }, + }, + }, + }, + }); + + aggConfigParams = []; + const persistResult = usePersistedAggParams( + types, + aggConfigParams, + 'old vis type', + 'new vis type' + ); + expect(persistResult).toMatchInlineSnapshot(`Array []`); + }); + + test('return an empty array when there are no new vis type or old vis type', () => { + const persistResult = usePersistedAggParams(types, aggConfigParams); + expect(persistResult).toMatchInlineSnapshot(`Array []`); + }); +}); diff --git a/src/plugins/vis_builder/public/application/utils/use/use_persisted_agg_params.ts b/src/plugins/vis_builder/public/application/utils/use/use_persisted_agg_params.ts new file mode 100644 index 000000000000..980c5f3e9f38 --- /dev/null +++ b/src/plugins/vis_builder/public/application/utils/use/use_persisted_agg_params.ts @@ -0,0 +1,95 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AggGroupNames, CreateAggConfigParams } from '../../../../../data/common'; +import { Schema } from '../../../../../vis_default_editor/public'; + +export const usePersistedAggParams = ( + types, + aggConfigParams: CreateAggConfigParams[], + oldVisType?: string, + newVisType?: string +): CreateAggConfigParams[] => { + if (oldVisType && newVisType) { + const oldVisualizationType = types.get(oldVisType)?.ui.containerConfig.data.schemas.all; + const newVisualizationType = types.get(newVisType)?.ui.containerConfig.data.schemas.all; + const aggMapping = getSchemaMapping(oldVisualizationType, newVisualizationType); + const updatedAggConfigParams = aggConfigParams.map((aggConfigParam: CreateAggConfigParams) => + updateAggParams(aggConfigParam, aggMapping) + ); + return updatedAggConfigParams; + } + return []; +}; + +// Map metric fields to metric fields, bucket fields to bucket fields +export const getSchemaMapping = ( + oldVisualizationType: Schema[], + newVisualizationType: Schema[] +): Map => { + const aggMap = new Map(); + + // currently Metrics, Buckets, and None are the three groups. We simply drop the aggregations that belongs to the None group + mapAggParamsSchema(oldVisualizationType, newVisualizationType, AggGroupNames.Metrics, aggMap); + mapAggParamsSchema(oldVisualizationType, newVisualizationType, AggGroupNames.Buckets, aggMap); + + return aggMap; +}; + +export interface AggMapping { + name: string; + maxCount: number; + currentCount: number; +} + +export const mapAggParamsSchema = ( + oldVisualizationType: Schema[], + newVisualizationType: Schema[], + aggGroup: string, + map: Map +) => { + const oldSchemas = oldVisualizationType.filter((type) => type.group === aggGroup); + const newSchemas = newVisualizationType.filter((type) => type.group === aggGroup); + + oldSchemas.forEach((oldSchema, index) => { + if (newSchemas[index]) { + const mappedNewSchema = { + name: newSchemas[index].name, + maxCount: newSchemas[index].max, + currentCount: 0, + }; + map.set(oldSchema.name, mappedNewSchema); + } + }); +}; + +export const updateAggParams = ( + oldAggParam: CreateAggConfigParams, + aggMap: Map +) => { + const newAggParam = { ...oldAggParam }; + if (oldAggParam.schema) { + const newSchema = aggMap.get(oldAggParam.schema); + newAggParam.schema = newSchema + ? newSchema.currentCount < newSchema.maxCount + ? assignNewSchemaType(oldAggParam, aggMap, newSchema) + : undefined + : undefined; + } + return newAggParam; +}; + +export const assignNewSchemaType = ( + oldAggParam: any, + aggMap: Map, + newSchema: AggMapping +) => { + aggMap.set(oldAggParam.schema, { + name: newSchema.name, + maxCount: newSchema.maxCount, + currentCount: newSchema.currentCount + 1, + }); + return aggMap.get(oldAggParam.schema)?.name; +};