Skip to content

Commit

Permalink
[VisBuilder] Fixes pipeline aggs (#3137) (#3241)
Browse files Browse the repository at this point in the history
* fixes pipeline aggs in visbuilder

Signed-off-by: Ashwin P Chandran <ashwinpc@amazon.com>

* adds changelog

Signed-off-by: Ashwin P Chandran <ashwinpc@amazon.com>

* Adds unit tests

Signed-off-by: Ashwin P Chandran <ashwinpc@amazon.com>

* fixes pipeline aggs in visbuilder

Signed-off-by: Ashwin P Chandran <ashwinpc@amazon.com>

* adds changelog

Signed-off-by: Ashwin P Chandran <ashwinpc@amazon.com>

* Adds unit tests

Signed-off-by: Ashwin P Chandran <ashwinpc@amazon.com>

* fixes unit tests

Signed-off-by: Ashwin P Chandran <ashwinpc@amazon.com>

Signed-off-by: Ashwin P Chandran <ashwinpc@amazon.com>
(cherry picked from commit c259952)
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

# Conflicts:
#	CHANGELOG.md

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
  • Loading branch information
1 parent ad8f53f commit 57e311f
Show file tree
Hide file tree
Showing 20 changed files with 436 additions and 113 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,16 @@ import {
import { VisBuilderServices } from '../../../types';
import { AggParam, IAggType, IFieldParamType } from '../../../../../data/public';
import { saveDraftAgg, editDraftAgg } from '../../utils/state_management/visualization_slice';
import { setValidity } from '../../utils/state_management/metadata_slice';
import { setError } from '../../utils/state_management/metadata_slice';
import { Storage } from '../../../../../opensearch_dashboards_utils/public';

const EDITOR_KEY = 'CONFIG_PANEL';
const PANEL_KEY = 'SECONDARY_PANEL';

export function SecondaryPanel() {
const { draftAgg, aggConfigParams } = useTypedSelector(
(state) => state.visualization.activeVisualization!
);
const isEditorValid = useTypedSelector((state) => state.metadata.editor.validity[EDITOR_KEY]);
const isEditorValid = useTypedSelector((state) => !state.metadata.editor.errors[PANEL_KEY]);
const [touched, setTouched] = useState(false);
const dispatch = useTypedDispatch();
const vizType = useVisualizationType();
Expand Down Expand Up @@ -76,9 +76,9 @@ export function SecondaryPanel() {
(isValid: boolean) => {
// Set validity state globally
dispatch(
setValidity({
key: EDITOR_KEY,
valid: isValid,
setError({
key: PANEL_KEY,
error: !isValid,
})
);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ import React, { FC, useState, useMemo, useEffect, useLayoutEffect } from 'react'
import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public';
import { IExpressionLoaderParams } from '../../../../expressions/public';
import { VisBuilderServices } from '../../types';
import { validateSchemaState } from '../utils/validate_schema_state';
import { validateSchemaState, validateAggregations } from '../utils/validations';
import { useTypedSelector } from '../utils/state_management';
import { useVisualizationType } from '../utils/use';
import { useAggs, useVisualizationType } from '../utils/use';
import { PersistedState } from '../../../../visualizations/public';

import hand_field from '../../assets/hand_field.svg';
Expand All @@ -29,6 +29,7 @@ export const Workspace: FC = ({ children }) => {
},
} = useOpenSearchDashboards<VisBuilderServices>();
const { toExpression, ui } = useVisualizationType();
const { aggConfigs } = useAggs();
const [expression, setExpression] = useState<string>();
const [searchContext, setSearchContext] = useState<IExpressionLoaderParams['searchContext']>({
query: data.query.queryString.getQuery(),
Expand All @@ -42,13 +43,17 @@ export const Workspace: FC = ({ children }) => {
useEffect(() => {
async function loadExpression() {
const schemas = ui.containerConfig.data.schemas;
const [valid, errorMsg] = validateSchemaState(schemas, rootState.visualization);

if (!valid) {
if (errorMsg) {
toasts.addWarning(errorMsg);
}
const noAggs = aggConfigs?.aggs?.length === 0;
const schemaValidation = validateSchemaState(schemas, rootState.visualization);
const aggValidation = validateAggregations(aggConfigs?.aggs || []);

if (noAggs || !aggValidation.valid || !schemaValidation.valid) {
const err = schemaValidation.errorMsg || aggValidation.errorMsg;

if (err) toasts.addWarning(err);
setExpression(undefined);

return;
}

Expand All @@ -57,7 +62,7 @@ export const Workspace: FC = ({ children }) => {
}

loadExpression();
}, [rootState, toExpression, toasts, ui.containerConfig.data.schemas, searchContext]);
}, [rootState, toExpression, toasts, ui.containerConfig.data.schemas, searchContext, aggConfigs]);

useLayoutEffect(() => {
const subscription = data.query.state$.subscribe(({ state }) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { setEditorState } from '../metadata_slice';
import { RootState, Store } from '../store';

export const handlerEditorState = (store: Store, state: RootState, previousState: RootState) => {
const { metadata, ...renderState } = state;
const { metadata: prevMetadata, ...prevRenderState } = previousState;

// Need to make sure the editorStates are in the clean states(not the initial states) to indicate the viz finished loading
// Because when loading a saved viz from saved object, the previousStore will differ from
// the currentStore even tho there is no changes applied ( aggParams will
// first be empty, and it then will change to not empty once the viz finished loading)
if (
prevMetadata.editor.state === 'clean' &&
metadata.editor.state === 'clean' &&
JSON.stringify(renderState) !== JSON.stringify(prevRenderState)
) {
store.dispatch(setEditorState({ state: 'dirty' }));
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { findLast } from 'lodash';
import { BUCKET_TYPES, IMetricAggType, search } from '../../../../../../data/public';
import { VisBuilderServices } from '../../../../types';
import { RootState, Store } from '../store';
import { setAggParamValue } from '../visualization_slice';

/**
* Parent pipeline aggs when combined with histogram aggs need `min_doc_count` to be set appropriately to avoid an error
* on opensearch engine https://opensearch.org/docs/2.4/opensearch/pipeline-agg/#parent-aggregations
*/
export const handlerParentAggs = async (
store: Store,
state: RootState,
services: VisBuilderServices
) => {
const {
visualization: { activeVisualization, indexPattern = '' },
} = state;

const {
data: {
indexPatterns,
search: { aggs: aggService },
},
} = services;

if (!activeVisualization) return state;

const aggConfigs = aggService.createAggConfigs(
await indexPatterns.get(indexPattern),
activeVisualization.aggConfigParams
);

// Pipeline aggs should have a valid bucket agg
const metricAggs = aggConfigs.aggs.filter((agg) => agg.schema === 'metric');
const lastParentPipelineAgg = findLast(
metricAggs,
({ type }: { type: IMetricAggType }) => type.subtype === search.aggs.parentPipelineType
);
const lastBucket = findLast(aggConfigs.aggs, (agg) => agg.type.type === 'buckets');

aggConfigs.aggs.forEach((agg) => {
const isLastBucket = lastBucket?.id === agg.id;
// When a Parent Pipeline agg is selected and this agg is the last bucket.
const isLastBucketAgg = isLastBucket && lastParentPipelineAgg && agg.type;

if (
isLastBucketAgg &&
([BUCKET_TYPES.DATE_HISTOGRAM, BUCKET_TYPES.HISTOGRAM] as any).includes(agg.type.name)
) {
store.dispatch(
setAggParamValue({
aggId: agg.id,
paramName: 'min_doc_count',
// "histogram" agg has an editor for "min_doc_count" param, which accepts boolean
// "date_histogram" agg doesn't have an editor for "min_doc_count" param, it should be set as a numeric value
value: agg.type.name === 'histogram' ? true : 0,
})
);
}
});
};
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ type EditorState = 'loading' | 'clean' | 'dirty';

export interface MetadataState {
editor: {
validity: {
// Validity for each section in the editor
errors: {
// Errors for each section in the editor
[key: string]: boolean;
};
state: EditorState;
Expand All @@ -26,7 +26,7 @@ export interface MetadataState {

const initialState: MetadataState = {
editor: {
validity: {},
errors: {},
state: 'loading',
},
originatingApp: undefined,
Expand All @@ -51,9 +51,9 @@ export const slice = createSlice({
name: 'metadata',
initialState,
reducers: {
setValidity: (state, action: PayloadAction<{ key: string; valid: boolean }>) => {
const { key, valid } = action.payload;
state.editor.validity[key] = valid;
setError: (state, action: PayloadAction<{ key: string; error: boolean }>) => {
const { key, error } = action.payload;
state.editor.errors[key] = error;
},
setEditorState: (state, action: PayloadAction<{ state: EditorState }>) => {
state.editor.state = action.payload.state;
Expand All @@ -68,4 +68,4 @@ export const slice = createSlice({
});

export const { reducer } = slice;
export const { setValidity, setEditorState, setOriginatingApp, setState } = slice.actions;
export const { setError, setEditorState, setOriginatingApp, setState } = slice.actions;
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@

import { VisBuilderServices } from '../../../types';
import { createVisBuilderServicesMock } from '../mocks';
import { getPreloadedState } from './preload';
import { loadReduxState, saveReduxState } from './redux_persistence';
import { loadReduxState, persistReduxState } from './redux_persistence';
import { RootState } from './store';

describe('test redux state persistence', () => {
let mockServices: jest.Mocked<VisBuilderServices>;
Expand All @@ -22,15 +22,15 @@ describe('test redux state persistence', () => {
});

test('test load redux state when url is empty', async () => {
const defaultStates = {
const defaultStates: RootState = {
style: 'style default states',
visualization: {
searchField: '',
activeVisualization: { name: 'viz', aggConfigParams: [] },
indexPattern: 'id',
},
metadata: {
editor: { validity: {}, state: 'loading' },
editor: { errors: {}, state: 'loading' },
originatingApp: undefined,
},
};
Expand All @@ -45,8 +45,8 @@ describe('test redux state persistence', () => {
expect(returnStates).toStrictEqual(reduxStateParams);
});

test('test save redux state', () => {
saveReduxState(reduxStateParams, mockServices);
test('test persist redux state', () => {
persistReduxState(reduxStateParams, mockServices);
const urlStates = mockServices.osdUrlStateStorage.get('_a');
expect(urlStates).toStrictEqual(reduxStateParams);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export const loadReduxState = async (services: VisBuilderServices) => {
return await getPreloadedState(services);
};

export const saveReduxState = (
export const persistReduxState = (
{ style, visualization, metadata },
services: VisBuilderServices
) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
*/

import { combineReducers, configureStore, PreloadedState } from '@reduxjs/toolkit';
import { isEqual } from 'lodash';
import { reducer as styleReducer } from './style_slice';
import { reducer as visualizationReducer } from './visualization_slice';
import { reducer as metadataReducer } from './metadata_slice';
import { VisBuilderServices } from '../../..';
import { setEditorState } from './metadata_slice';
import { loadReduxState, saveReduxState } from './redux_persistence';
import { loadReduxState, persistReduxState } from './redux_persistence';
import { handlerEditorState } from './handlers/editor_state';
import { handlerParentAggs } from './handlers/parent_aggs';

const rootReducer = combineReducers({
style: styleReducer,
Expand All @@ -28,49 +30,20 @@ export const getPreloadedStore = async (services: VisBuilderServices) => {
const preloadedState = await loadReduxState(services);
const store = configurePreloadedStore(preloadedState);

const { metadata: metadataState, style: styleState, visualization: vizState } = store.getState();
let previousStore = {
viz: vizState,
style: styleState,
};
let previousMetadata = metadataState;
let previousState = store.getState();

// Listen to changes
const handleChange = () => {
const {
metadata: currentMetadataState,
style: currentStyleState,
visualization: currentVizState,
} = store.getState();
const currentStore = {
viz: currentVizState,
style: currentStyleState,
};
const currentMetadata = currentMetadataState;
const state = store.getState();
persistReduxState(state, services);

// Need to make sure the editorStates are in the clean states(not the initial states) to indicate the viz finished loading
// Because when loading a saved viz from saved object, the previousStore will differ from
// the currentStore even tho there is no changes applied ( aggParams will
// first be empty, and it then will change to not empty once the viz finished loading)
if (
previousMetadata.editor.state === 'clean' &&
currentMetadata.editor.state === 'clean' &&
JSON.stringify(currentStore) !== JSON.stringify(previousStore)
) {
store.dispatch(setEditorState({ state: 'dirty' }));
}
if (isEqual(state, previousState)) return;

previousStore = currentStore;
previousMetadata = currentMetadata;
// Side effects to apply after changes to the store are made
handlerEditorState(store, state, previousState);
handlerParentAggs(store, state, services);

saveReduxState(
{
style: store.getState().style,
visualization: store.getState().visualization,
metadata: store.getState().metadata,
},
services
);
previousState = state;
};

// the store subscriber will automatically detect changes and call handleChange function
Expand All @@ -82,7 +55,7 @@ export const getPreloadedStore = async (services: VisBuilderServices) => {
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof rootReducer>;
export type RenderState = Omit<RootState, 'metadata'>; // Remaining state after auxillary states are removed
type Store = ReturnType<typeof configurePreloadedStore>;
export type Store = ReturnType<typeof configurePreloadedStore>;
export type AppDispatch = Store['dispatch'];

export { setState as setStyleState, StyleState } from './style_slice';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,23 @@ export const slice = createSlice({
updateAggConfigParams: (state, action: PayloadAction<CreateAggConfigParams[]>) => {
state.activeVisualization!.aggConfigParams = action.payload;
},
setAggParamValue: (
state,
action: PayloadAction<{
aggId: string;
paramName: string;
value: any;
}>
) => {
const aggIndex = state.activeVisualization!.aggConfigParams.findIndex(
(agg) => agg.id === action.payload.aggId
);

state.activeVisualization!.aggConfigParams[aggIndex].params = {
...state.activeVisualization!.aggConfigParams[aggIndex].params,
[action.payload.paramName]: action.payload.value,
};
},
setState: (_state, action: PayloadAction<VisualizationState>) => {
return action.payload;
},
Expand All @@ -119,6 +136,7 @@ export const {
editDraftAgg,
saveDraftAgg,
updateAggConfigParams,
setAggParamValue,
reorderAgg,
setState,
} = slice.actions;
Loading

0 comments on commit 57e311f

Please sign in to comment.