diff --git a/src/plugins/data_explorer/public/services/view_service/types.ts b/src/plugins/data_explorer/public/services/view_service/types.ts index 5ecba7920b63..dd426a065256 100644 --- a/src/plugins/data_explorer/public/services/view_service/types.ts +++ b/src/plugins/data_explorer/public/services/view_service/types.ts @@ -13,7 +13,7 @@ interface ViewListItem { label: string; } -export interface DefaultViewState { +export interface DefaultViewState { state: T; root?: Partial; } @@ -25,7 +25,15 @@ export interface ViewDefinition { readonly title: string; readonly ui?: { defaults: DefaultViewState | (() => DefaultViewState) | (() => Promise); - slice: Slice; + slices: Array>; + sideEffects?: Array< + ( + store: Store, + currentState: RootState, + previousState?: RootState, + services?: DataExplorerServices + ) => void + >; }; readonly Canvas: LazyExoticComponent<(props: ViewProps) => React.ReactElement>; readonly Panel: LazyExoticComponent<(props: ViewProps) => React.ReactElement>; diff --git a/src/plugins/data_explorer/public/utils/state_management/preload.ts b/src/plugins/data_explorer/public/utils/state_management/preload.ts index fe5c23bd366c..8cfa62314430 100644 --- a/src/plugins/data_explorer/public/utils/state_management/preload.ts +++ b/src/plugins/data_explorer/public/utils/state_management/preload.ts @@ -22,12 +22,15 @@ export const getPreloadedState = async ( return; } - const { defaults } = view.ui; + const { defaults, slices } = view.ui; try { // defaults can be a function or an object const preloadedState = typeof defaults === 'function' ? await defaults() : defaults; - rootState[view.id] = preloadedState.state; + slices.forEach((slice) => { + const id = slice.name; + rootState[id] = preloadedState.state.id ? preloadedState.state.id : preloadedState.state; + }); // if the view wants to override the root state, we do that here if (preloadedState.root) { diff --git a/src/plugins/data_explorer/public/utils/state_management/store.ts b/src/plugins/data_explorer/public/utils/state_management/store.ts index daf0b3d7e369..f097fbc30b96 100644 --- a/src/plugins/data_explorer/public/utils/state_management/store.ts +++ b/src/plugins/data_explorer/public/utils/state_management/store.ts @@ -53,11 +53,18 @@ export const configurePreloadedStore = (preloadedState: PreloadedState { // For each view preload the data and register the slice const views = services.viewRegistry.all(); + const viewSideEffectsMap: Record = {}; + views.forEach((view) => { if (!view.ui) return; - const { slice } = view.ui; - registerSlice(slice); + const { slices, sideEffects } = view.ui; + registerSlices(slices); + + // Save side effects if they exist + if (sideEffects) { + viewSideEffectsMap[view.id] = sideEffects; + } }); const preloadedState = await loadReduxState(services); @@ -72,7 +79,17 @@ export const getPreloadedStore = async (services: DataExplorerServices) => { if (isEqual(state, previousState)) return; - // Add Side effects here to apply after changes to the store are made. None for now. + // Execute view-specific side effects. + Object.entries(viewSideEffectsMap).forEach(([viewId, effects]) => { + effects.forEach((effect) => { + try { + effect(state, previousState, services); + } catch (e) { + // eslint-disable-next-line no-console + console.error(`Error executing side effect for view ${viewId}:`, e); + } + }); + }); previousState = state; }; @@ -103,11 +120,13 @@ export const getPreloadedStore = async (services: DataExplorerServices) => { return { store, unsubscribe: onUnsubscribe }; }; -export const registerSlice = (slice: Slice) => { - if (dynamicReducers[slice.name]) { - throw new Error(`Slice ${slice.name} already registered`); - } - dynamicReducers[slice.name] = slice.reducer; +export const registerSlices = (slices: Slice[]) => { + slices.forEach((slice) => { + if (dynamicReducers[slice.name]) { + throw new Error(`Slice ${slice.name} already registered`); + } + dynamicReducers[slice.name] = slice.reducer; + }); }; // Infer the `RootState` and `AppDispatch` types from the store itself diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index f8e0f254f925..16dc539ac3f5 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -329,7 +329,7 @@ export class DiscoverPlugin const services = getServices(); return await getPreloadedState(services); }, - slice: discoverSlice, + slices: [discoverSlice], }, shouldShow: () => true, // ViewComponent diff --git a/src/plugins/vis_builder_new/common/index.ts b/src/plugins/vis_builder_new/common/index.ts new file mode 100644 index 000000000000..406c2554577a --- /dev/null +++ b/src/plugins/vis_builder_new/common/index.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const PLUGIN_ID = 'vis-builder-new'; +export const PLUGIN_NAME = 'VisBuilderNew'; +export const VISUALIZE_ID = 'visualize'; +export const EDIT_PATH = '/edit'; +export const VIS_BUILDER_CHART_TYPE = 'VisBuilderNew'; + +export { + VisBuilderSavedObjectAttributes, + VISBUILDER_SAVED_OBJECT, +} from './vis_builder_saved_object_attributes'; diff --git a/src/plugins/vis_builder_new/common/vis_builder_saved_object_attributes.ts b/src/plugins/vis_builder_new/common/vis_builder_saved_object_attributes.ts new file mode 100644 index 000000000000..c52ebf2b18db --- /dev/null +++ b/src/plugins/vis_builder_new/common/vis_builder_saved_object_attributes.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectAttributes } from '../../../core/types'; + +export const VISBUILDER_SAVED_OBJECT = 'visualization-visbuilder-new'; + +export interface VisBuilderSavedObjectAttributes extends SavedObjectAttributes { + title: string; + description?: string; + visualizationState?: string; + updated_at?: string; + styleState?: string; + uiState?: string; + version: number; + searchSourceFields?: { + index?: string; + }; +} diff --git a/src/plugins/vis_builder_new/config.ts b/src/plugins/vis_builder_new/config.ts new file mode 100644 index 000000000000..b6be3f718eea --- /dev/null +++ b/src/plugins/vis_builder_new/config.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema, TypeOf } from '@osd/config-schema'; + +export const configSchema = schema.object({ + enabled: schema.boolean({ defaultValue: true }), +}); + +export type ConfigSchema = TypeOf; diff --git a/src/plugins/vis_builder_new/opensearch_dashboards.json b/src/plugins/vis_builder_new/opensearch_dashboards.json new file mode 100644 index 000000000000..8b9079a8dfe7 --- /dev/null +++ b/src/plugins/vis_builder_new/opensearch_dashboards.json @@ -0,0 +1,25 @@ +{ + "id": "visBuilder-new", + "version": "1.0.0", + "opensearchDashboardsVersion": "opensearchDashboards", + "server": true, + "ui": true, + "requiredPlugins": [ + "dashboard", + "data", + "embeddable", + "expressions", + "navigation", + "savedObjects", + "visualizations", + "uiActions", + "dataExplorer" + ], + "requiredBundles": [ + "charts", + "opensearchDashboardsReact", + "opensearchDashboardsUtils", + "visDefaultEditor", + "visTypeVislib" + ] +} diff --git a/src/plugins/vis_builder_new/public/application/_util.scss b/src/plugins/vis_builder_new/public/application/_util.scss new file mode 100644 index 000000000000..165879c2ab12 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/_util.scss @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +@mixin scrollNavParent($template-row: none) { + display: grid; + min-height: 0; + + @if $template-row != "none" { + grid-template-rows: $template-row; + } +} diff --git a/src/plugins/vis_builder_new/public/application/_variables.scss b/src/plugins/vis_builder_new/public/application/_variables.scss new file mode 100644 index 000000000000..e72314b3a3bc --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/_variables.scss @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +@import "@elastic/eui/src/global_styling/variables/header"; +@import "@elastic/eui/src/global_styling/variables/form"; + +$osdHeaderOffset: $euiHeaderHeightCompensation; +$vbLeftNavWidth: 462px; diff --git a/src/plugins/vis_builder_new/public/application/app.scss b/src/plugins/vis_builder_new/public/application/app.scss new file mode 100644 index 000000000000..7e1a0b12ada9 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/app.scss @@ -0,0 +1,27 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +@import "variables"; + +.vbLayout { + padding: 0; + display: grid; + grid-template: + "topNav topNav" min-content + "leftNav workspaceNav" 1fr / #{$vbLeftNavWidth} 1fr; + height: calc(100vh - #{$osdHeaderOffset}); + + &__resizeContainer { + min-height: 0; + background-color: $euiColorEmptyShade; + } + + &__resizeButton { + transform: translateX(-$euiSizeM / 2); + } +} + +.headerIsExpanded .vbLayout { + height: calc(100vh - #{$osdHeaderOffset * 2}); +} diff --git a/src/plugins/vis_builder_new/public/application/app.tsx b/src/plugins/vis_builder_new/public/application/app.tsx new file mode 100644 index 000000000000..5c5557903b0f --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/app.tsx @@ -0,0 +1,84 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { EuiPage, EuiResizableContainer } from '@elastic/eui'; +import { useLocation } from 'react-router-dom'; +// import { DragDropProvider } from './utils/drag_drop/drag_drop_context'; +// import { LeftNav } from './components/left_nav'; +// import { TopNav } from './components/top_nav'; +// import { Workspace } from './components/workspace'; +// import { RightNav } from './components/right_nav'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { VisBuilderServices } from '../types'; +import { syncQueryStateWithUrl } from '../../../data/public'; + +import './app.scss'; + +export const VisBuilderApp = () => { + const { + services: { + data: { query }, + osdUrlStateStorage, + }, + } = useOpenSearchDashboards(); + const { pathname } = useLocation(); + + useEffect(() => { + // syncs `_g` portion of url with query services + const { stop } = syncQueryStateWithUrl(query, osdUrlStateStorage); + + return () => stop(); + + // this effect should re-run when pathname is changed to preserve querystring part, + // so the global state is always preserved + }, [query, osdUrlStateStorage, pathname]); + + // Render the application DOM. + return ( + + {/* + + + + + {(EuiResizablePanel, EuiResizableButton) => ( + <> + + + + + + + + + )} + + + */} + + ); +}; + +export { Option } from './components/option'; diff --git a/src/plugins/vis_builder_new/public/application/components/option.scss b/src/plugins/vis_builder_new/public/application/components/option.scss new file mode 100644 index 000000000000..7410489ad0b7 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/option.scss @@ -0,0 +1,8 @@ +.vbOption { + background-color: $euiColorEmptyShade; + padding: $euiSizeM; + + & &__panel { + background-color: $euiColorLightestShade; + } +} diff --git a/src/plugins/vis_builder_new/public/application/components/option.tsx b/src/plugins/vis_builder_new/public/application/components/option.tsx new file mode 100644 index 000000000000..2797629ebd04 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/components/option.tsx @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiAccordion, EuiHorizontalRule, EuiPanel, EuiSpacer } from '@elastic/eui'; +import React, { FC } from 'react'; +import './option.scss'; + +interface Props { + title: string; + initialIsOpen?: boolean; +} + +export const Option: FC = ({ title, children, initialIsOpen = false }) => { + return ( + <> + + + + {children} + + + + + ); +}; diff --git a/src/plugins/vis_builder_new/public/application/index.tsx b/src/plugins/vis_builder_new/public/application/index.tsx new file mode 100644 index 000000000000..89a67648a7dd --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/index.tsx @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Router, Route, Switch } from 'react-router-dom'; +import { Provider as ReduxProvider } from 'react-redux'; +import { Store } from 'redux'; +import { AppMountParameters } from '../../../../core/public'; +import { VisBuilderServices } from '../types'; +import { VisBuilderApp } from './app'; +import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; +import { EDIT_PATH } from '../../common'; + +export const renderApp = ( + { element, history }: AppMountParameters, + services: VisBuilderServices, + store: Store +) => { + ReactDOM.render( + + + + + + + + + + + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/src/plugins/vis_builder_new/public/application/utils/handle_vis_event.test.ts b/src/plugins/vis_builder_new/public/application/utils/handle_vis_event.test.ts new file mode 100644 index 000000000000..d92f77a7f51a --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/handle_vis_event.test.ts @@ -0,0 +1,102 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExpressionRendererEvent } from '../../../../expressions/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../visualizations/public'; +import { handleVisEvent } from './handle_vis_event'; +import { uiActionsPluginMock } from '../../../../ui_actions/public/mocks'; +import { Action, ActionType, createAction } from '../../../../ui_actions/public'; + +const executeFn = jest.fn(); + +function createTestAction( + type: string, + checkCompatibility: (context: C) => boolean, + autoExecutable = true +): Action { + return createAction({ + type: type as ActionType, + id: type, + isCompatible: (context: C) => Promise.resolve(checkCompatibility(context)), + execute: (context) => { + return executeFn(context); + }, + shouldAutoExecute: () => Promise.resolve(autoExecutable), + }); +} + +let uiActions: ReturnType; + +describe('handleVisEvent', () => { + beforeEach(() => { + uiActions = uiActionsPluginMock.createPlugin(); + + executeFn.mockClear(); + jest.useFakeTimers(); + }); + + test('should trigger the correct event', async () => { + const event: ExpressionRendererEvent = { + name: 'filter', + data: {}, + }; + const action = createTestAction('test1', () => true); + const timeFieldName = 'test-timefeild-name'; + uiActions.setup.addTriggerAction(VIS_EVENT_TO_TRIGGER.filter, action); + + await handleVisEvent(event, uiActions.doStart(), timeFieldName); + + jest.runAllTimers(); + + expect(executeFn).toBeCalledTimes(1); + expect(executeFn).toBeCalledWith( + expect.objectContaining({ + data: { timeFieldName }, + }) + ); + }); + + test('should trigger the default trigger when not found', async () => { + const event: ExpressionRendererEvent = { + name: 'test', + data: {}, + }; + const action = createTestAction('test2', () => true); + const timeFieldName = 'test-timefeild-name'; + uiActions.setup.addTriggerAction(VIS_EVENT_TO_TRIGGER.filter, action); + + await handleVisEvent(event, uiActions.doStart(), timeFieldName); + + jest.runAllTimers(); + + expect(executeFn).toBeCalledTimes(1); + expect(executeFn).toBeCalledWith( + expect.objectContaining({ + data: { timeFieldName }, + }) + ); + }); + + test('should have the correct context for `applyfilter`', async () => { + const event: ExpressionRendererEvent = { + name: 'applyFilter', + data: {}, + }; + const action = createTestAction('test3', () => true); + const timeFieldName = 'test-timefeild-name'; + uiActions.setup.addTriggerAction(VIS_EVENT_TO_TRIGGER.applyFilter, action); + + await handleVisEvent(event, uiActions.doStart(), timeFieldName); + + jest.runAllTimers(); + + expect(executeFn).toBeCalledTimes(1); + expect(executeFn).toBeCalledWith( + expect.objectContaining({ + timeFieldName, + }) + ); + }); +}); diff --git a/src/plugins/vis_builder_new/public/application/utils/handle_vis_event.ts b/src/plugins/vis_builder_new/public/application/utils/handle_vis_event.ts new file mode 100644 index 000000000000..55404a00a052 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/handle_vis_event.ts @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ExpressionRendererEvent } from '../../../../expressions/public'; +import { VIS_EVENT_TO_TRIGGER } from '../../../../visualizations/public'; +import { UiActionsStart } from '../../../../ui_actions/public'; + +export const handleVisEvent = async ( + event: ExpressionRendererEvent, + uiActions: UiActionsStart, + timeFieldName?: string +) => { + const triggerId = VIS_EVENT_TO_TRIGGER[event.name] ?? VIS_EVENT_TO_TRIGGER.filter; + const isApplyFilter = triggerId === VIS_EVENT_TO_TRIGGER.applyFilter; + const dataContext = { + timeFieldName, + ...event.data, + }; + const context = isApplyFilter ? dataContext : { data: dataContext }; + + await uiActions.getTrigger(triggerId).exec(context); +}; diff --git a/src/plugins/vis_builder_new/public/application/utils/schema.json b/src/plugins/vis_builder_new/public/application/utils/schema.json new file mode 100644 index 000000000000..7cf8bbc2534f --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/schema.json @@ -0,0 +1,47 @@ +{ + "type": "object", + "properties": { + "styleState": { + "type": "object" + }, + "visualizationState": { + "type": "object", + "properties": { + "activeVisualization": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "aggConfigParams": { + "type": "array" + } + }, + "required": [ + "name", + "aggConfigParams" + ], + "additionalProperties": false + }, + "indexPattern": { + "type": "string" + }, + "searchField": { + "type": "string" + } + }, + "required": [ + "searchField" + ], + "additionalProperties": false + }, + "uiState": { + "type": "object" + } + }, + "required": [ + "styleState", + "visualizationState" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/src/plugins/vis_builder_new/public/application/utils/state_management/editor_slice.ts b/src/plugins/vis_builder_new/public/application/utils/state_management/editor_slice.ts new file mode 100644 index 000000000000..b42c9f9fc38d --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/state_management/editor_slice.ts @@ -0,0 +1,52 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { VisBuilderServices } from '../../../types'; + +/* + * Initial state: default state when opening visBuilder plugin + * Clean state: when viz finished loading and ready to be edited + * Dirty state: when there are changes applied to the viz after it finished loading + */ +type EditorStatus = 'loading' | 'loaded' | 'clean' | 'dirty'; + +export interface EditorState { + errors: { + // Errors for each section in the editor + [key: string]: boolean; + }; + status: EditorStatus; +} + +const initialState: EditorState = { + errors: {}, + status: 'loading', +}; + +export const getPreloadedState = async (services: VisBuilderServices): Promise => { + const preloadedState = { ...initialState }; + return preloadedState; +}; + +export const slice = createSlice({ + name: 'vbEditor', + initialState, + reducers: { + setError: (state, action: PayloadAction<{ key: string; error: boolean }>) => { + const { key, error } = action.payload; + state.errors[key] = error; + }, + setEditorState: (state, action: PayloadAction<{ state: EditorStatus }>) => { + state.status = action.payload.state; + }, + setState: (_state, action: PayloadAction) => { + return action.payload; + }, + }, +}); + +export const { reducer } = slice; +export const { setError, setEditorState, setState } = slice.actions; diff --git a/src/plugins/vis_builder_new/public/application/utils/state_management/handlers/editor_state.ts b/src/plugins/vis_builder_new/public/application/utils/state_management/handlers/editor_state.ts new file mode 100644 index 000000000000..9b7e61622f9f --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/state_management/handlers/editor_state.ts @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { setEditorState } from '../editor_slice'; +import { RootState, Store } from '../../../../../../data_explorer/public'; + +export const handlerEditorState = (store: Store, state: RootState, previousState: RootState) => { + const editor = state.vbEditor; + const prevEditor = previousState.vbEditor; + const renderState = { + vbStyle: state.vbStyle, + vbUi: state.vbUi, + vbVisualization: state.vbVisualization, + } + const prevRenderState = { + vbStyle: previousState.vbStyle, + vbUi: previousState.vbUi, + vbVisualization: previousState.vbVisualization, + } + + // 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 ( + prevEditor.status === 'clean' && + editor.status === 'clean' && + JSON.stringify(renderState) !== JSON.stringify(prevRenderState) + ) { + store.dispatch(setEditorState({ state: 'dirty' })); + } +}; diff --git a/src/plugins/vis_builder_new/public/application/utils/state_management/handlers/index.ts b/src/plugins/vis_builder_new/public/application/utils/state_management/handlers/index.ts new file mode 100644 index 000000000000..1557ac80add2 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/state_management/handlers/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './editor_state'; +export * from './parent_aggs'; diff --git a/src/plugins/vis_builder_new/public/application/utils/state_management/handlers/parent_aggs.ts b/src/plugins/vis_builder_new/public/application/utils/state_management/handlers/parent_aggs.ts new file mode 100644 index 000000000000..565772415d6b --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/state_management/handlers/parent_aggs.ts @@ -0,0 +1,68 @@ +/* + * 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 '../../../../../../data_explorer/public'; +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 { + activeVisualization, + indexPattern = '' + } = state.vbVisualization; + + 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, + }) + ); + } + }); +}; diff --git a/src/plugins/vis_builder_new/public/application/utils/state_management/index.ts b/src/plugins/vis_builder_new/public/application/utils/state_management/index.ts new file mode 100644 index 000000000000..9a0549018155 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/state_management/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TypedUseSelectorHook } from 'react-redux'; +import { RootState, useTypedDispatch, useTypedSelector } from '../../../../../data_explorer/public'; +import { slice as editorSlice, EditorState } from './editor_slice'; +import { styleSlice, StyleState } from './style_slice'; +import { uiStateSlice, UIStateState } from './ui_state_slice'; +import { slice as visualizationSlice, VisualizationState } from './visualization_slice'; + +export * from './preload'; +export * from './handlers'; + +export const useEditorSelector: TypedUseSelectorHook = useTypedSelector; +export const useStyleSelector: TypedUseSelectorHook = useTypedSelector; +export const useUiSelector: TypedUseSelectorHook = useTypedSelector; +export const useVisualizationSelector: TypedUseSelectorHook = useTypedSelector; +export const useDispatch = useTypedDispatch; +export { editorSlice, styleSlice, uiStateSlice, visualizationSlice }; diff --git a/src/plugins/vis_builder_new/public/application/utils/state_management/preload.ts b/src/plugins/vis_builder_new/public/application/utils/state_management/preload.ts new file mode 100644 index 000000000000..32ecccfbdbe2 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/state_management/preload.ts @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VisBuilderServices } from '../../..'; +import { StyleState, getPreloadedState as getPreloadedStyleState } from './style_slice'; +import { + VisualizationState, + getPreloadedState as getPreloadedVisualizationState, +} from './visualization_slice'; +import { EditorState, getPreloadedState as getPreloadedEditorState } from './editor_slice'; +import { UIStateState, getPreloadedState as getPreloadedUIState } from './ui_state_slice'; +import { RootState, DefaultViewState } from '../../../../../data_explorer/public'; + +export interface VisBuilderState { + vbEditor: EditorState; + vbStyle: StyleState; + vbUi: UIStateState; + vbVisualization: VisualizationState; +} + +export const getPreloadedState = async ( + services: VisBuilderServices +): Promise> => { + const styleState = await getPreloadedStyleState(services); + const visualizationState = await getPreloadedVisualizationState(services); + const editorState = await getPreloadedEditorState(services); + const uiStateState = await getPreloadedUIState(services); + const initialState = { + vbStyle: styleState, + vbVisualization: visualizationState, + vbEditor: editorState, + vbUi: uiStateState, + }; + + const preloadedState: DefaultViewState = { + state: { + ...initialState, + }, + }; + + return preloadedState; +}; + +export type RenderState = Omit; diff --git a/src/plugins/vis_builder_new/public/application/utils/state_management/shared_actions.ts b/src/plugins/vis_builder_new/public/application/utils/state_management/shared_actions.ts new file mode 100644 index 000000000000..be6cf7e4f41b --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/state_management/shared_actions.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createAction } from '@reduxjs/toolkit'; +import { CreateAggConfigParams } from '../../../../../data/common'; +import { VisualizationType } from '../../../services/type_service/visualization_type'; + +export 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_new/public/application/utils/state_management/style_slice.ts b/src/plugins/vis_builder_new/public/application/utils/state_management/style_slice.ts new file mode 100644 index 000000000000..a28dbe8e1dbe --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/state_management/style_slice.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { VisBuilderServices } from '../../../types'; +import { setActiveVisualization } from './shared_actions'; + +export type StyleState = T; + +const initialState = {} as StyleState; + +export const getPreloadedState = async ({ types }: VisBuilderServices): Promise => { + 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: 'vbStyle', + initialState, + reducers: { + setState(state: T, action: PayloadAction>) { + return action.payload; + }, + updateState(state: T, action: PayloadAction>>) { + state = { + ...state, + ...action.payload, + }; + }, + }, + extraReducers(builder) { + builder.addCase(setActiveVisualization, (state, action) => { + return action.payload.style; + }); + }, +}); + +// Exposing the state functions as generics +export const setState = styleSlice.actions.setState as (payload: T) => PayloadAction; +export const updateState = styleSlice.actions.updateState as ( + payload: Partial +) => PayloadAction>; + +export const { reducer } = styleSlice; diff --git a/src/plugins/vis_builder_new/public/application/utils/state_management/ui_state_slice.ts b/src/plugins/vis_builder_new/public/application/utils/state_management/ui_state_slice.ts new file mode 100644 index 000000000000..e589aa6713cf --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/state_management/ui_state_slice.ts @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { VisBuilderServices } from '../../../types'; + +export type UIStateState = T; + +const initialState = {} as UIStateState; + +export const getPreloadedState = async (services: VisBuilderServices): Promise => { + return initialState; +}; + +export const uiStateSlice = createSlice({ + name: 'vbUi', + initialState, + reducers: { + setState(state: T, action: PayloadAction>) { + return action.payload; + }, + updateState(state: T, action: PayloadAction>>) { + state = { + ...state, + ...action.payload, + }; + }, + }, +}); + +// Exposing the state functions as generics +export const setState = uiStateSlice.actions.setState as (payload: T) => PayloadAction; +export const updateState = uiStateSlice.actions.updateState as ( + payload: Partial +) => PayloadAction>; + +export const { reducer } = uiStateSlice; diff --git a/src/plugins/vis_builder_new/public/application/utils/state_management/visualization_slice.ts b/src/plugins/vis_builder_new/public/application/utils/state_management/visualization_slice.ts new file mode 100644 index 000000000000..7d7f28a39188 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/state_management/visualization_slice.ts @@ -0,0 +1,142 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { CreateAggConfigParams } from '../../../../../data/common'; +import { VisBuilderServices } from '../../../types'; +import { setActiveVisualization } from './shared_actions'; + +export interface VisualizationState { + indexPattern?: string; + searchField: string; + activeVisualization?: { + name: string; + aggConfigParams: CreateAggConfigParams[]; + draftAgg?: CreateAggConfigParams; + }; +} + +const initialState: VisualizationState = { + searchField: '', +}; + +export const getPreloadedState = async ({ + types, + data, +}: VisBuilderServices): Promise => { + const preloadedState = { ...initialState }; + + const defaultVisualization = types.all()[0]; + const defaultIndexPattern = await data.indexPatterns.getDefault(); + const name = defaultVisualization.name; + if (name && defaultIndexPattern) { + preloadedState.activeVisualization = { + name, + aggConfigParams: [], + }; + + preloadedState.indexPattern = defaultIndexPattern.id; + } + + return preloadedState; +}; + +export const slice = createSlice({ + name: 'vbVisualization', + initialState, + reducers: { + setIndexPattern: (state, action: PayloadAction) => { + state.indexPattern = action.payload; + state.activeVisualization!.aggConfigParams = []; + state.activeVisualization!.draftAgg = undefined; + }, + setSearchField: (state, action: PayloadAction) => { + state.searchField = action.payload; + }, + editDraftAgg: (state, action: PayloadAction) => { + state.activeVisualization!.draftAgg = action.payload; + }, + saveDraftAgg: (state, action: PayloadAction) => { + const draftAgg = state.activeVisualization!.draftAgg; + + if (draftAgg) { + const aggIndex = state.activeVisualization!.aggConfigParams.findIndex( + (agg) => agg.id === draftAgg.id + ); + + if (aggIndex === -1) { + state.activeVisualization!.aggConfigParams.push(draftAgg); + } else { + state.activeVisualization!.aggConfigParams.splice(aggIndex, 1, draftAgg); + } + } + }, + reorderAgg: ( + 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) => { + 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) => { + return action.payload; + }, + }, + extraReducers(builder) { + builder.addCase(setActiveVisualization, (state, action) => { + state.activeVisualization = { + name: action.payload.name, + aggConfigParams: action.payload.aggConfigParams, + }; + }); + }, +}); + +export const { reducer } = slice; +export const { + setIndexPattern, + setSearchField, + editDraftAgg, + saveDraftAgg, + updateAggConfigParams, + setAggParamValue, + reorderAgg, + setState, +} = slice.actions; diff --git a/src/plugins/vis_builder_new/public/application/utils/validations/index.ts b/src/plugins/vis_builder_new/public/application/utils/validations/index.ts new file mode 100644 index 000000000000..2986b354f669 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/validations/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './validate_aggregations'; +export * from './validate_schema_state'; +export * from './vis_builder_state_validation'; diff --git a/src/plugins/vis_builder_new/public/application/utils/validations/types.ts b/src/plugins/vis_builder_new/public/application/utils/validations/types.ts new file mode 100644 index 000000000000..2763c476f2d3 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/validations/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface ValidationResult { + errorMsg?: string; + valid: T; +} diff --git a/src/plugins/vis_builder_new/public/application/utils/validations/validate_aggregations.test.ts b/src/plugins/vis_builder_new/public/application/utils/validations/validate_aggregations.test.ts new file mode 100644 index 000000000000..bec1ae506928 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/validations/validate_aggregations.test.ts @@ -0,0 +1,99 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BUCKET_TYPES, IndexPattern, METRIC_TYPES } from '../../../../../data/public'; +import { dataPluginMock } from '../../../../../data/public/mocks'; +import { validateAggregations } from './validate_aggregations'; + +describe('validateAggregations', () => { + const fields = [ + { + name: '@timestamp', + }, + { + name: 'bytes', + }, + ]; + + const indexPattern = { + id: '1234', + title: 'logstash-*', + fields: { + getByName: (name: string) => fields.find((f) => f.name === name), + filter: () => fields, + }, + } as any; + + const dataStart = dataPluginMock.createStartContract(); + + test('Pipeline aggs should have a bucket agg as the last agg', () => { + const aggConfigs = dataStart.search.aggs.createAggConfigs(indexPattern as IndexPattern, [ + { + id: '1', + enabled: true, + type: METRIC_TYPES.CUMULATIVE_SUM, + schema: 'metric', + params: {}, + }, + ]); + + const { valid, errorMsg } = validateAggregations(aggConfigs.aggs); + + expect(valid).toBe(false); + expect(errorMsg).toMatchInlineSnapshot( + `"Add a bucket with \\"Date Histogram\\" or \\"Histogram\\" aggregation."` + ); + }); + + test('Pipeline aggs should have a valid bucket agg', () => { + const aggConfigs = dataStart.search.aggs.createAggConfigs(indexPattern as IndexPattern, [ + { + id: '0', + enabled: true, + type: BUCKET_TYPES.SIGNIFICANT_TERMS, + schema: 'segment', + params: {}, + }, + { + id: '1', + enabled: true, + type: METRIC_TYPES.CUMULATIVE_SUM, + schema: 'metric', + params: {}, + }, + ]); + + const { valid, errorMsg } = validateAggregations(aggConfigs.aggs); + + expect(valid).toBe(false); + expect(errorMsg).toMatchInlineSnapshot( + `"Last bucket aggregation must be \\"Date Histogram\\" or \\"Histogram\\" when using \\"Cumulative Sum\\" metric aggregation."` + ); + }); + + test('Valid pipeline aggconfigs', () => { + const aggConfigs = dataStart.search.aggs.createAggConfigs(indexPattern as IndexPattern, [ + { + id: '0', + enabled: true, + type: BUCKET_TYPES.DATE_HISTOGRAM, + schema: 'segment', + params: {}, + }, + { + id: '1', + enabled: true, + type: METRIC_TYPES.CUMULATIVE_SUM, + schema: 'metric', + params: {}, + }, + ]); + + const { valid, errorMsg } = validateAggregations(aggConfigs.aggs); + + expect(valid).toBe(true); + expect(errorMsg).not.toBeDefined(); + }); +}); diff --git a/src/plugins/vis_builder_new/public/application/utils/validations/validate_aggregations.ts b/src/plugins/vis_builder_new/public/application/utils/validations/validate_aggregations.ts new file mode 100644 index 000000000000..470c83e96895 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/validations/validate_aggregations.ts @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { findLast } from 'lodash'; +import { AggConfig, BUCKET_TYPES, IMetricAggType } from '../../../../../data/common'; +import { search } from '../../../../../data/public'; +import { ValidationResult } from './types'; + +/** + * Validate if the aggregations to perform are possible + * @param aggs Aggregations to be performed + * @returns ValidationResult + */ +export const validateAggregations = (aggs: AggConfig[]): ValidationResult => { + // Pipeline aggs should have a valid bucket agg + const metricAggs = aggs.filter((agg) => agg.schema === 'metric'); + const lastParentPipelineAgg = findLast( + metricAggs, + ({ type }: { type: IMetricAggType }) => type.subtype === search.aggs.parentPipelineType + ); + const lastBucket = findLast(aggs, (agg) => agg.type.type === 'buckets'); + + if (!lastBucket && lastParentPipelineAgg) { + return { + valid: false, + errorMsg: i18n.translate('visBuilder.aggregation.mustHaveBucketErrorMessage', { + defaultMessage: 'Add a bucket with "Date Histogram" or "Histogram" aggregation.', + description: 'Date Histogram and Histogram should not be translated', + }), + }; + } + + // Last bucket in a Pipeline aggs should be either a date histogram or histogram + if ( + lastBucket && + lastParentPipelineAgg && + !([BUCKET_TYPES.DATE_HISTOGRAM, BUCKET_TYPES.HISTOGRAM] as any).includes(lastBucket.type.name) + ) { + return { + valid: false, + errorMsg: i18n.translate('visBuilder.aggregation.wrongLastBucketTypeErrorMessage', { + defaultMessage: + 'Last bucket aggregation must be "Date Histogram" or "Histogram" when using "{type}" metric aggregation.', + values: { type: (lastParentPipelineAgg as AggConfig).type.title }, + description: 'Date Histogram and Histogram should not be translated', + }), + }; + } + + return { valid: true }; +}; diff --git a/src/plugins/vis_builder_new/public/application/utils/validations/validate_schema_state.test.ts b/src/plugins/vis_builder_new/public/application/utils/validations/validate_schema_state.test.ts new file mode 100644 index 000000000000..a0c017cec3c4 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/validations/validate_schema_state.test.ts @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Schemas } from '../../../../../vis_default_editor/public'; +import { VisualizationState } from '../state_management'; +import { validateSchemaState } from './validate_schema_state'; + +describe('validateSchemaState', () => { + const schemas = new Schemas([ + { + name: 'metrics', + group: 'metrics', + min: 1, + }, + { + name: 'buckets', + group: 'buckets', + }, + ]); + + test('should error if schema min agg requirement not met', () => { + const visState: VisualizationState = { + searchField: '', + activeVisualization: { + name: 'Test vis', + aggConfigParams: [], + }, + }; + + const { valid, errorMsg } = validateSchemaState(schemas, visState); + + expect(valid).toBe(false); + expect(errorMsg).toMatchInlineSnapshot( + `"The Test vis visualization needs at least 1 field(s) in the agg type \\"metrics\\""` + ); + }); + + test('should be valid if schema requirements are met', () => { + const visState: VisualizationState = { + searchField: '', + activeVisualization: { + name: 'Test vis', + aggConfigParams: [ + { + type: 'count', + schema: 'metrics', + }, + ], + }, + }; + + const { valid, errorMsg } = validateSchemaState(schemas, visState); + + expect(valid).toBe(true); + expect(errorMsg).not.toBeDefined(); + }); +}); diff --git a/src/plugins/vis_builder_new/public/application/utils/validations/validate_schema_state.ts b/src/plugins/vis_builder_new/public/application/utils/validations/validate_schema_state.ts new file mode 100644 index 000000000000..38139768a8f0 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/validations/validate_schema_state.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { countBy } from 'lodash'; +import { Schemas } from '../../../../../vis_default_editor/public'; +import { VisualizationState } from '../state_management'; +import { ValidationResult } from './types'; + +/** + * Validate if the visualization state fits the vis type schema criteria + * @param schemas Visualization type config Schema objects + * @param state visualization state + * @returns ValidationResult + */ +export const validateSchemaState = ( + schemas: Schemas, + state: VisualizationState +): ValidationResult => { + const activeViz = state.activeVisualization; + const vizName = activeViz?.name; + const aggs = activeViz?.aggConfigParams; + + // Check if each schema's min agg requirement is met + const aggSchemaCount = countBy(aggs, (agg) => agg.schema); + const invalidsSchemas = schemas.all.filter((schema) => { + if (!schema.min) return false; + if (!aggSchemaCount[schema.name] || aggSchemaCount[schema.name] < schema.min) return true; + + return false; + }); + + if (invalidsSchemas.length > 0) { + return { + valid: false, + errorMsg: `The ${vizName} visualization needs at least ${invalidsSchemas[0].min} field(s) in the agg type "${invalidsSchemas[0].name}"`, + }; + } + + return { valid: true }; +}; diff --git a/src/plugins/vis_builder_new/public/application/utils/validations/vis_builder_state_validation.test.ts b/src/plugins/vis_builder_new/public/application/utils/validations/vis_builder_state_validation.test.ts new file mode 100644 index 000000000000..550e59c65f20 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/validations/vis_builder_state_validation.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { RootState } from '../state_management'; +import { validateVisBuilderState } from './vis_builder_state_validation'; + +describe('visBuilder state validation', () => { + const validStyleState = { + addLegend: true, + addTooltip: true, + legendPosition: '', + type: 'metric', + }; + + const validVisualizationState: RootState['visualization'] = { + activeVisualization: { + name: 'metric', + aggConfigParams: [], + }, + indexPattern: '', + searchField: '', + }; + + describe('correct return when validation suceeds', () => { + test('with correct visBuilder state', () => { + const validationResult = validateVisBuilderState({ + styleState: validStyleState, + visualizationState: validVisualizationState, + }); + expect(validationResult.valid).toBeTruthy(); + expect(validationResult.errorMsg).toBeUndefined(); + }); + }); + + describe('correct return with errors when validation fails', () => { + test('with non object type styleStyle', () => { + const validationResult = validateVisBuilderState({ + styleState: [], + visualizationState: validVisualizationState, + }); + expect(validationResult.valid).toBeFalsy(); + expect(validationResult.errorMsg).toBeDefined(); + }); + }); +}); diff --git a/src/plugins/vis_builder_new/public/application/utils/validations/vis_builder_state_validation.ts b/src/plugins/vis_builder_new/public/application/utils/validations/vis_builder_state_validation.ts new file mode 100644 index 000000000000..e1d85f9ff061 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/utils/validations/vis_builder_state_validation.ts @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import Ajv from 'ajv'; +import visBuilderStateSchema from '../schema.json'; +import { ValidationResult } from './types'; + +const ajv = new Ajv(); +const validateState = ajv.compile(visBuilderStateSchema); + +export const validateVisBuilderState = (visBuilderState: any): ValidationResult => { + const isVisBuilderStateValid = validateState(visBuilderState); + const errorMsg = validateState.errors + ? validateState.errors[0].instancePath + ' ' + validateState.errors[0].message + : undefined; + + return { + valid: isVisBuilderStateValid, + errorMsg, + }; +}; diff --git a/src/plugins/vis_builder_new/public/application/view_components/canvas/index.tsx b/src/plugins/vis_builder_new/public/application/view_components/canvas/index.tsx new file mode 100644 index 000000000000..cca7fa3771d1 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/view_components/canvas/index.tsx @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { ViewProps } from '../../../../../data_explorer/public'; +import { useUiSelector } from '../../utils/state_management'; + +// eslint-disable-next-line import/no-default-export +export default function VisBuilderCanvas(props: ViewProps) { + const allState = useUiSelector((state) => state); + return ; +} diff --git a/src/plugins/vis_builder_new/public/application/view_components/context/index.tsx b/src/plugins/vis_builder_new/public/application/view_components/context/index.tsx new file mode 100644 index 000000000000..cac37acf5686 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/view_components/context/index.tsx @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { DataExplorerServices, ViewProps } from '../../../../../data_explorer/public'; +import { + OpenSearchDashboardsContextProvider, + useOpenSearchDashboards, +} from '../../../../../opensearch_dashboards_react/public'; +import { VisBuilderServices, VisBuilderViewServices } from '../../../types'; +import { useSearch, SearchContextValue } from '../utils/use_search'; + +const VBContext = React.createContext({} as SearchContextValue); + +// eslint-disable-next-line import/no-default-export +export default function VisBuilderContext({ children }: React.PropsWithChildren) { + const { services: deServices } = useOpenSearchDashboards(); + const { services: vbServices } = useOpenSearchDashboards(); + const services: VisBuilderViewServices = { ...deServices, ...vbServices }; + const searchParams = useSearch(services); + + return ( + + {children} + + ); +} + +export const useVBContext = () => React.useContext(VBContext); diff --git a/src/plugins/vis_builder_new/public/application/view_components/index.ts b/src/plugins/vis_builder_new/public/application/view_components/index.ts new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/plugins/vis_builder_new/public/application/view_components/panel/index.tsx b/src/plugins/vis_builder_new/public/application/view_components/panel/index.tsx new file mode 100644 index 000000000000..20c1ec3cece5 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/view_components/panel/index.tsx @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { ViewProps } from '../../../../../data_explorer/public'; + +// eslint-disable-next-line import/no-default-export +export default function VisBuilderPanel(props: ViewProps) { + return ( +
+ +
+ ); +} diff --git a/src/plugins/vis_builder_new/public/application/view_components/utils/use_search.ts b/src/plugins/vis_builder_new/public/application/view_components/utils/use_search.ts new file mode 100644 index 000000000000..4ab26f7c2f36 --- /dev/null +++ b/src/plugins/vis_builder_new/public/application/view_components/utils/use_search.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VisBuilderViewServices } from '../../../types'; + +export const useSearch = (services: VisBuilderViewServices) => { + // const indexPattern = useIndexPattern(services); + return {}; +}; + +export type SearchContextValue = ReturnType; diff --git a/src/plugins/vis_builder_new/public/assets/fields_bg.svg b/src/plugins/vis_builder_new/public/assets/fields_bg.svg new file mode 100644 index 000000000000..d7ac9e455f0c --- /dev/null +++ b/src/plugins/vis_builder_new/public/assets/fields_bg.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/plugins/vis_builder_new/public/assets/hand_field.svg b/src/plugins/vis_builder_new/public/assets/hand_field.svg new file mode 100644 index 000000000000..8c38e60edd59 --- /dev/null +++ b/src/plugins/vis_builder_new/public/assets/hand_field.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/plugins/vis_builder_new/public/embeddable/disabled_embeddable.tsx b/src/plugins/vis_builder_new/public/embeddable/disabled_embeddable.tsx new file mode 100644 index 000000000000..6fc07deb74d8 --- /dev/null +++ b/src/plugins/vis_builder_new/public/embeddable/disabled_embeddable.tsx @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Embeddable, EmbeddableOutput } from '../../../embeddable/public'; + +import { DisabledVisualization } from './disabled_visualization'; +import { VisBuilderInput, VISBUILDER_EMBEDDABLE } from './vis_builder_embeddable'; + +export class DisabledEmbeddable extends Embeddable { + private domNode?: HTMLElement; + public readonly type = VISBUILDER_EMBEDDABLE; + + constructor(private readonly title: string, initialInput: VisBuilderInput) { + super(initialInput, { title }); + } + + public reload() {} + public render(domNode: HTMLElement) { + if (this.title) { + this.domNode = domNode; + ReactDOM.render(, domNode); + } + } + + public destroy() { + if (this.domNode) { + ReactDOM.unmountComponentAtNode(this.domNode); + } + } +} diff --git a/src/plugins/vis_builder_new/public/embeddable/disabled_visualization.scss b/src/plugins/vis_builder_new/public/embeddable/disabled_visualization.scss new file mode 100644 index 000000000000..825ff4d73223 --- /dev/null +++ b/src/plugins/vis_builder_new/public/embeddable/disabled_visualization.scss @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +.vbDisabledVisualization { + width: 100%; + display: grid; + grid-gap: $euiSize; + place-content: center; + place-items: center; + text-align: center; +} diff --git a/src/plugins/vis_builder_new/public/embeddable/disabled_visualization.tsx b/src/plugins/vis_builder_new/public/embeddable/disabled_visualization.tsx new file mode 100644 index 000000000000..30b5dd5ffa3f --- /dev/null +++ b/src/plugins/vis_builder_new/public/embeddable/disabled_visualization.tsx @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiIcon } from '@elastic/eui'; +import { FormattedMessage } from '@osd/i18n/react'; +import React from 'react'; + +import './disabled_visualization.scss'; + +export function DisabledVisualization({ title }: { title: string }) { + return ( +
+ +
+ {title} }} + /> +
+
+ +
+
+ ); +} diff --git a/src/plugins/vis_builder_new/public/embeddable/index.ts b/src/plugins/vis_builder_new/public/embeddable/index.ts new file mode 100644 index 000000000000..8b2bd225b03c --- /dev/null +++ b/src/plugins/vis_builder_new/public/embeddable/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './vis_builder_embeddable'; +export * from './vis_builder_embeddable_factory'; diff --git a/src/plugins/vis_builder_new/public/embeddable/vis_builder_component.tsx b/src/plugins/vis_builder_new/public/embeddable/vis_builder_component.tsx new file mode 100644 index 000000000000..9364f1ada0b2 --- /dev/null +++ b/src/plugins/vis_builder_new/public/embeddable/vis_builder_component.tsx @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +import { SavedObjectEmbeddableInput, withEmbeddableSubscription } from '../../../embeddable/public'; +import { VisBuilderEmbeddable, VisBuilderOutput } from './vis_builder_embeddable'; +import { getReactExpressionRenderer } from '../plugin_services'; + +interface Props { + embeddable: VisBuilderEmbeddable; + input: SavedObjectEmbeddableInput; + output: VisBuilderOutput; +} + +function VisBuilderEmbeddableComponentInner({ embeddable, input: {}, output: { error } }: Props) { + const { expression } = embeddable; + const ReactExpressionRenderer = getReactExpressionRenderer(); + + return ( + <> + {error?.message ? ( + // TODO: add correct loading and error states +
{error.message}
+ ) : ( + + )} + + ); +} + +export const VisBuilderEmbeddableComponent = withEmbeddableSubscription< + SavedObjectEmbeddableInput, + VisBuilderOutput, + VisBuilderEmbeddable +>(VisBuilderEmbeddableComponentInner); diff --git a/src/plugins/vis_builder_new/public/embeddable/vis_builder_embeddable.tsx b/src/plugins/vis_builder_new/public/embeddable/vis_builder_embeddable.tsx new file mode 100644 index 000000000000..a931877ffe6d --- /dev/null +++ b/src/plugins/vis_builder_new/public/embeddable/vis_builder_embeddable.tsx @@ -0,0 +1,341 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { cloneDeep, isEqual } from 'lodash'; +import ReactDOM from 'react-dom'; +import { merge, Subscription } from 'rxjs'; + +import { PLUGIN_ID, VISBUILDER_SAVED_OBJECT } from '../../common'; +import { + Embeddable, + EmbeddableOutput, + ErrorEmbeddable, + IContainer, + SavedObjectEmbeddableInput, +} from '../../../embeddable/public'; +import { + ExpressionRenderError, + ExpressionsStart, + IExpressionLoaderParams, +} from '../../../expressions/public'; +import { + Filter, + IIndexPattern, + opensearchFilters, + Query, + TimefilterContract, + TimeRange, +} from '../../../data/public'; +import { validateSchemaState } from '../application/utils/validations/validate_schema_state'; +import { + getExpressionLoader, + getIndexPatterns, + getTypeService, + getUIActions, +} from '../plugin_services'; +import { PersistedState } from '../../../visualizations/public'; +import { VisBuilderSavedVis } from '../saved_visualizations/transforms'; +import { handleVisEvent } from '../application/utils/handle_vis_event'; +import { VisBuilderEmbeddableFactoryDeps } from './vis_builder_embeddable_factory'; + +// Apparently this needs to match the saved object type for the clone and replace panel actions to work +export const VISBUILDER_EMBEDDABLE = VISBUILDER_SAVED_OBJECT; + +export interface VisBuilderEmbeddableConfiguration { + savedVis: VisBuilderSavedVis; + indexPatterns?: IIndexPattern[]; + editPath: string; + editUrl: string; + editable: boolean; + deps: VisBuilderEmbeddableFactoryDeps; +} + +export interface VisBuilderInput extends SavedObjectEmbeddableInput { + uiState?: any; +} + +export interface VisBuilderOutput extends EmbeddableOutput { + /** + * Will contain the saved object attributes of the VisBuilder Saved Object that matches + * `input.savedObjectId`. If the id is invalid, this may be undefined. + */ + savedVis?: VisBuilderSavedVis; + indexPatterns?: IIndexPattern[]; +} + +type ExpressionLoader = InstanceType; + +export class VisBuilderEmbeddable extends Embeddable { + public readonly type = VISBUILDER_EMBEDDABLE; + private handler?: ExpressionLoader; + private timeRange?: TimeRange; + private query?: Query; + private filters?: Filter[]; + private abortController?: AbortController; + public expression: string = ''; + private autoRefreshFetchSubscription: Subscription; + private subscriptions: Subscription[] = []; + private node?: HTMLElement; + private savedVis?: VisBuilderSavedVis; + private serializedState?: string; + private uiState: PersistedState; + private readonly deps: VisBuilderEmbeddableFactoryDeps; + + constructor( + timefilter: TimefilterContract, + { + savedVis, + editPath, + editUrl, + editable, + deps, + indexPatterns, + }: VisBuilderEmbeddableConfiguration, + initialInput: SavedObjectEmbeddableInput, + { + parent, + }: { + parent?: IContainer; + } + ) { + super( + initialInput, + { + defaultTitle: savedVis.title, + editPath, + editApp: PLUGIN_ID, + editUrl, + editable, + savedVis, + indexPatterns, + }, + parent + ); + + this.deps = deps; + this.savedVis = savedVis; + this.uiState = new PersistedState(savedVis.state.ui); + this.uiState.on('change', this.uiStateChangeHandler); + this.uiState.on('reload', this.reload); + + this.autoRefreshFetchSubscription = timefilter + .getAutoRefreshFetch$() + .subscribe(this.updateHandler.bind(this)); + + this.subscriptions.push( + merge(this.getOutput$(), this.getInput$()).subscribe(() => { + this.handleChanges(); + }) + ); + } + + private getSerializedState = () => JSON.stringify(this.savedVis?.state); + + private getExpression = async () => { + try { + // Check if saved visualization exists + const renderState = this.savedVis?.state; + if (!renderState) throw new Error('No saved visualization'); + + const visTypeString = renderState.visualization?.activeVisualization?.name || ''; + const visualizationType = getTypeService().get(visTypeString); + + if (!visualizationType) throw new Error(`Invalid visualization type ${visTypeString}`); + + const { toExpression, ui } = visualizationType; + const schemas = ui.containerConfig.data.schemas; + const { valid, errorMsg } = validateSchemaState(schemas, renderState.visualization); + + if (!valid && errorMsg) throw new Error(errorMsg); + + const exp = await toExpression(renderState, { + filters: this.filters, + query: this.query, + timeRange: this.timeRange, + }); + return exp; + } catch (error) { + this.onContainerError(error as Error); + return; + } + }; + + // Needed to enable inspection panel option + public getInspectorAdapters = () => { + if (!this.handler) { + return undefined; + } + return this.handler.inspect(); + }; + + // Needed to add informational tooltip + public getDescription() { + return this.savedVis?.description; + } + + public render(node: HTMLElement) { + if (this.output.error) { + // TODO: Can we find a more elegant way to throw, propagate, and render errors? + const errorEmbeddable = new ErrorEmbeddable( + this.output.error as Error, + this.input, + this.parent + ); + return errorEmbeddable.render(node); + } + this.timeRange = cloneDeep(this.input.timeRange); + + const div = document.createElement('div'); + div.className = `visBuilder visualize panel-content panel-content--fullWidth`; + node.appendChild(div); + + this.node = div; + super.render(this.node); + + // TODO: Investigate migrating to using `./wizard_component` for React rendering instead + const ExpressionLoader = getExpressionLoader(); + this.handler = new ExpressionLoader(this.node, undefined, { + onRenderError: (_element: HTMLElement, error: ExpressionRenderError) => { + this.onContainerError(error); + }, + }); + + this.subscriptions.push( + this.handler.events$.subscribe(async (event) => { + if (!this.input.disableTriggers) { + const indexPattern = await getIndexPatterns().get( + this.savedVis?.state.visualization.indexPattern ?? '' + ); + + handleVisEvent(event, getUIActions(), indexPattern.timeFieldName); + } + }) + ); + + if (this.savedVis?.description) { + div.setAttribute('data-description', this.savedVis.description); + } + + div.setAttribute('data-test-subj', 'visBuilderLoader'); + + this.subscriptions.push(this.handler.loading$.subscribe(this.onContainerLoading)); + this.subscriptions.push(this.handler.render$.subscribe(this.onContainerRender)); + + this.updateHandler(); + } + + public async reload() { + this.updateHandler(); + } + + public destroy() { + super.destroy(); + this.subscriptions.forEach((s) => s.unsubscribe()); + this.uiState.off('change', this.uiStateChangeHandler); + this.uiState.off('reload', this.reload); + if (this.node) { + ReactDOM.unmountComponentAtNode(this.node); + } + + if (this.handler) { + this.handler.destroy(); + this.handler.getElement().remove(); + } + this.autoRefreshFetchSubscription.unsubscribe(); + } + + private async updateHandler() { + const expressionParams: IExpressionLoaderParams = { + searchContext: { + timeRange: this.timeRange, + query: this.input.query, + filters: this.input.filters, + }, + uiState: this.uiState, + }; + if (this.abortController) { + this.abortController.abort(); + } + this.abortController = new AbortController(); + const abortController = this.abortController; + + if (this.handler && !abortController.signal.aborted) { + this.handler.update(this.expression, expressionParams); + } + } + + public async handleChanges() { + // TODO: refactor (here and in visualize) to remove lodash dependency - immer probably a better choice + this.transferInputToUiState(); + + let dirty = false; + + // Check if timerange has changed + if (!isEqual(this.input.timeRange, this.timeRange)) { + this.timeRange = cloneDeep(this.input.timeRange); + dirty = true; + } + + // Check if filters has changed + if (!opensearchFilters.onlyDisabledFiltersChanged(this.input.filters, this.filters)) { + this.filters = this.input.filters; + dirty = true; + } + + // Check if query has changed + if (!isEqual(this.input.query, this.query)) { + this.query = this.input.query; + dirty = true; + } + + // Check if rootState has changed + if (this.getSerializedState() !== this.serializedState) { + this.serializedState = this.getSerializedState(); + dirty = true; + } + + if (dirty) { + this.expression = (await this.getExpression()) ?? ''; + + if (this.handler) { + this.updateHandler(); + } + } + } + + onContainerLoading = () => { + this.renderComplete.dispatchInProgress(); + this.updateOutput({ loading: true, error: undefined }); + }; + + onContainerRender = () => { + this.renderComplete.dispatchComplete(); + this.updateOutput({ loading: false, error: undefined }); + }; + + onContainerError = (error: ExpressionRenderError) => { + if (this.abortController) { + this.abortController.abort(); + } + this.renderComplete.dispatchError(); + this.updateOutput({ loading: false, error }); + }; + + private uiStateChangeHandler = () => { + this.updateInput({ + uiState: this.uiState.toJSON(), + }); + }; + + private transferInputToUiState = () => { + if (JSON.stringify(this.input.uiState) !== this.uiState.toString()) + this.uiState.set(this.input.uiState); + }; + + // TODO: we may eventually need to add support for visualizations that use triggers like filter or brush, but current VisBuilder vis types don't support triggers + // public supportedTriggers(): TriggerId[] { + // return this.visType.getSupportedTriggers?.() ?? []; + // } +} diff --git a/src/plugins/vis_builder_new/public/embeddable/vis_builder_embeddable_factory.tsx b/src/plugins/vis_builder_new/public/embeddable/vis_builder_embeddable_factory.tsx new file mode 100644 index 000000000000..3c0bf0337369 --- /dev/null +++ b/src/plugins/vis_builder_new/public/embeddable/vis_builder_embeddable_factory.tsx @@ -0,0 +1,129 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { + EmbeddableFactoryDefinition, + EmbeddableOutput, + ErrorEmbeddable, + IContainer, + SavedObjectEmbeddableInput, +} from '../../../embeddable/public'; +import { VISUALIZE_ENABLE_LABS_SETTING } from '../../../visualizations/public'; +import { + EDIT_PATH, + PLUGIN_ID, + PLUGIN_NAME, + VisBuilderSavedObjectAttributes, + VISBUILDER_SAVED_OBJECT, +} from '../../common'; +import { DisabledEmbeddable } from './disabled_embeddable'; +import { + VisBuilderEmbeddable, + VisBuilderInput, + VisBuilderOutput, + VISBUILDER_EMBEDDABLE, +} from './vis_builder_embeddable'; +import { getStateFromSavedObject } from '../saved_visualizations/transforms'; +import { + getHttp, + getSavedVisBuilderLoader, + getTimeFilter, + getUISettings, +} from '../plugin_services'; +import { StartServicesGetter } from '../../../opensearch_dashboards_utils/public'; +import { VisBuilderPluginStartDependencies } from '../types'; + +export interface VisBuilderEmbeddableFactoryDeps { + start: StartServicesGetter; +} + +export class VisBuilderEmbeddableFactory + implements + EmbeddableFactoryDefinition< + SavedObjectEmbeddableInput, + VisBuilderOutput | EmbeddableOutput, + VisBuilderEmbeddable | DisabledEmbeddable, + VisBuilderSavedObjectAttributes + > { + public readonly type = VISBUILDER_EMBEDDABLE; + public readonly savedObjectMetaData = { + // TODO: Update to include most vis functionality + name: PLUGIN_NAME, + includeFields: ['visualizationState'], + type: VISBUILDER_SAVED_OBJECT, + getIconForSavedObject: () => 'visBuilder', + }; + + // TODO: Would it be better to explicitly declare start service dependencies? + constructor(private readonly deps: VisBuilderEmbeddableFactoryDeps) {} + + public canCreateNew() { + // Because VisBuilder creation starts with the visualization modal, no need to have a separate entry for VisBuilder until it's separate + return false; + } + + public async isEditable() { + // TODO: Add proper access controls + // return getCapabilities().visualize.save as boolean; + return true; + } + + public async createFromSavedObject( + savedObjectId: string, + input: VisBuilderInput, + parent?: IContainer + ): Promise { + try { + const savedObject = await getSavedVisBuilderLoader().get(savedObjectId); + const editPath = `${EDIT_PATH}/${savedObjectId}`; + const editUrl = getHttp().basePath.prepend(`/app/${PLUGIN_ID}${editPath}`); + const isLabsEnabled = getUISettings().get(VISUALIZE_ENABLE_LABS_SETTING); + + if (!isLabsEnabled) { + return new DisabledEmbeddable(PLUGIN_NAME, input); + } + + const savedVis = getStateFromSavedObject(savedObject); + const indexPatternService = this.deps.start().plugins.data.indexPatterns; + const indexPattern = await indexPatternService.get( + savedVis.state.visualization.indexPattern || '' + ); + const indexPatterns = indexPattern ? [indexPattern] : []; + + return new VisBuilderEmbeddable( + getTimeFilter(), + { + savedVis, + editUrl, + editPath, + editable: true, + deps: this.deps, + indexPatterns, + }, + { + ...input, + savedObjectId: input.savedObjectId ?? '', + }, + { + parent, + } + ); + } catch (e) { + console.error(e); // eslint-disable-line no-console + return new ErrorEmbeddable(e as Error, input, parent); + } + } + + public async create(_input: SavedObjectEmbeddableInput, _parent?: IContainer) { + return undefined; + } + + public getDisplayName() { + return i18n.translate('visBuilder.displayName', { + defaultMessage: PLUGIN_ID, + }); + } +} diff --git a/src/plugins/vis_builder_new/public/index.ts b/src/plugins/vis_builder_new/public/index.ts new file mode 100644 index 000000000000..57beb3e4e513 --- /dev/null +++ b/src/plugins/vis_builder_new/public/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginInitializerContext } from '../../../core/public'; +import { VisBuilderPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, OpenSearch Dashboards Platform `plugin()` initializer. +export function plugin(initializerContext: PluginInitializerContext) { + return new VisBuilderPlugin(initializerContext); +} +export { VisBuilderServices, VisBuilderPluginStartDependencies, VisBuilderStart } from './types'; diff --git a/src/plugins/vis_builder_new/public/plugin.ts b/src/plugins/vis_builder_new/public/plugin.ts new file mode 100644 index 000000000000..c7829aa18b37 --- /dev/null +++ b/src/plugins/vis_builder_new/public/plugin.ts @@ -0,0 +1,246 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { BehaviorSubject } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { lazy } from 'react'; +import { + AppMountParameters, + AppNavLinkStatus, + AppUpdater, + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, + ScopedHistory, +} from '../../../core/public'; +import { + VisBuilderPluginSetupDependencies, + VisBuilderPluginStartDependencies, + VisBuilderServices, + VisBuilderSetup, + VisBuilderStart, +} from './types'; +import { VisBuilderEmbeddableFactory, VISBUILDER_EMBEDDABLE } from './embeddable'; +import { + EDIT_PATH, + PLUGIN_ID, + PLUGIN_NAME, + VISBUILDER_SAVED_OBJECT, + VIS_BUILDER_CHART_TYPE, +} from '../common'; +import { TypeService } from './services/type_service'; +import { + editorSlice, + styleSlice, + uiStateSlice, + visualizationSlice, + getPreloadedState, + handlerEditorState, + handlerParentAggs, +} from './application/utils/state_management'; +import { + setExpressionLoader, + setReactExpressionRenderer, + setSearchService, + setIndexPatterns, + setHttp, + setSavedVisBuilderLoader, + setTimeFilter, + setUISettings, + setUIActions, + setTypeService, + setQueryService, + getHeaderActionMenuMounter, + setHeaderActionMenuMounter, +} from './plugin_services'; +import { createSavedVisBuilderLoader } from './saved_visualizations'; +import { registerDefaultTypes } from './visualizations'; +import { ConfigSchema } from '../config'; +import { createStartServicesGetter } from '../../opensearch_dashboards_utils/public'; +import { opensearchFilters } from '../../data/public'; +import { useOpenSearchDashboards } from '../../opensearch_dashboards_react/public'; + +export class VisBuilderPlugin + implements + Plugin< + VisBuilderSetup, + VisBuilderStart, + VisBuilderPluginSetupDependencies, + VisBuilderPluginStartDependencies + > { + private typeService = new TypeService(); + private appStateUpdater = new BehaviorSubject(() => ({})); + private stopUrlTracking?: () => void; + private currentHistory?: ScopedHistory; + + constructor(public initializerContext: PluginInitializerContext) {} + + public setup( + core: CoreSetup, + { embeddable, visualizations, dataExplorer }: VisBuilderPluginSetupDependencies + ) { + // Register Default Visualizations + const typeService = this.typeService; + registerDefaultTypes(typeService.setup()); + + // Register the plugin to core + core.application.register({ + id: PLUGIN_ID, + title: PLUGIN_NAME, + navLinkStatus: AppNavLinkStatus.hidden, + defaultPath: '#/', + mount: async (params: AppMountParameters) => { + // Get start services as specified in opensearch_dashboards.json + const [coreStart] = await core.getStartServices(); + const { + application: { navigateToApp }, + } = coreStart; + setHeaderActionMenuMounter(params.setHeaderActionMenu); + this.currentHistory = params.history; + + // dispatch synthetic hash change event to update hash history objects + // this is necessary because hash updates triggered by using popState won't trigger this event naturally. + this.currentHistory.listen(() => { + window.dispatchEvent(new HashChangeEvent('hashchange')); + }); + + // This is for instances where the user navigates to the app from the application nav menu + const path = window.location.hash; + navigateToApp('data-explorer', { + replace: true, + path: `/${PLUGIN_ID}${path}`, + }); + + return () => {}; + }, + }); + + // Register view in data explorer + dataExplorer.registerView({ + id: PLUGIN_ID, + title: PLUGIN_NAME, + defaultPath: '#/', + appExtentions: { + savedObject: { + docTypes: [VISBUILDER_SAVED_OBJECT], + toListItem: (obj) => ({ + id: obj.id, + label: obj.title, + }), + }, + }, + ui: { + defaults: async () => { + const [coreStart, pluginsStart, selfStart] = await core.getStartServices(); + const services: VisBuilderServices = { + ...coreStart, + appName: PLUGIN_ID, + // history: this.currentHistory, + toastNotifications: coreStart.notifications.toasts, + data: pluginsStart.data, + savedObjectsPublic: pluginsStart.savedObjects, + navigation: pluginsStart.navigation, + expressions: pluginsStart.expressions, + types: typeService.start(), + savedVisBuilderLoader: selfStart.savedVisBuilderLoader, + embeddable: pluginsStart.embeddable, + dashboard: pluginsStart.dashboard, + uiActions: pluginsStart.uiActions, + scopedHistory: this.currentHistory, + }; + + return await getPreloadedState(services); + }, + slices: [editorSlice, styleSlice, uiStateSlice, visualizationSlice], + sideEffects: [handlerEditorState, handlerParentAggs], + }, + shouldShow: () => true, + // ViewComponent + Canvas: lazy(() => import('./application/view_components/canvas')), + Panel: lazy(() => import('./application/view_components/panel')), + Context: lazy(() => import('./application/view_components/context')), + }); + + // Register embeddable + const start = createStartServicesGetter(core.getStartServices); + const embeddableFactory = new VisBuilderEmbeddableFactory({ start }); + embeddable.registerEmbeddableFactory(VISBUILDER_EMBEDDABLE, embeddableFactory); + + // Register the plugin as an alias to create visualization + visualizations.registerAlias({ + name: PLUGIN_ID, + title: PLUGIN_NAME, + description: i18n.translate('visBuilder.visPicker.description', { + defaultMessage: 'Create visualizations using the new VisBuilder', + }), + icon: 'visBuilder', + stage: 'experimental', + aliasApp: PLUGIN_ID, + aliasPath: '#/', + appExtensions: { + visualizations: { + docTypes: [VISBUILDER_SAVED_OBJECT], + toListItem: ({ id, attributes, updated_at: updatedAt }) => ({ + description: attributes?.description, + editApp: PLUGIN_ID, + editUrl: `${EDIT_PATH}/${encodeURIComponent(id)}`, + icon: 'visBuilder', + id, + savedObjectType: VISBUILDER_SAVED_OBJECT, + stage: 'experimental', + title: attributes?.title, + typeTitle: VIS_BUILDER_CHART_TYPE, + updated_at: updatedAt, + }), + }, + }, + }); + + return { + ...typeService.setup(), + }; + } + + public start( + core: CoreStart, + { expressions, data, uiActions }: VisBuilderPluginStartDependencies + ): VisBuilderStart { + const typeService = this.typeService.start(); + + const savedVisBuilderLoader = createSavedVisBuilderLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns: data.indexPatterns, + search: data.search, + chrome: core.chrome, + overlays: core.overlays, + }); + + // Register plugin services + setSearchService(data.search); + setExpressionLoader(expressions.ExpressionLoader); + setReactExpressionRenderer(expressions.ReactExpressionRenderer); + setHttp(core.http); + setIndexPatterns(data.indexPatterns); + setSavedVisBuilderLoader(savedVisBuilderLoader); + setTimeFilter(data.query.timefilter.timefilter); + setTypeService(typeService); + setUISettings(core.uiSettings); + setUIActions(uiActions); + setQueryService(data.query); + + return { + ...typeService, + savedVisBuilderLoader, + }; + } + + public stop() { + if (this.stopUrlTracking) { + this.stopUrlTracking(); + } + } +} diff --git a/src/plugins/vis_builder_new/public/plugin_services.ts b/src/plugins/vis_builder_new/public/plugin_services.ts new file mode 100644 index 000000000000..a5cddc234ced --- /dev/null +++ b/src/plugins/vis_builder_new/public/plugin_services.ts @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AppMountParameters } from 'opensearch-dashboards/public'; +import { createGetterSetter } from '../../opensearch_dashboards_utils/common'; +import { DataPublicPluginStart, TimefilterContract } from '../../data/public'; +import { SavedVisBuilderLoader } from './saved_visualizations'; +import { HttpStart, IUiSettingsClient } from '../../../core/public'; +import { ExpressionsStart } from '../../expressions/public'; +import { TypeServiceStart } from './services/type_service'; +import { UiActionsStart } from '../../ui_actions/public'; + +export const [getSearchService, setSearchService] = createGetterSetter< + DataPublicPluginStart['search'] +>('data.search'); + +export const [getExpressionLoader, setExpressionLoader] = createGetterSetter< + ExpressionsStart['ExpressionLoader'] +>('expressions.ExpressionLoader'); + +export const [getReactExpressionRenderer, setReactExpressionRenderer] = createGetterSetter< + ExpressionsStart['ReactExpressionRenderer'] +>('expressions.ReactExpressionRenderer'); + +export const [getHttp, setHttp] = createGetterSetter('Http'); + +export const [getIndexPatterns, setIndexPatterns] = createGetterSetter< + DataPublicPluginStart['indexPatterns'] +>('data.indexPatterns'); + +export const [getSavedVisBuilderLoader, setSavedVisBuilderLoader] = createGetterSetter< + SavedVisBuilderLoader +>('SavedVisBuilderLoader'); + +export const [getTimeFilter, setTimeFilter] = createGetterSetter('TimeFilter'); + +export const [getTypeService, setTypeService] = createGetterSetter('TypeService'); + +export const [getUISettings, setUISettings] = createGetterSetter('UISettings'); +export const [getUIActions, setUIActions] = createGetterSetter('UIActions'); + +export const [getQueryService, setQueryService] = createGetterSetter< + DataPublicPluginStart['query'] +>('Query'); + +export const [getHeaderActionMenuMounter, setHeaderActionMenuMounter] = createGetterSetter< + AppMountParameters['setHeaderActionMenu'] +>('headerActionMenuMounter'); diff --git a/src/plugins/vis_builder_new/public/saved_visualizations/_saved_vis.ts b/src/plugins/vis_builder_new/public/saved_visualizations/_saved_vis.ts new file mode 100644 index 000000000000..021af777df17 --- /dev/null +++ b/src/plugins/vis_builder_new/public/saved_visualizations/_saved_vis.ts @@ -0,0 +1,58 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createSavedObjectClass, + SavedObjectOpenSearchDashboardsServices, +} from '../../../saved_objects/public'; +import { EDIT_PATH, PLUGIN_ID, VISBUILDER_SAVED_OBJECT } from '../../common'; +import { injectReferences } from './saved_visualization_references'; + +export function createSavedVisBuilderVisClass(services: SavedObjectOpenSearchDashboardsServices) { + const SavedObjectClass = createSavedObjectClass(services); + + class SavedVisBuilderVis extends SavedObjectClass { + public static type = VISBUILDER_SAVED_OBJECT; + + // if type:visBuilder has no mapping, we push this mapping into OpenSearch + public static mapping = { + title: 'text', + description: 'text', + visualizationState: 'text', + styleState: 'text', + uiState: 'text', + version: 'integer', + }; + + // Order these fields to the top, the rest are alphabetical + static fieldOrder = ['title', 'description']; + + // ID is optional, without it one will be generated on save. + constructor(id: string) { + super({ + type: SavedVisBuilderVis.type, + mapping: SavedVisBuilderVis.mapping, + injectReferences, + + // if this is null/undefined then the SavedObject will be assigned the defaults + id, + + // default values that will get assigned if the doc is new + defaults: { + title: '', + description: '', + visualizationState: '{}', + styleState: '{}', + uiState: '{}', + version: 3, + }, + }); + this.showInRecentlyAccessed = true; + this.getFullPath = () => `/app/${PLUGIN_ID}${EDIT_PATH}/${this.id}`; + } + } + + return SavedVisBuilderVis; +} diff --git a/src/plugins/vis_builder_new/public/saved_visualizations/index.ts b/src/plugins/vis_builder_new/public/saved_visualizations/index.ts new file mode 100644 index 000000000000..442d5107ea05 --- /dev/null +++ b/src/plugins/vis_builder_new/public/saved_visualizations/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './saved_visualizations'; diff --git a/src/plugins/vis_builder_new/public/saved_visualizations/saved_visualization_references.ts b/src/plugins/vis_builder_new/public/saved_visualizations/saved_visualization_references.ts new file mode 100644 index 000000000000..06710c4d0780 --- /dev/null +++ b/src/plugins/vis_builder_new/public/saved_visualizations/saved_visualization_references.ts @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectReference } from '../../../../core/public'; +import { VisBuilderSavedObject } from '../types'; +import { injectSearchSourceReferences } from '../../../data/public'; + +export function injectReferences( + savedObject: VisBuilderSavedObject, + references: SavedObjectReference[] +) { + if (savedObject.searchSourceFields) { + savedObject.searchSourceFields = injectSearchSourceReferences( + savedObject.searchSourceFields as any, + references + ); + } +} diff --git a/src/plugins/vis_builder_new/public/saved_visualizations/saved_visualizations.ts b/src/plugins/vis_builder_new/public/saved_visualizations/saved_visualizations.ts new file mode 100644 index 000000000000..324290c1da7d --- /dev/null +++ b/src/plugins/vis_builder_new/public/saved_visualizations/saved_visualizations.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + SavedObjectLoader, + SavedObjectOpenSearchDashboardsServices, +} from '../../../saved_objects/public'; +import { createSavedVisBuilderVisClass } from './_saved_vis'; + +export type SavedVisBuilderLoader = ReturnType; +export function createSavedVisBuilderLoader(services: SavedObjectOpenSearchDashboardsServices) { + const { savedObjectsClient } = services; + const SavedVisBuilderVisClass = createSavedVisBuilderVisClass(services); + + return new SavedObjectLoader(SavedVisBuilderVisClass, savedObjectsClient); +} diff --git a/src/plugins/vis_builder_new/public/saved_visualizations/transforms.test.ts b/src/plugins/vis_builder_new/public/saved_visualizations/transforms.test.ts new file mode 100644 index 000000000000..68c24dfe4af1 --- /dev/null +++ b/src/plugins/vis_builder_new/public/saved_visualizations/transforms.test.ts @@ -0,0 +1,117 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock } from '../../../../core/public/mocks'; +import { getStubIndexPattern } from '../../../data/public/test_utils'; +import { IndexPattern } from '../../../data/public'; +import { RootState } from '../application/utils/state_management'; +import { VisBuilderSavedObject } from '../types'; +import { getStateFromSavedObject, saveStateToSavedObject } from './transforms'; +import { VisBuilderSavedObjectAttributes } from '../../common'; + +const getConfig = (cfg: any) => cfg; + +describe('transforms', () => { + describe('saveStateToSavedObject', () => { + let TEST_INDEX_PATTERN_ID; + let savedObject; + let rootState: RootState; + let indexPattern: IndexPattern; + + beforeEach(() => { + TEST_INDEX_PATTERN_ID = 'test-pattern'; + savedObject = {} as VisBuilderSavedObject; + rootState = { + metadata: { editor: { state: 'loading', errors: {} } }, + style: {}, + visualization: { + searchField: '', + indexPattern: TEST_INDEX_PATTERN_ID, + activeVisualization: { + name: 'bar', + aggConfigParams: [], + }, + }, + ui: {}, + }; + indexPattern = getStubIndexPattern( + TEST_INDEX_PATTERN_ID, + getConfig, + null, + [], + coreMock.createSetup() + ); + }); + + test('should save root state information into saved object', async () => { + saveStateToSavedObject(savedObject, rootState, indexPattern); + + expect(savedObject.visualizationState).not.toContain(TEST_INDEX_PATTERN_ID); + expect(savedObject.styleState).toEqual(JSON.stringify(rootState.style)); + expect(savedObject.uiState).toEqual(JSON.stringify(rootState.ui)); + expect(savedObject.searchSourceFields?.index?.id).toEqual(TEST_INDEX_PATTERN_ID); + }); + + test('should fail if the index pattern does not match the value on state', () => { + rootState.visualization.indexPattern = 'Some-other-pattern'; + + expect(() => + saveStateToSavedObject(savedObject, rootState, indexPattern) + ).toThrowErrorMatchingInlineSnapshot( + `"indexPattern id should match the value in redux state"` + ); + }); + }); + + describe('getStateFromSavedObject', () => { + const defaultVBSaveObj = { + styleState: '{}', + visualizationState: JSON.stringify({ + searchField: '', + }), + uiState: '{}', + searchSourceFields: { + index: 'test-index', + }, + } as VisBuilderSavedObjectAttributes; + + test('should return saved object with state', () => { + const { state } = getStateFromSavedObject(defaultVBSaveObj); + + expect(state).toMatchInlineSnapshot(` + Object { + "style": Object {}, + "ui": Object {}, + "visualization": Object { + "indexPattern": "test-index", + "searchField": "", + }, + } + `); + }); + + test('should throw error if state is invalid', () => { + const mockVBSaveObj = { + ...defaultVBSaveObj, + }; + delete mockVBSaveObj.visualizationState; + + expect(() => getStateFromSavedObject(mockVBSaveObj)).toThrowErrorMatchingInlineSnapshot( + `"Unexpected end of JSON input"` + ); + }); + + test('should throw error if index pattern is missing', () => { + const mockVBSaveObj = { + ...defaultVBSaveObj, + }; + delete mockVBSaveObj.searchSourceFields; + + expect(() => getStateFromSavedObject(mockVBSaveObj)).toThrowErrorMatchingInlineSnapshot( + `"The saved object is missing an index pattern"` + ); + }); + }); +}); diff --git a/src/plugins/vis_builder_new/public/saved_visualizations/transforms.ts b/src/plugins/vis_builder_new/public/saved_visualizations/transforms.ts new file mode 100644 index 000000000000..9f8dd705e3e4 --- /dev/null +++ b/src/plugins/vis_builder_new/public/saved_visualizations/transforms.ts @@ -0,0 +1,83 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import produce from 'immer'; +import { IndexPattern } from '../../../data/public'; +import { InvalidJSONProperty } from '../../../opensearch_dashboards_utils/public'; +import { RenderState, RootState, VisualizationState } from '../application/utils/state_management'; +import { validateVisBuilderState } from '../application/utils/validations'; +import { VisBuilderSavedObject } from '../types'; +import { VisBuilderSavedObjectAttributes } from '../../common'; + +export const saveStateToSavedObject = ( + obj: VisBuilderSavedObject, + state: RootState, + indexPattern: IndexPattern +): VisBuilderSavedObject => { + if (state.visualization.indexPattern !== indexPattern.id) + throw new Error('indexPattern id should match the value in redux state'); + + obj.visualizationState = JSON.stringify( + produce(state.visualization, (draft: VisualizationState) => { + delete draft.indexPattern; + }) + ); + obj.styleState = JSON.stringify(state.style); + obj.searchSourceFields = { index: indexPattern }; + obj.uiState = JSON.stringify(state.ui); + + return obj; +}; + +export interface VisBuilderSavedVis + extends Pick { + state: RenderState; +} + +export const getStateFromSavedObject = ( + obj: VisBuilderSavedObjectAttributes +): VisBuilderSavedVis => { + const { id, title, description } = obj; + const styleState = JSON.parse(obj.styleState || '{}'); + const uiState = JSON.parse(obj.uiState || '{}'); + const vizStateWithoutIndex = JSON.parse(obj.visualizationState || ''); + const visualizationState: VisualizationState = { + searchField: '', + ...vizStateWithoutIndex, + indexPattern: obj.searchSourceFields?.index, + }; + + const validateResult = validateVisBuilderState({ styleState, visualizationState, uiState }); + + if (!validateResult.valid) { + throw new InvalidJSONProperty( + validateResult.errorMsg || + i18n.translate('visBuilder.getStateFromSavedObject.genericJSONError', { + defaultMessage: + 'Something went wrong while loading your saved object. The object may be corrupted or does not match the latest schema', + }) + ); + } + + if (!visualizationState.indexPattern) { + throw new Error( + i18n.translate('visBuilder.getStateFromSavedObject.missingIndexPattern', { + defaultMessage: 'The saved object is missing an index pattern', + }) + ); + } + + return { + id, + title, + description, + state: { + visualization: visualizationState, + style: styleState, + ui: uiState, + }, + }; +}; diff --git a/src/plugins/vis_builder_new/public/services/type_service/index.ts b/src/plugins/vis_builder_new/public/services/type_service/index.ts new file mode 100644 index 000000000000..9baeb6dbc9f4 --- /dev/null +++ b/src/plugins/vis_builder_new/public/services/type_service/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './type_service'; +export * from './types'; diff --git a/src/plugins/vis_builder_new/public/services/type_service/type_service.test.ts b/src/plugins/vis_builder_new/public/services/type_service/type_service.test.ts new file mode 100644 index 000000000000..89e1ecb59154 --- /dev/null +++ b/src/plugins/vis_builder_new/public/services/type_service/type_service.test.ts @@ -0,0 +1,70 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { VisualizationTypeOptions } from './types'; +import { TypeService } from './type_service'; + +const DEFAULT_VIZ_PROPS: VisualizationTypeOptions = { + name: 'some-name', + icon: 'some-icon', + title: 'Some Title', + ui: {} as any, // Not required for this test + toExpression: async (state) => { + return 'test'; + }, +}; + +describe('TypeService', () => { + const createVizType = (props?: Partial): VisualizationTypeOptions => { + return { + ...DEFAULT_VIZ_PROPS, + ...props, + }; + }; + + let service: TypeService; + + beforeEach(() => { + service = new TypeService(); + }); + + describe('#setup', () => { + test('should throw an error if two visualizations of the same id are registered', () => { + const { createVisualizationType } = service.setup(); + + createVisualizationType(createVizType({ name: 'viz-type-1' })); + + expect(() => { + createVisualizationType(createVizType({ name: 'viz-type-1' })); + }).toThrowErrorMatchingInlineSnapshot( + `"A visualization with this the name viz-type-1 already exists!"` + ); + }); + }); + + describe('#start', () => { + test('should return registered visualization if it exists', () => { + const { createVisualizationType } = service.setup(); + createVisualizationType(createVizType({ name: 'viz-type-1' })); + + const { get } = service.start(); + expect(get('viz-type-1')).toEqual(expect.objectContaining({ name: 'viz-type-1' })); + expect(get('viz-type-no-exists')).toBeUndefined(); + }); + + test('should return all registered visualizations', () => { + const { createVisualizationType } = service.setup(); + createVisualizationType(createVizType({ name: 'viz-type-1' })); + createVisualizationType(createVizType({ name: 'viz-type-2' })); + + const { all } = service.start(); + const allRegisteredVisualizations = all(); + expect(allRegisteredVisualizations.map(({ name }) => name)).toEqual([ + 'viz-type-1', + 'viz-type-2', + ]); + }); + }); +}); diff --git a/src/plugins/vis_builder_new/public/services/type_service/type_service.ts b/src/plugins/vis_builder_new/public/services/type_service/type_service.ts new file mode 100644 index 000000000000..ddbd735fb9e8 --- /dev/null +++ b/src/plugins/vis_builder_new/public/services/type_service/type_service.ts @@ -0,0 +1,88 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { CoreService } from '../../../../../core/types'; +import { VisualizationTypeOptions } from './types'; +import { VisualizationType } from './visualization_type'; + +/** + * Visualization Types Service + * + * @internal + */ +export class TypeService implements CoreService { + private types: Record = {}; + + private registerVisualizationType(visDefinition: VisualizationType) { + if (this.types[visDefinition.name]) { + throw new Error(`A visualization with this the name ${visDefinition.name} already exists!`); + } + this.types[visDefinition.name] = visDefinition; + } + + public setup() { + return { + /** + * registers a visualization type + * @param config - visualization type definition + */ + createVisualizationType: (config: VisualizationTypeOptions): void => { + const vis = new VisualizationType(config); + this.registerVisualizationType(vis); + }, + }; + } + + public start() { + return { + /** + * returns specific visualization or undefined if not found + * @param {string} visualization - id of visualization to return + */ + get: (visualization: string): VisualizationType | undefined => { + return this.types[visualization]; + }, + /** + * returns all registered visualization types + */ + all: (): VisualizationType[] => { + return [...Object.values(this.types)]; + }, + }; + } + + public stop() { + // nothing to do here yet + } +} + +/** @internal */ +export type TypeServiceSetup = ReturnType; +export type TypeServiceStart = ReturnType; diff --git a/src/plugins/vis_builder_new/public/services/type_service/types.ts b/src/plugins/vis_builder_new/public/services/type_service/types.ts new file mode 100644 index 000000000000..edcc0b659fc7 --- /dev/null +++ b/src/plugins/vis_builder_new/public/services/type_service/types.ts @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { ReactElement } from 'react'; +import { IconType } from '@elastic/eui'; +import { RenderState } from '../../application/utils/state_management'; +import { Schemas } from '../../../../vis_default_editor/public'; +import { IExpressionLoaderParams } from '../../../../expressions/public'; + +export interface DataTabConfig { + schemas: Schemas; +} + +export interface StyleTabConfig { + defaults: T; + render: () => ReactElement; +} + +export interface VisualizationTypeOptions { + readonly name: string; + readonly title: string; + readonly description?: string; + readonly icon: IconType; + readonly stage?: 'experimental' | 'production'; + readonly ui: { + containerConfig: { + data: DataTabConfig; + style: StyleTabConfig; + }; + }; + readonly toExpression: ( + state: RenderState, + searchContext: IExpressionLoaderParams['searchContext'] + ) => Promise; +} diff --git a/src/plugins/vis_builder_new/public/services/type_service/visualization_type.tsx b/src/plugins/vis_builder_new/public/services/type_service/visualization_type.tsx new file mode 100644 index 000000000000..2f863316435e --- /dev/null +++ b/src/plugins/vis_builder_new/public/services/type_service/visualization_type.tsx @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { IconType } from '@elastic/eui'; +import { IExpressionLoaderParams } from '../../../../expressions/public'; +import { RenderState } from '../../application/utils/state_management'; +import { VisualizationTypeOptions } from './types'; + +type IVisualizationType = VisualizationTypeOptions; + +export class VisualizationType implements IVisualizationType { + public readonly name: string; + public readonly title: string; + public readonly description: string; + public readonly icon: IconType; + public readonly stage: 'experimental' | 'production'; + public readonly ui: IVisualizationType['ui']; + public readonly toExpression: ( + state: RenderState, + searchContext: IExpressionLoaderParams['searchContext'] + ) => Promise; + + constructor(options: VisualizationTypeOptions) { + this.name = options.name; + this.title = options.title; + this.description = options.description ?? ''; + this.icon = options.icon; + this.stage = options.stage ?? 'production'; + this.ui = options.ui; + this.toExpression = options.toExpression; + } +} diff --git a/src/plugins/vis_builder_new/public/types.ts b/src/plugins/vis_builder_new/public/types.ts new file mode 100644 index 000000000000..154d03f8786c --- /dev/null +++ b/src/plugins/vis_builder_new/public/types.ts @@ -0,0 +1,69 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { History } from 'history'; +import { SavedObject, SavedObjectsStart } from '../../saved_objects/public'; +import { EmbeddableSetup, EmbeddableStart } from '../../embeddable/public'; +import { DashboardStart } from '../../dashboard/public'; +import { VisualizationsSetup } from '../../visualizations/public'; +import { ExpressionsStart } from '../../expressions/public'; +import { NavigationPublicPluginStart } from '../../navigation/public'; +import { DataPublicPluginSetup, DataPublicPluginStart } from '../../data/public'; +import { TypeServiceSetup, TypeServiceStart } from './services/type_service'; +import { SavedObjectLoader } from '../../saved_objects/public'; +import { AppMountParameters, CoreStart, ToastsStart, ScopedHistory } from '../../../core/public'; +import { UiActionsStart } from '../../ui_actions/public'; +import { DataExplorerPluginSetup, DataExplorerServices } from '../../data_explorer/public'; + +export type VisBuilderSetup = TypeServiceSetup; +export interface VisBuilderStart extends TypeServiceStart { + savedVisBuilderLoader: SavedObjectLoader; +} + +export interface VisBuilderPluginSetupDependencies { + embeddable: EmbeddableSetup; + visualizations: VisualizationsSetup; + data: DataPublicPluginSetup; + dataExplorer: DataExplorerPluginSetup; +} +export interface VisBuilderPluginStartDependencies { + embeddable: EmbeddableStart; + navigation: NavigationPublicPluginStart; + data: DataPublicPluginStart; + savedObjects: SavedObjectsStart; + dashboard: DashboardStart; + expressions: ExpressionsStart; + uiActions: UiActionsStart; +} + +export interface VisBuilderServices extends CoreStart { + appName: string; + savedVisBuilderLoader: VisBuilderStart['savedVisBuilderLoader']; + toastNotifications: ToastsStart; + savedObjectsPublic: SavedObjectsStart; + navigation: NavigationPublicPluginStart; + data: DataPublicPluginStart; + types: TypeServiceStart; + expressions: ExpressionsStart; + // history: History; + embeddable: EmbeddableStart; + scopedHistory: ScopedHistory; + dashboard: DashboardStart; + uiActions: UiActionsStart; +} + +export interface ISavedVis { + id?: string; + title: string; + description?: string; + visualizationState?: string; + styleState?: string; + uiState?: string; + version?: number; +} + +export interface VisBuilderSavedObject extends SavedObject, ISavedVis {} +// Any component inside the panel and canvas views has access to both these services. +export type VisBuilderViewServices = VisBuilderServices & DataExplorerServices; diff --git a/src/plugins/vis_builder_new/public/visualizations/common/expression_helpers.ts b/src/plugins/vis_builder_new/public/visualizations/common/expression_helpers.ts new file mode 100644 index 000000000000..f50ab9172cdb --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/common/expression_helpers.ts @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { cloneDeep } from 'lodash'; +import { OpenSearchaggsExpressionFunctionDefinition } from '../../../../data/public'; +import { ExpressionFunctionOpenSearchDashboards } from '../../../../expressions'; +import { buildExpressionFunction } from '../../../../expressions/public'; +import { VisualizationState } from '../../application/utils/state_management'; +import { getSearchService, getIndexPatterns } from '../../plugin_services'; +import { StyleState } from '../../application/utils/state_management'; + +export const getAggExpressionFunctions = async ( + visualization: VisualizationState, + style?: StyleState +) => { + const { activeVisualization, indexPattern: indexId = '' } = visualization; + const { aggConfigParams } = activeVisualization || {}; + + const indexPatternsService = getIndexPatterns(); + const indexPattern = await indexPatternsService.get(indexId); + // aggConfigParams is the serealizeable aggConfigs that need to be reconstructed here using the agg servce + const aggConfigs = getSearchService().aggs.createAggConfigs( + indexPattern, + cloneDeep(aggConfigParams) + ); + + const opensearchDashboards = buildExpressionFunction( + 'opensearchDashboards', + {} + ); + + // soon this becomes: const opensearchaggs = vis.data.aggs!.toExpressionAst(); + const opensearchaggs = buildExpressionFunction( + 'opensearchaggs', + { + index: indexId, + metricsAtAllLevels: style?.showMetricsAtAllLevels || false, + partialRows: style?.showPartialRows || false, + aggConfigs: JSON.stringify(aggConfigs.aggs), + includeFormatHints: false, + } + ); + + return { + aggConfigs, + indexPattern, + expressionFns: [opensearchDashboards, opensearchaggs], + }; +}; diff --git a/src/plugins/vis_builder_new/public/visualizations/index.ts b/src/plugins/vis_builder_new/public/visualizations/index.ts new file mode 100644 index 000000000000..c867e570143e --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/index.ts @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { TypeServiceSetup } from '../services/type_service'; +import { createMetricConfig } from './metric'; +import { createTableConfig } from './table'; +import { createHistogramConfig, createLineConfig, createAreaConfig } from './vislib'; + +export function registerDefaultTypes(typeServiceSetup: TypeServiceSetup) { + const visualizationTypes = [ + createHistogramConfig, + createLineConfig, + createAreaConfig, + createMetricConfig, + createTableConfig, + ]; + + visualizationTypes.forEach((createTypeConfig) => { + typeServiceSetup.createVisualizationType(createTypeConfig()); + }); +} diff --git a/src/plugins/vis_builder_new/public/visualizations/metric/components/metric_viz_options.tsx b/src/plugins/vis_builder_new/public/visualizations/metric/components/metric_viz_options.tsx new file mode 100644 index 000000000000..4a626cb01179 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/metric/components/metric_viz_options.tsx @@ -0,0 +1,170 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiButtonGroup, EuiFormRow } from '@elastic/eui'; +import produce from 'immer'; +import { Draft } from 'immer'; +import { + ColorModes, + ColorRanges, + ColorSchemaOptions, + colorSchemas, + RangeOption, + SwitchOption, +} from '../../../../../charts/public'; +import { + useTypedDispatch, + useTypedSelector, + setStyleState, +} from '../../../application/utils/state_management'; +import { MetricOptionsDefaults } from '../metric_viz_type'; +import { PersistedState } from '../../../../../visualizations/public'; +import { Option } from '../../../application/app'; + +const METRIC_COLOR_MODES = [ + { + id: ColorModes.NONE, + label: i18n.translate('visTypeMetric.colorModes.noneOptionLabel', { + defaultMessage: 'None', + }), + }, + { + id: ColorModes.LABELS, + label: i18n.translate('visTypeMetric.colorModes.labelsOptionLabel', { + defaultMessage: 'Labels', + }), + }, + { + id: ColorModes.BACKGROUND, + label: i18n.translate('visTypeMetric.colorModes.backgroundOptionLabel', { + defaultMessage: 'Background', + }), + }, +]; + +function MetricVizOptions() { + const styleState = useTypedSelector((state) => state.style) as MetricOptionsDefaults; + const dispatch = useTypedDispatch(); + const { metric } = styleState; + + const setOption = useCallback( + (callback: (draft: Draft) => void) => { + const newState = produce(styleState, callback); + dispatch(setStyleState(newState)); + }, + [dispatch, styleState] + ); + + const metricColorModeLabel = i18n.translate('visTypeMetric.params.color.useForLabel', { + defaultMessage: 'Use color for', + }); + + return ( + <> + + + + + ); +} + +export { MetricVizOptions }; diff --git a/src/plugins/vis_builder_new/public/visualizations/metric/index.ts b/src/plugins/vis_builder_new/public/visualizations/metric/index.ts new file mode 100644 index 000000000000..8efccb2639d7 --- /dev/null +++ b/src/plugins/vis_builder_new/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/vis_builder_new/public/visualizations/metric/metric_viz_type.ts b/src/plugins/vis_builder_new/public/visualizations/metric/metric_viz_type.ts new file mode 100644 index 000000000000..ce85db45c51b --- /dev/null +++ b/src/plugins/vis_builder_new/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 { RangeValues, 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: RangeValues[]; + labels: { + show: boolean; + }; + invertColors: boolean; + style: { + bgFill: string; + bgColor: boolean; + labelColor: boolean; + subText: string; + fontSize: number; + }; + }; +} + +export const createMetricConfig = (): VisualizationTypeOptions => ({ + 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: { + aggTypes: ['avg', 'cardinality'], + }, + }, + { + 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: { + aggTypes: ['terms'], + }, + }, + ]), + }, + 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/vis_builder_new/public/visualizations/metric/to_expression.ts b/src/plugins/vis_builder_new/public/visualizations/metric/to_expression.ts new file mode 100644 index 000000000000..5577d640b7a9 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/metric/to_expression.ts @@ -0,0 +1,151 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SchemaConfig } from '../../../../visualizations/public'; +import { MetricVisExpressionFunctionDefinition } from '../../../../vis_type_metric/public'; +import { AggConfigs, IAggConfig } from '../../../../data/common'; +import { buildExpression, buildExpressionFunction } from '../../../../expressions/public'; +import { RenderState } from '../../application/utils/state_management'; +import { MetricOptionsDefaults } from './metric_viz_type'; +import { getAggExpressionFunctions } from '../common/expression_helpers'; + +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; +}; + +export interface MetricRootState extends RenderState { + vbStyle: MetricOptionsDefaults; +} + +export const toExpression = async ({ vbStyle: styleState, vbVisualization }: MetricRootState) => { + const { aggConfigs, expressionFns } = await getAggExpressionFunctions(vbVisualization); + + // 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('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)); + }); + + return buildExpression([...expressionFns, metricVis]).toString(); +}; diff --git a/src/plugins/vis_builder_new/public/visualizations/table/components/table_viz_options.tsx b/src/plugins/vis_builder_new/public/visualizations/table/components/table_viz_options.tsx new file mode 100644 index 000000000000..a77a0811e609 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/table/components/table_viz_options.tsx @@ -0,0 +1,107 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import produce from 'immer'; +import { Draft } from 'immer'; +import { EuiIconTip } from '@elastic/eui'; +import { NumberInputOption, SwitchOption } from '../../../../../charts/public'; +import { + useTypedDispatch, + useTypedSelector, + setStyleState, +} from '../../../application/utils/state_management'; +import { TableOptionsDefaults } from '../table_viz_type'; +import { Option } from '../../../application/app'; + +function TableVizOptions() { + const styleState = useTypedSelector((state) => state.style) as TableOptionsDefaults; + const dispatch = useTypedDispatch(); + + const setOption = useCallback( + (callback: (draft: Draft) => void) => { + const newState = produce(styleState, callback); + dispatch(setStyleState(newState)); + }, + [dispatch, styleState] + ); + + const isPerPageValid = styleState.perPage === '' || styleState.perPage > 0; + + return ( + <> + + + ); +} + +export { TableVizOptions }; diff --git a/src/plugins/vis_builder_new/public/visualizations/table/index.ts b/src/plugins/vis_builder_new/public/visualizations/table/index.ts new file mode 100644 index 000000000000..51fd19d291e7 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/table/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { createTableConfig } from './table_viz_type'; diff --git a/src/plugins/vis_builder_new/public/visualizations/table/table_viz_type.ts b/src/plugins/vis_builder_new/public/visualizations/table/table_viz_type.ts new file mode 100644 index 000000000000..733ad986f289 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/table/table_viz_type.ts @@ -0,0 +1,95 @@ +/* + * 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 { TableVizOptions } from './components/table_viz_options'; +import { VisualizationTypeOptions } from '../../services/type_service'; +import { toExpression } from './to_expression'; + +export interface TableOptionsDefaults { + perPage: number | ''; + showPartialRows: boolean; + showMetricsAtAllLevels: boolean; +} + +export const createTableConfig = (): VisualizationTypeOptions => ({ + name: 'table', + title: 'Table', + icon: 'visTable', + description: 'Display table visualizations', + toExpression, + ui: { + containerConfig: { + data: { + schemas: new Schemas([ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypeTableNewNew.tableVisEditorConfig.schemas.metricTitle', { + defaultMessage: 'Metric', + }), + min: 1, + aggFilter: ['!geo_centroid', '!geo_bounds'], + aggSettings: { + top_hits: { + allowStrings: true, + }, + }, + defaults: { + aggTypes: ['avg', 'cardinality'], + }, + }, + { + group: AggGroupNames.Buckets, + name: 'bucket', + title: i18n.translate('visTypeTableNewNew.tableVisEditorConfig.schemas.bucketTitle', { + defaultMessage: 'Split rows', + }), + aggFilter: ['!filter'], + defaults: { + aggTypes: ['terms'], + }, + }, + { + group: AggGroupNames.Buckets, + name: 'split_row', + title: i18n.translate('visTypeTableNewNew.tableVisEditorConfig.schemas.splitTitle', { + defaultMessage: 'Split table in rows', + }), + min: 0, + max: 1, + aggFilter: ['!filter'], + defaults: { + aggTypes: ['terms'], + }, + }, + { + group: AggGroupNames.Buckets, + name: 'split_column', + title: i18n.translate('visTypeTableNewNew.tableVisEditorConfig.schemas.splitTitle', { + defaultMessage: 'Split table in columns', + }), + min: 0, + max: 1, + aggFilter: ['!filter'], + defaults: { + aggTypes: ['terms'], + }, + }, + ]), + }, + style: { + defaults: { + perPage: 10, + showPartialRows: false, + showMetricsAtAllLevels: false, + }, + render: TableVizOptions, + }, + }, + }, +}); diff --git a/src/plugins/vis_builder_new/public/visualizations/table/to_expression.ts b/src/plugins/vis_builder_new/public/visualizations/table/to_expression.ts new file mode 100644 index 000000000000..4ab691f6b014 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/table/to_expression.ts @@ -0,0 +1,133 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SchemaConfig } from '../../../../visualizations/public'; +import { TableVisExpressionFunctionDefinition } from '../../../../vis_type_table/public'; +import { AggConfigs, IAggConfig } from '../../../../data/common'; +import { buildExpression, buildExpressionFunction } from '../../../../expressions/public'; +import { RenderState } from '../../application/utils/state_management'; +import { TableOptionsDefaults } from './table_viz_type'; +import { getAggExpressionFunctions } from '../common/expression_helpers'; + +// 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, showMetricsAtAllLevels: boolean): 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().filter((agg: IAggConfig) => agg.enabled); + const metrics = responseAggs.filter((agg: IAggConfig) => agg.type.type === 'metrics'); + + responseAggs.forEach((agg) => { + let skipMetrics = false; + const schemaName = agg.schema; + + if (!schemaName) { + cnt++; + return; + } + + if (schemaName === 'split_row' || schemaName === 'split_column') { + skipMetrics = responseAggs.length - metrics.length > 1; + } + + if (!schemas[schemaName]) { + schemas[schemaName] = []; + } + + if (!showMetricsAtAllLevels || agg.type.type !== 'metrics') { + schemas[schemaName]!.push(createSchemaConfig(cnt++, agg)); + } + + if ( + showMetricsAtAllLevels && + (agg.type.type !== 'metrics' || metrics.length === responseAggs.length) + ) { + metrics.forEach((metric: any) => { + const schemaConfig = createSchemaConfig(cnt++, metric); + if (!skipMetrics) { + schemas.metric.push(schemaConfig); + } + }); + } + }); + + return schemas; +}; + +export interface TableRootState extends RenderState { + vbStyle: TableOptionsDefaults; +} + +export const toExpression = async ({ vbStyle: styleState, vbVisualization }: TableRootState) => { + const { aggConfigs, expressionFns } = await getAggExpressionFunctions( + vbVisualization, + styleState + ); + const { showPartialRows, showMetricsAtAllLevels } = styleState; + + const schemas = getVisSchemas(aggConfigs, showMetricsAtAllLevels); + + const metrics = + schemas.bucket && showPartialRows && !showMetricsAtAllLevels + ? schemas.metric.slice(-1 * (schemas.metric.length / schemas.bucket.length)) + : schemas.metric; + + const tableData = { + metrics, + buckets: schemas.bucket || [], + splitRow: schemas.split_row, + splitColumn: schemas.split_column, + }; + + const visConfig = { + ...styleState, + ...tableData, + }; + + const tableVis = buildExpressionFunction( + 'opensearch_dashboards_table', + { + visConfig: JSON.stringify(visConfig), + } + ); + + return buildExpression([...expressionFns, tableVis]).toString(); +}; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/area/area_vis_type.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/area/area_vis_type.ts new file mode 100644 index 000000000000..5a42492e862e --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/area/area_vis_type.ts @@ -0,0 +1,86 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { Schemas } from '../../../../../vis_default_editor/public'; +import { Positions } from '../../../../../vis_type_vislib/public'; +import { AggGroupNames } from '../../../../../data/public'; +import { AreaVisOptions } from './components/area_vis_options'; +import { VisualizationTypeOptions } from '../../../services/type_service'; +import { toExpression } from './to_expression'; +import { BasicOptionsDefaults } from '../common/types'; + +export interface AreaOptionsDefaults extends BasicOptionsDefaults { + type: 'area'; +} + +export const createAreaConfig = (): VisualizationTypeOptions => ({ + name: 'area', + title: 'Area', + icon: 'visArea', + description: 'Display area chart', + toExpression, + ui: { + containerConfig: { + data: { + schemas: new Schemas([ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypeVislib.area.metricTitle', { + defaultMessage: 'Y-axis', + }), + min: 1, + max: 3, + aggFilter: ['!geo_centroid', '!geo_bounds'], + defaults: { aggTypes: ['median'] }, + }, + { + group: AggGroupNames.Buckets, + name: 'segment', + title: i18n.translate('visTypeVislib.area.segmentTitle', { + defaultMessage: 'X-axis', + }), + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!filters'], + defaults: { aggTypes: ['date_histogram', 'terms'] }, + }, + { + group: AggGroupNames.Buckets, + name: 'group', + title: i18n.translate('visTypeVislib.area.groupTitle', { + defaultMessage: 'Split series', + }), + min: 0, + max: 3, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + defaults: { aggTypes: ['terms'] }, + }, + { + group: AggGroupNames.Buckets, + name: 'split', + title: i18n.translate('visTypeVislib.area.splitTitle', { + defaultMessage: 'Split chart', + }), + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + defaults: { aggTypes: ['terms'] }, + }, + ]), + }, + style: { + defaults: { + addTooltip: true, + addLegend: true, + legendPosition: Positions.RIGHT, + type: 'area', + }, + render: AreaVisOptions, + }, + }, + }, +}); diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/area/components/area_vis_options.tsx b/src/plugins/vis_builder_new/public/visualizations/vislib/area/components/area_vis_options.tsx new file mode 100644 index 000000000000..4b3116c83992 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/area/components/area_vis_options.tsx @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { i18n } from '@osd/i18n'; +import produce, { Draft } from 'immer'; +import { useTypedDispatch, useTypedSelector } from '../../../../application/utils/state_management'; +import { AreaOptionsDefaults } from '../area_vis_type'; +import { setState } from '../../../../application/utils/state_management/style_slice'; +import { Option } from '../../../../application/app'; +import { BasicVisOptions } from '../../common/basic_vis_options'; + +function AreaVisOptions() { + const styleState = useTypedSelector((state) => state.style) as AreaOptionsDefaults; + const dispatch = useTypedDispatch(); + + const setOption = useCallback( + (callback: (draft: Draft) => void) => { + const newState = produce(styleState, callback); + dispatch(setState(newState)); + }, + [dispatch, styleState] + ); + + return ( + <> + + + ); +} + +export { AreaVisOptions }; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/area/index.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/area/index.ts new file mode 100644 index 000000000000..7ec1f37a601d --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/area/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { createAreaConfig } from './area_vis_type'; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/area/to_expression.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/area/to_expression.ts new file mode 100644 index 000000000000..d3564d15d5d7 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/area/to_expression.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildVislibDimensions } from '../../../../../visualizations/public'; +import { + buildExpression, + buildExpressionFunction, + IExpressionLoaderParams, +} from '../../../../../expressions/public'; +import { AreaOptionsDefaults } from './area_vis_type'; +import { getAggExpressionFunctions } from '../../common/expression_helpers'; +import { VislibRootState, getValueAxes, getPipelineParams } from '../common'; +import { createVis } from '../common/create_vis'; + +export const toExpression = async ( + { vbStyle: styleState, vbVisualization }: VislibRootState, + searchContext: IExpressionLoaderParams['searchContext'] +) => { + const { aggConfigs, expressionFns, indexPattern } = await getAggExpressionFunctions( + vbVisualization + ); + const { addLegend, addTooltip, legendPosition, type } = styleState; + + const vis = await createVis(type, aggConfigs, indexPattern, searchContext?.timeRange); + + const params = getPipelineParams(); + const dimensions = await buildVislibDimensions(vis, params); + const valueAxes = getValueAxes(dimensions.y); + + // TODO: what do we want to put in this "vis config"? + const visConfig = { + addLegend, + legendPosition, + addTimeMarker: false, + addTooltip, + dimensions, + valueAxes, + }; + + const vislib = buildExpressionFunction('vislib', { + type, + visConfig: JSON.stringify(visConfig), + }); + + return buildExpression([...expressionFns, vislib]).toString(); +}; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/common/basic_vis_options.tsx b/src/plugins/vis_builder_new/public/visualizations/vislib/common/basic_vis_options.tsx new file mode 100644 index 000000000000..6b088a68547f --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/common/basic_vis_options.tsx @@ -0,0 +1,49 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import React from 'react'; +import { Draft } from 'immer'; +import { SelectOption, SwitchOption } from '../../../../../charts/public'; +import { getConfigCollections } from '../../../../../vis_type_vislib/public'; +import { BasicOptionsDefaults } from './types'; + +interface Props { + styleState: BasicOptionsDefaults; + setOption: (callback: (draft: Draft) => void) => void; +} + +export const BasicVisOptions = ({ styleState, setOption }: Props) => { + const { legendPositions } = getConfigCollections(); + return ( + <> + + setOption((draft) => { + draft.legendPosition = value; + }) + } + /> + + setOption((draft) => { + draft.addTooltip = value; + }) + } + /> + + ); +}; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/common/create_vis.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/common/create_vis.ts new file mode 100644 index 000000000000..209f4a2a50b8 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/common/create_vis.ts @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AggConfigs, IndexPattern, TimeRange } from '../../../../../data/public'; +import { Vis } from '../../../../../visualizations/public'; +import { getSearchService } from '../../../plugin_services'; + +export const createVis = async ( + type: string, + aggConfigs: AggConfigs, + indexPattern: IndexPattern, + timeRange?: TimeRange +) => { + const vis = new Vis(type); + vis.data.aggs = aggConfigs; + vis.data.searchSource = await getSearchService().searchSource.create(); + vis.data.searchSource.setField('index', indexPattern); + + const responseAggs = vis.data.aggs.getResponseAggs().filter((agg) => agg.enabled); + responseAggs.forEach((agg) => { + agg.params.timeRange = timeRange; + }); + return vis; +}; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/common/get_pipeline_params.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/common/get_pipeline_params.ts new file mode 100644 index 000000000000..12288c138b2e --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/common/get_pipeline_params.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BuildPipelineParams } from '../../../../../visualizations/public'; +import { getTimeFilter } from '../../../plugin_services'; + +export const getPipelineParams = (): BuildPipelineParams => { + const timeFilter = getTimeFilter(); + return { + timefilter: timeFilter, + timeRange: timeFilter.getTime(), + }; +}; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/common/get_value_axes.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/common/get_value_axes.ts new file mode 100644 index 000000000000..86c135110f50 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/common/get_value_axes.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SchemaConfig } from '../../../../../visualizations/public'; +import { ValueAxis } from '../../../../../vis_type_vislib/public'; + +interface ValueAxisConfig extends ValueAxis { + style: any; +} + +export const getValueAxes = (yAxes: SchemaConfig[]): ValueAxisConfig[] => + yAxes.map((y, index) => ({ + id: `ValueAxis-${index + 1}`, + labels: { + show: true, + }, + name: `ValueAxis-${index + 1}`, + position: 'left', + scale: { + type: 'linear', + mode: 'normal', + }, + show: true, + style: {}, + title: { + text: y.label, + }, + type: 'value', + })); diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/common/index.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/common/index.ts new file mode 100644 index 000000000000..70614ce555eb --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/common/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './basic_vis_options'; +export * from './get_pipeline_params'; +export * from './get_value_axes'; +export * from './types'; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/common/types.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/common/types.ts new file mode 100644 index 000000000000..1091ef271c65 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/common/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Positions } from '../../../../../vis_type_vislib/public'; +import { RenderState } from '../../../application/utils/state_management'; + +export interface BasicOptionsDefaults { + addTooltip: boolean; + addLegend: boolean; + legendPosition: Positions; + type: string; +} + +export interface VislibRootState extends RenderState { + vbStyle: T; +} diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/histogram/components/histogram_vis_options.tsx b/src/plugins/vis_builder_new/public/visualizations/vislib/histogram/components/histogram_vis_options.tsx new file mode 100644 index 000000000000..873b26ca4301 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/histogram/components/histogram_vis_options.tsx @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { i18n } from '@osd/i18n'; +import produce, { Draft } from 'immer'; +import { useTypedDispatch, useTypedSelector } from '../../../../application/utils/state_management'; +import { HistogramOptionsDefaults } from '../histogram_vis_type'; +import { BasicVisOptions } from '../../common/basic_vis_options'; +import { setState } from '../../../../application/utils/state_management/style_slice'; +import { Option } from '../../../../application/app'; + +function HistogramVisOptions() { + const styleState = useTypedSelector((state) => state.style) as HistogramOptionsDefaults; + const dispatch = useTypedDispatch(); + + const setOption = useCallback( + (callback: (draft: Draft) => void) => { + const newState = produce(styleState, callback); + dispatch(setState(newState)); + }, + [dispatch, styleState] + ); + + return ( + <> + + + ); +} + +export { HistogramVisOptions }; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/histogram/histogram_vis_type.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/histogram/histogram_vis_type.ts new file mode 100644 index 000000000000..2524c4c978fa --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/histogram/histogram_vis_type.ts @@ -0,0 +1,86 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { Schemas } from '../../../../../vis_default_editor/public'; +import { Positions } from '../../../../../vis_type_vislib/public'; +import { AggGroupNames } from '../../../../../data/public'; +import { BasicOptionsDefaults } from '../common/types'; +import { HistogramVisOptions } from './components/histogram_vis_options'; +import { VisualizationTypeOptions } from '../../../services/type_service'; +import { toExpression } from './to_expression'; + +export interface HistogramOptionsDefaults extends BasicOptionsDefaults { + type: 'histogram'; +} + +export const createHistogramConfig = (): VisualizationTypeOptions => ({ + name: 'histogram', + title: 'Bar', + icon: 'visBarVertical', + description: 'Display histogram visualizations', + toExpression, + ui: { + containerConfig: { + data: { + schemas: new Schemas([ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypeVislib.histogram.metricTitle', { + defaultMessage: 'Y-axis', + }), + min: 1, + max: 3, + aggFilter: ['!geo_centroid', '!geo_bounds'], + defaults: { aggTypes: ['median'] }, + }, + { + group: AggGroupNames.Buckets, + name: 'segment', + title: i18n.translate('visTypeVislib.histogram.segmentTitle', { + defaultMessage: 'X-axis', + }), + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!filters'], + defaults: { aggTypes: ['date_histogram', 'terms'] }, + }, + { + group: AggGroupNames.Buckets, + name: 'group', + title: i18n.translate('visTypeVislib.histogram.groupTitle', { + defaultMessage: 'Split series', + }), + min: 0, + max: 3, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + defaults: { aggTypes: ['terms'] }, + }, + { + group: AggGroupNames.Buckets, + name: 'split', + title: i18n.translate('visTypeVislib.histogram.splitTitle', { + defaultMessage: 'Split chart', + }), + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + defaults: { aggTypes: ['terms'] }, + }, + ]), + }, + style: { + defaults: { + addTooltip: true, + addLegend: true, + legendPosition: Positions.RIGHT, + type: 'histogram', + }, + render: HistogramVisOptions, + }, + }, + }, +}); diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/histogram/index.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/histogram/index.ts new file mode 100644 index 000000000000..bba280de2d77 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/histogram/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { createHistogramConfig } from './histogram_vis_type'; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/histogram/to_expression.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/histogram/to_expression.ts new file mode 100644 index 000000000000..eda2ed5fce8d --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/histogram/to_expression.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildVislibDimensions } from '../../../../../visualizations/public'; +import { + buildExpression, + buildExpressionFunction, + IExpressionLoaderParams, +} from '../../../../../expressions/public'; +import { HistogramOptionsDefaults } from './histogram_vis_type'; +import { getAggExpressionFunctions } from '../../common/expression_helpers'; +import { VislibRootState, getValueAxes, getPipelineParams } from '../common'; +import { createVis } from '../common/create_vis'; + +export const toExpression = async ( + { vbStyle: styleState, vbVisualization }: VislibRootState, + searchContext: IExpressionLoaderParams['searchContext'] +) => { + const { aggConfigs, expressionFns, indexPattern } = await getAggExpressionFunctions( + vbVisualization + ); + const { addLegend, addTooltip, legendPosition, type } = styleState; + + const vis = await createVis(type, aggConfigs, indexPattern, searchContext?.timeRange); + + const params = getPipelineParams(); + const dimensions = await buildVislibDimensions(vis, params); + const valueAxes = getValueAxes(dimensions.y); + + // TODO: what do we want to put in this "vis config"? + const visConfig = { + addLegend, + legendPosition, + addTimeMarker: false, + addTooltip, + dimensions, + valueAxes, + }; + + const vislib = buildExpressionFunction('vislib', { + type, + visConfig: JSON.stringify(visConfig), + }); + + return buildExpression([...expressionFns, vislib]).toString(); +}; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/index.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/index.ts new file mode 100644 index 000000000000..84dc3e346ef5 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { createHistogramConfig } from './histogram'; +export { createLineConfig } from './line'; +export { createAreaConfig } from './area'; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/line/components/line_vis_options.tsx b/src/plugins/vis_builder_new/public/visualizations/vislib/line/components/line_vis_options.tsx new file mode 100644 index 000000000000..a5bb1994c92a --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/line/components/line_vis_options.tsx @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { i18n } from '@osd/i18n'; +import produce, { Draft } from 'immer'; +import { useTypedDispatch, useTypedSelector } from '../../../../application/utils/state_management'; +import { LineOptionsDefaults } from '../line_vis_type'; +import { setState } from '../../../../application/utils/state_management/style_slice'; +import { Option } from '../../../../application/app'; +import { BasicVisOptions } from '../../common/basic_vis_options'; + +function LineVisOptions() { + const styleState = useTypedSelector((state) => state.style) as LineOptionsDefaults; + const dispatch = useTypedDispatch(); + + const setOption = useCallback( + (callback: (draft: Draft) => void) => { + const newState = produce(styleState, callback); + dispatch(setState(newState)); + }, + [dispatch, styleState] + ); + + return ( + <> + + + ); +} + +export { LineVisOptions }; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/line/index.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/line/index.ts new file mode 100644 index 000000000000..721ec7858a7a --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/line/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { createLineConfig } from './line_vis_type'; diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/line/line_vis_type.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/line/line_vis_type.ts new file mode 100644 index 000000000000..c17c443fd9a7 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/line/line_vis_type.ts @@ -0,0 +1,97 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { Schemas } from '../../../../../vis_default_editor/public'; +import { Positions } from '../../../../../vis_type_vislib/public'; +import { AggGroupNames } from '../../../../../data/public'; +import { LineVisOptions } from './components/line_vis_options'; +import { VisualizationTypeOptions } from '../../../services/type_service'; +import { toExpression } from './to_expression'; +import { BasicOptionsDefaults } from '../common/types'; + +export interface LineOptionsDefaults extends BasicOptionsDefaults { + type: 'line'; +} + +export const createLineConfig = (): VisualizationTypeOptions => ({ + name: 'line', + title: 'Line', + icon: 'visLine', + description: 'Display line chart', + toExpression, + ui: { + containerConfig: { + data: { + schemas: new Schemas([ + { + group: AggGroupNames.Metrics, + name: 'metric', + title: i18n.translate('visTypeVislib.line.metricTitle', { + defaultMessage: 'Y-axis', + }), + min: 1, + max: 3, + aggFilter: ['!geo_centroid', '!geo_bounds'], + defaults: { aggTypes: ['median'] }, + }, + { + group: AggGroupNames.Buckets, + name: 'segment', + title: i18n.translate('visTypeVislib.line.segmentTitle', { + defaultMessage: 'X-axis', + }), + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter', '!filters'], + defaults: { aggTypes: ['date_histogram', 'terms'] }, + }, + { + group: AggGroupNames.Buckets, + name: 'group', + title: i18n.translate('visTypeVislib.line.groupTitle', { + defaultMessage: 'Split series', + }), + min: 0, + max: 3, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + defaults: { aggTypes: ['terms'] }, + }, + { + group: AggGroupNames.Buckets, + name: 'split', + title: i18n.translate('visTypeVislib.line.splitTitle', { + defaultMessage: 'Split chart', + }), + min: 0, + max: 1, + aggFilter: ['!geohash_grid', '!geotile_grid', '!filter'], + defaults: { aggTypes: ['terms'] }, + }, + { + group: AggGroupNames.Metrics, + name: 'radius', + title: i18n.translate('visTypeVislib.line.radiusTitle', { + defaultMessage: 'Dot size', + }), + min: 0, + max: 1, + aggFilter: ['count', 'avg', 'sum', 'min', 'max', 'cardinality'], + defaults: { aggTypes: ['count'] }, + }, + ]), + }, + style: { + defaults: { + addTooltip: true, + addLegend: true, + legendPosition: Positions.RIGHT, + type: 'line', + }, + render: LineVisOptions, + }, + }, + }, +}); diff --git a/src/plugins/vis_builder_new/public/visualizations/vislib/line/to_expression.ts b/src/plugins/vis_builder_new/public/visualizations/vislib/line/to_expression.ts new file mode 100644 index 000000000000..55219ecc46e1 --- /dev/null +++ b/src/plugins/vis_builder_new/public/visualizations/vislib/line/to_expression.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildVislibDimensions } from '../../../../../visualizations/public'; +import { + buildExpression, + buildExpressionFunction, + IExpressionLoaderParams, +} from '../../../../../expressions/public'; +import { LineOptionsDefaults } from './line_vis_type'; +import { getAggExpressionFunctions } from '../../common/expression_helpers'; +import { VislibRootState, getValueAxes, getPipelineParams } from '../common'; +import { createVis } from '../common/create_vis'; + +export const toExpression = async ( + { vbStyle: styleState, vbVisualization }: VislibRootState, + searchContext: IExpressionLoaderParams['searchContext'] +) => { + const { aggConfigs, expressionFns, indexPattern } = await getAggExpressionFunctions( + vbVisualization + ); + const { addLegend, addTooltip, legendPosition, type } = styleState; + + const vis = await createVis(type, aggConfigs, indexPattern, searchContext?.timeRange); + + const params = getPipelineParams(); + const dimensions = await buildVislibDimensions(vis, params); + const valueAxes = getValueAxes(dimensions.y); + + // TODO: what do we want to put in this "vis config"? + const visConfig = { + addLegend, + legendPosition, + addTimeMarker: false, + addTooltip, + dimensions, + valueAxes, + }; + + const vislib = buildExpressionFunction('vislib', { + type, + visConfig: JSON.stringify(visConfig), + }); + + return buildExpression([...expressionFns, vislib]).toString(); +}; diff --git a/src/plugins/vis_builder_new/server/capabilities_provider.ts b/src/plugins/vis_builder_new/server/capabilities_provider.ts new file mode 100644 index 000000000000..54699da885e3 --- /dev/null +++ b/src/plugins/vis_builder_new/server/capabilities_provider.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const capabilitiesProvider = () => ({ + 'visualization-visbuilder-new': { + // TODO: investigate which capabilities we need to provide + // createNew: true, + // createShortUrl: true, + // delete: true, + show: true, + // showWriteControls: true, + // save: true, + // saveQuery: true, + }, +}); diff --git a/src/plugins/vis_builder_new/server/index.ts b/src/plugins/vis_builder_new/server/index.ts new file mode 100644 index 000000000000..f04ba546623f --- /dev/null +++ b/src/plugins/vis_builder_new/server/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginConfigDescriptor, PluginInitializerContext } from '../../../core/server'; +import { ConfigSchema, configSchema } from '../config'; +import { VisBuilderPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as the OpenSearch Dashboards Platform `plugin()` initializer. + +export function plugin(initializerContext: PluginInitializerContext) { + return new VisBuilderPlugin(initializerContext); +} + +export { VisBuilderPluginSetup, VisBuilderPluginStart } from './types'; + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; diff --git a/src/plugins/vis_builder_new/server/plugin.ts b/src/plugins/vis_builder_new/server/plugin.ts new file mode 100644 index 000000000000..d250c21f14ad --- /dev/null +++ b/src/plugins/vis_builder_new/server/plugin.ts @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '../../../core/server'; + +import { VisBuilderPluginSetup, VisBuilderPluginStart } from './types'; +import { capabilitiesProvider } from './capabilities_provider'; +import { visBuilderSavedObjectType } from './saved_objects'; + +export class VisBuilderPlugin implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup({ capabilities, http, savedObjects }: CoreSetup) { + this.logger.debug('vis-builder: Setup'); + + // Register saved object types + savedObjects.registerType(visBuilderSavedObjectType); + + // Register capabilities + capabilities.registerProvider(capabilitiesProvider); + + return {}; + } + + public start(_core: CoreStart) { + this.logger.debug('vis-builder: Started'); + return {}; + } + + public stop() {} +} diff --git a/src/plugins/vis_builder_new/server/saved_objects/index.ts b/src/plugins/vis_builder_new/server/saved_objects/index.ts new file mode 100644 index 000000000000..bb7fa3ffcfff --- /dev/null +++ b/src/plugins/vis_builder_new/server/saved_objects/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { visBuilderSavedObjectType } from './vis_builder_app'; diff --git a/src/plugins/vis_builder_new/server/saved_objects/vis_builder_app.ts b/src/plugins/vis_builder_new/server/saved_objects/vis_builder_app.ts new file mode 100644 index 000000000000..6a7ac522cd95 --- /dev/null +++ b/src/plugins/vis_builder_new/server/saved_objects/vis_builder_app.ts @@ -0,0 +1,62 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObject, SavedObjectsType } from '../../../../core/server'; +import { + EDIT_PATH, + PLUGIN_ID, + VisBuilderSavedObjectAttributes, + VISBUILDER_SAVED_OBJECT, +} from '../../common'; + +export const visBuilderSavedObjectType: SavedObjectsType = { + name: VISBUILDER_SAVED_OBJECT, + hidden: false, + namespaceType: 'single', + management: { + // icon: '', // TODO: Need a custom icon here - unfortunately a custom SVG won't work without changes to the SavedObjectsManagement plugin + defaultSearchField: 'title', + importableAndExportable: true, + getTitle: ({ attributes: { title } }: SavedObject) => title, + getEditUrl: ({ id }: SavedObject) => + `/management/opensearch-dashboards/objects/savedVisBuilder/${encodeURIComponent(id)}`, + getInAppUrl({ id }: SavedObject) { + return { + path: `/app/${PLUGIN_ID}${EDIT_PATH}/${encodeURIComponent(id)}`, + uiCapabilitiesPath: 'visualization-visbuilder-new.show', + }; + }, + }, + migrations: {}, + mappings: { + properties: { + title: { + type: 'text', + }, + description: { + type: 'text', + }, + visualizationState: { + type: 'text', + index: false, + }, + styleState: { + type: 'text', + index: false, + }, + uiState: { + type: 'text', + index: false, + }, + version: { type: 'integer' }, + // Need to add a kibanaSavedObjectMeta attribute here to follow the current saved object flow + // When we save a saved object, the saved object plugin will extract the search source into two parts + // Some information will be put into kibanaSavedObjectMeta while others will be created as a reference object and pushed to the reference array + kibanaSavedObjectMeta: { + properties: { searchSourceJSON: { type: 'text', index: false } }, + }, + }, + }, +}; diff --git a/src/plugins/vis_builder_new/server/types.ts b/src/plugins/vis_builder_new/server/types.ts new file mode 100644 index 000000000000..8899e4be8ac0 --- /dev/null +++ b/src/plugins/vis_builder_new/server/types.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// We need to export plugin server types, even if empty +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface VisBuilderPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface VisBuilderPluginStart {} diff --git a/src/plugins/vis_builder_new/tsconfig.json b/src/plugins/vis_builder_new/tsconfig.json new file mode 100644 index 000000000000..3af8638d980c --- /dev/null +++ b/src/plugins/vis_builder_new/tsconfig.json @@ -0,0 +1,74 @@ +{ + "compilerOptions": { + "baseUrl": "./src/plugins/vis_builder", + "paths": { + // Allows for importing from `opensearch-dashboards` package for the exported types. + "opensearch-dashboards": ["./opensearch_dashboards"], + "opensearch-dashboards/public": ["src/core/public"], + "opensearch-dashboards/server": ["src/core/server"], + "plugins/*": ["src/legacy/core_plugins/*/public/"], + "test_utils/*": [ + "src/test_utils/public/*" + ], + "fixtures/*": ["src/fixtures/*"], + "@opensearch-project/opensearch": ["node_modules/@opensearch-project/opensearch/api/new"] + }, + // Support .tsx files and transform JSX into calls to React.createElement + "jsx": "react", + // Enables all strict type checking options. + "strict": true, + // save information about the project graph on disk + "incremental": true, + // enables "core language features" + "lib": [ + "esnext", + // includes support for browser APIs + "dom" + ], + // Node 8 should support everything output by esnext, we override this + // in webpack with loader-level compiler options + "target": "esnext", + // Use commonjs for node, overridden in webpack to keep import statements + // to maintain support for things like `await import()` + "module": "commonjs", + // Allows default imports from modules with no default export. This does not affect code emit, just type checking. + // We have to enable this option explicitly since `esModuleInterop` doesn't enable it automatically when ES2015 or + // ESNext module format is used. + "allowSyntheticDefaultImports": true, + // Emits __importStar and __importDefault helpers for runtime babel ecosystem compatibility. + "esModuleInterop": true, + // Resolve modules in the same way as Node.js. Aka make `require` works the + // same in TypeScript as it does in Node.js. + "moduleResolution": "node", + // "resolveJsonModule" allows for importing, extracting types from and generating .json files. + "resolveJsonModule": true, + // Disallow inconsistently-cased references to the same file. + "forceConsistentCasingInFileNames": true, + // Forbid unused local variables as the rule was deprecated by ts-lint + "noUnusedLocals": true, + // Provide full support for iterables in for..of, spread and destructuring when targeting ES5 or ES3. + "downlevelIteration": true, + // import tslib helpers rather than inlining helpers for iteration or spreading, for instance + "importHelpers": true, + // adding global typings + "noImplicitAny": false, + "types": [ + "node", + "jest", + "react", + "flot", + "@testing-library/jest-dom", + "resize-observer-polyfill" + ] + }, + "include": [ + "public/**/*.ts", + "public/**/*.tsx", + "server/**/*.ts", + "../../../typings/**/*", + ], + "exclude": [], + "references": [ + { "path": "../../core/tsconfig.json" } + ] +}