From b1e5237da6dba7137a092143e91724162bc3eb91 Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Mon, 22 Jul 2024 12:23:15 -0700 Subject: [PATCH] Add Drag & Drop Across Axis Functionality to Vis Builder (#7107) (#7266) * Add Drag Across Axis Functionality to Vis Builder * Changeset file for PR #7107 created/updated --------- (cherry picked from commit 27669cff48cd404e480310f232a0bb0f6b988aed) Signed-off-by: Suchit Sahoo Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7107.yml | 2 + .../components/data_tab/config_panel.tsx | 45 ++++-- .../components/data_tab/constants.ts | 11 ++ .../drag_drop/add_field_to_configuration.ts | 67 +++++++++ .../drag_drop/move_field_between_schemas.ts | 63 ++++++++ .../drag_drop/reorder_fields_within_schema.ts | 40 +++++ .../replace_field_in_configuration.ts | 80 ++++++++++ .../components/data_tab/dropbox.scss | 4 + .../components/data_tab/dropbox.tsx | 133 ++++++++--------- .../components/data_tab/field.test.tsx | 9 +- .../application/components/data_tab/field.tsx | 33 ++++- .../components/data_tab/field_selector.scss | 2 - .../data_tab/field_selector.test.tsx | 9 +- .../components/data_tab/field_selector.tsx | 106 +++++++------ .../application/components/data_tab/index.tsx | 139 +++++++++++++++++- .../components/data_tab/schema_to_dropbox.tsx | 22 ++- .../components/data_tab/use/use_dropbox.tsx | 84 ++++++----- .../data_tab/utils/get_valid_aggregations.tsx | 99 +++++++++++++ .../components/draggable_accordion.scss | 34 +++++ .../components/draggable_accordion.tsx | 59 ++++++++ 20 files changed, 855 insertions(+), 186 deletions(-) create mode 100644 changelogs/fragments/7107.yml create mode 100644 src/plugins/vis_builder/public/application/components/data_tab/constants.ts create mode 100644 src/plugins/vis_builder/public/application/components/data_tab/drag_drop/add_field_to_configuration.ts create mode 100644 src/plugins/vis_builder/public/application/components/data_tab/drag_drop/move_field_between_schemas.ts create mode 100644 src/plugins/vis_builder/public/application/components/data_tab/drag_drop/reorder_fields_within_schema.ts create mode 100644 src/plugins/vis_builder/public/application/components/data_tab/drag_drop/replace_field_in_configuration.ts create mode 100644 src/plugins/vis_builder/public/application/components/data_tab/utils/get_valid_aggregations.tsx create mode 100644 src/plugins/vis_builder/public/application/components/draggable_accordion.scss create mode 100644 src/plugins/vis_builder/public/application/components/draggable_accordion.tsx diff --git a/changelogs/fragments/7107.yml b/changelogs/fragments/7107.yml new file mode 100644 index 000000000000..163246e04698 --- /dev/null +++ b/changelogs/fragments/7107.yml @@ -0,0 +1,2 @@ +feat: +- Enhance Drag & Drop functionality in Vis Builder ([#7107](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7107)) \ No newline at end of file diff --git a/src/plugins/vis_builder/public/application/components/data_tab/config_panel.tsx b/src/plugins/vis_builder/public/application/components/data_tab/config_panel.tsx index ec3b6b60a096..ae9067cd676e 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/config_panel.tsx +++ b/src/plugins/vis_builder/public/application/components/data_tab/config_panel.tsx @@ -5,22 +5,49 @@ import { EuiForm } from '@elastic/eui'; import React from 'react'; -import { useVisualizationType } from '../../utils/use'; -import { useTypedSelector } from '../../utils/state_management'; + import './config_panel.scss'; import { mapSchemaToAggPanel } from './schema_to_dropbox'; import { SecondaryPanel } from './secondary_panel'; +import { Schemas } from '../../../../../vis_default_editor/public'; +import { + AggConfig, + AggConfigs, + CreateAggConfigParams, +} from '../../../../../data/common/search/aggs'; +import { IndexPattern, TimeRange } from '../../../../../data/public'; +import { SchemaDisplayStates } from '.'; -export function ConfigPanel() { - const vizType = useVisualizationType(); - const editingState = useTypedSelector( - (state) => state.visualization.activeVisualization?.draftAgg - ); - const schemas = vizType.ui.containerConfig.data.schemas; +export interface AggProps { + indexPattern: IndexPattern | undefined; + aggConfigs: AggConfigs | undefined; + aggs: AggConfig[]; + timeRange: TimeRange; +} +export interface ConfigPanelProps { + schemas: Schemas; + editingState?: CreateAggConfigParams; + aggProps: AggProps; + activeSchemaFields: SchemaDisplayStates; + setActiveSchemaFields: React.Dispatch>; +} + +export function ConfigPanel({ + schemas, + editingState, + aggProps, + activeSchemaFields, + setActiveSchemaFields, +}: ConfigPanelProps) { if (!schemas) return null; - const mainPanel = mapSchemaToAggPanel(schemas); + const mainPanel = mapSchemaToAggPanel( + schemas, + aggProps, + activeSchemaFields, + setActiveSchemaFields + ); return ( diff --git a/src/plugins/vis_builder/public/application/components/data_tab/constants.ts b/src/plugins/vis_builder/public/application/components/data_tab/constants.ts new file mode 100644 index 000000000000..9369bd78e143 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/constants.ts @@ -0,0 +1,11 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum FIELD_SELECTOR_ID { + COUNT = 'preDefinedCountMetric', + CATEGORICAL = 'categoricalFields', + NUMERICAL = 'numericalFields', + META = 'metaFields', +} diff --git a/src/plugins/vis_builder/public/application/components/data_tab/drag_drop/add_field_to_configuration.ts b/src/plugins/vis_builder/public/application/components/data_tab/drag_drop/add_field_to_configuration.ts new file mode 100644 index 000000000000..e8df5654f46c --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/drag_drop/add_field_to_configuration.ts @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DropResult } from '@elastic/eui'; +import { AnyAction } from 'redux'; +import { createNewAggConfig } from '../utils/get_valid_aggregations'; +import { updateAggConfigParams } from '../../../utils/state_management/visualization_slice'; +import { Schemas } from '../../../../../../vis_default_editor/public'; +import { AggProps } from '../config_panel'; +import { SchemaDisplayStates } from '../index'; +import { Dispatch } from '../../../../../../opensearch_dashboards_utils/common/state_containers/types'; +import { AggsStart } from '../../../../../../data/common'; + +export interface DragDropProperties { + dropResult: DropResult; + schemas: Schemas; + aggProps: AggProps; + aggService: AggsStart; + activeSchemaFields: SchemaDisplayStates; + dispatch: Dispatch; +} + +export function addFieldToConfiguration({ + dropResult, + schemas, + aggProps, + aggService, + activeSchemaFields, + dispatch, +}: DragDropProperties) { + const { source, destination, combine, draggableId } = dropResult; + + const destinationSchemaName = destination?.droppableId; + const destinationSchema = schemas.all.find((schema) => schema.name === destinationSchemaName); + + const newFieldToAdd = draggableId; + + if (!destinationSchema || !destinationSchemaName) { + // Invalid drop target selected + return; + } + + const destinationFields = activeSchemaFields[destinationSchemaName]; + + if (!combine && destination && destinationFields.length > destinationSchema?.max) { + // Can't Add additional Fields + return; + } + + // Adding the new field + createNewAggConfig({ + fieldName: newFieldToAdd, + sourceGroup: source.droppableId, + destinationSchema, + aggProps, + aggService, + sourceAgg: null, + }); + + const updatedAggConfigs = aggProps.aggConfigs?.aggs; + + if (updatedAggConfigs) { + dispatch(updateAggConfigParams(updatedAggConfigs.map((agg) => agg.serialize()))); + } +} diff --git a/src/plugins/vis_builder/public/application/components/data_tab/drag_drop/move_field_between_schemas.ts b/src/plugins/vis_builder/public/application/components/data_tab/drag_drop/move_field_between_schemas.ts new file mode 100644 index 000000000000..6f02c2cf0f5a --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/drag_drop/move_field_between_schemas.ts @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { updateAggConfigParams } from '../../../utils/state_management/visualization_slice'; +import { createNewAggConfig } from '../utils/get_valid_aggregations'; +import { DragDropProperties } from './add_field_to_configuration'; + +export function moveFieldBetweenSchemas({ + dropResult, + schemas, + aggProps, + aggService, + activeSchemaFields, + dispatch, +}: DragDropProperties) { + const { source, destination, combine, draggableId } = dropResult; + + const destinationSchemaName = destination?.droppableId; + if (!destinationSchemaName) { + // Invalid Transition + return; + } + const sourceAggId = draggableId; + + const destinationSchema = schemas.all.find( + (schema) => schema.name === (destination?.droppableId || combine?.droppableId) + ); + + if (!destinationSchema) { + // Invalid Transition + return; + } + + const sourceAgg = aggProps.aggConfigs?.aggs.find((agg) => agg.id === sourceAggId); + const sourceFieldName = sourceAgg?.fieldName(); + + const destinationAggFields = activeSchemaFields[destinationSchemaName]; + + const destinationLimit = destinationSchema?.max; + + if (destinationLimit && destinationAggFields.length <= destinationLimit) { + // destination schema has space for more items to be added + // We Need to update sourceAgg + + createNewAggConfig({ + fieldName: sourceFieldName, + sourceGroup: source.droppableId, + destinationSchema, + aggProps, + aggService, + sourceAgg, + }); + + // Remove the sourceAggConfig from the updated Config + const updatedAggConfig = aggProps.aggConfigs?.aggs.filter((agg) => agg.id !== sourceAggId); + + if (updatedAggConfig?.length) { + dispatch(updateAggConfigParams(updatedAggConfig.map((agg) => agg.serialize()))); + } + } +} diff --git a/src/plugins/vis_builder/public/application/components/data_tab/drag_drop/reorder_fields_within_schema.ts b/src/plugins/vis_builder/public/application/components/data_tab/drag_drop/reorder_fields_within_schema.ts new file mode 100644 index 000000000000..b6d83ff07ea4 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/drag_drop/reorder_fields_within_schema.ts @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { reorderAgg } from '../../../utils/state_management/visualization_slice'; +import { DragDropProperties } from './add_field_to_configuration'; + +export function reorderFieldsWithinSchema({ + dropResult, + schemas, + activeSchemaFields, + dispatch, +}: DragDropProperties) { + const { destination, draggableId } = dropResult; + + const destinationSchemaName = destination?.droppableId; + if (!destinationSchemaName) { + // Invalid Transition + return; + } + const destinationAggFields = activeSchemaFields[destinationSchemaName]; + + const sourceAggId = draggableId; + const destinationAggId = destinationAggFields[destination?.index].id; + + const destinationSchema = schemas.all.find((schema) => schema.name === destination?.droppableId); + + if (!destinationSchema) { + // Invalid Transition + return; + } + + dispatch( + reorderAgg({ + sourceId: sourceAggId, + destinationId: destinationAggId, + }) + ); +} diff --git a/src/plugins/vis_builder/public/application/components/data_tab/drag_drop/replace_field_in_configuration.ts b/src/plugins/vis_builder/public/application/components/data_tab/drag_drop/replace_field_in_configuration.ts new file mode 100644 index 000000000000..316d9570899c --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/drag_drop/replace_field_in_configuration.ts @@ -0,0 +1,80 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { updateAggConfigParams } from '../../../utils/state_management/visualization_slice'; +import { FIELD_SELECTOR_ID } from '../constants'; +import { createNewAggConfig } from '../utils/get_valid_aggregations'; +import { DragDropProperties } from './add_field_to_configuration'; + +export function replaceFieldInConfiguration({ + dropResult, + schemas, + aggProps, + aggService, + dispatch, +}: DragDropProperties) { + const { source, combine, draggableId } = dropResult; + + const destinationSchemaName = combine?.droppableId; + if (!destinationSchemaName) { + return; + } + + const sourceAggId = draggableId; + const destinationAggId = combine?.draggableId; + + const destinationSchema = schemas.all.find((schema) => schema.name === combine?.droppableId); + + if (!destinationSchema) { + // Invalid Transition + return; + } + + const sourceSchema = source.droppableId; + + if (Object.values(FIELD_SELECTOR_ID).includes(sourceSchema as FIELD_SELECTOR_ID)) { + // Replacing an exisitng configuration with a new field from field selector panel + + const newFieldToAdd = draggableId; + createNewAggConfig({ + fieldName: newFieldToAdd, + sourceGroup: source.droppableId, + destinationSchema, + aggProps, + aggService, + sourceAgg: null, + }); + + // Removing the exisiting destination Aggregation + const updatedAggConfig = aggProps.aggConfigs?.aggs.filter((agg) => agg.id !== destinationAggId); + + if (updatedAggConfig) { + dispatch(updateAggConfigParams(updatedAggConfig.map((agg) => agg.serialize()))); + } + } else { + // Replacing an existing configuration with another exisiting configuration + + const sourceAgg = aggProps.aggConfigs?.aggs.find((agg) => agg.id === sourceAggId); + const sourceFieldName = sourceAgg?.fieldName(); + + createNewAggConfig({ + fieldName: sourceFieldName, + sourceGroup: source.droppableId, + destinationSchema, + aggProps, + aggService, + sourceAgg, + }); + + // Removing the exisiting destination and source Aggregation + const updatedAggConfig = aggProps.aggConfigs?.aggs.filter( + (agg) => agg.id !== destinationAggId && agg.id !== sourceAggId + ); + + if (updatedAggConfig) { + dispatch(updateAggConfigParams(updatedAggConfig.map((agg) => agg.serialize()))); + } + } +} diff --git a/src/plugins/vis_builder/public/application/components/data_tab/dropbox.scss b/src/plugins/vis_builder/public/application/components/data_tab/dropbox.scss index 89c7832ac40a..a92639420b1c 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/dropbox.scss +++ b/src/plugins/vis_builder/public/application/components/data_tab/dropbox.scss @@ -15,6 +15,10 @@ border-bottom: none; } + &__droppable { + min-height: 1px; + } + &__container { display: grid; grid-gap: calc($euiSizeXS / 2); diff --git a/src/plugins/vis_builder/public/application/components/data_tab/dropbox.tsx b/src/plugins/vis_builder/public/application/components/data_tab/dropbox.tsx index 70b43a2c6014..e5b46b589e0f 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/dropbox.tsx +++ b/src/plugins/vis_builder/public/application/components/data_tab/dropbox.tsx @@ -6,13 +6,11 @@ import { i18n } from '@osd/i18n'; import { EuiButtonIcon, - EuiDragDropContext, EuiDraggable, EuiDroppable, EuiFormRow, EuiPanel, EuiText, - DropResult, } from '@elastic/eui'; import React, { useCallback, useState } from 'react'; import { IDropAttributes, IDropState } from '../../utils/drag_drop'; @@ -51,7 +49,6 @@ const DropboxComponent = ({ onAddField, onDeleteField, onEditField, - onReorderField, limit = 1, isValidDropTarget, canDrop, @@ -59,17 +56,6 @@ const DropboxComponent = ({ }: DropboxProps) => { const prefersReducedMotion = usePrefersReducedMotion(); const [closing, setClosing] = useState(false); - const handleDragEnd = useCallback( - ({ source, destination }: DropResult) => { - if (!destination) return; - - onReorderField({ - sourceAggId: fields[source.index].id, - destinationAggId: fields[destination.index].id, - }); - }, - [fields, onReorderField] - ); const animateDelete = useCallback( (id: string) => { @@ -86,71 +72,72 @@ const DropboxComponent = ({ ); return ( - - -
- - {fields.map(({ id, label }, index) => ( - - - onEditField(id)}> - - {label} - - - animateDelete(id)} - data-test-subj="dropBoxRemoveBtn" - /> - - - ))} - - {fields.length < limit && ( - +
+ + {fields.map(({ id, label }, index) => ( + - - {i18n.translate('visBuilder.dropbox.addField.title', { - defaultMessage: 'Click or drop to add', - })} - - onAddField()} - data-test-subj="dropBoxAddBtn" - /> - - )} -
- - + + onEditField(id)}> + + {label} + + + animateDelete(id)} + data-test-subj="dropBoxRemoveBtn" + /> + + + ))} + + {fields.length < limit && ( + + + {i18n.translate('visBuilder.dropbox.addField.title', { + defaultMessage: 'Click or drop to add', + })} + + onAddField()} + data-test-subj="dropBoxAddBtn" + /> + + )} +
+
); }; const Dropbox = React.memo((dropBox: UseDropboxProps) => { const props = useDropbox(dropBox); - return ; }); diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field.test.tsx b/src/plugins/vis_builder/public/application/components/data_tab/field.test.tsx index 6aed9deb159e..d65608c97454 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/field.test.tsx +++ b/src/plugins/vis_builder/public/application/components/data_tab/field.test.tsx @@ -9,6 +9,7 @@ import { render, screen } from '@testing-library/react'; import { IndexPatternField } from '../../../../../data/public'; import { DraggableFieldButton } from './field'; +import { DropResult, EuiDragDropContext, EuiDroppable } from '@elastic/eui'; describe('visBuilder field', function () { describe('DraggableFieldButton', () => { @@ -28,7 +29,13 @@ describe('visBuilder field', function () { 'bytes' ), }; - render(); + render( + {}}> + + + + + ); const button = screen.getByTestId('field-bytes-showDetails'); diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field.tsx b/src/plugins/vis_builder/public/application/components/data_tab/field.tsx index ee34b4c92a37..92ce4a688c54 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/field.tsx +++ b/src/plugins/vis_builder/public/application/components/data_tab/field.tsx @@ -29,7 +29,7 @@ */ import React, { useState } from 'react'; -import { EuiPopover } from '@elastic/eui'; +import { EuiDraggable, EuiPopover } from '@elastic/eui'; import { IndexPatternField } from '../../../../../data/public'; import { @@ -46,10 +46,11 @@ import './field.scss'; export interface FieldProps { field: IndexPatternField; getDetails: (field) => FieldDetails; + id: number; } // TODO: Add field sections (Available fields, popular fields from src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx) -export const Field = ({ field, getDetails }: FieldProps) => { +export const Field = ({ field, getDetails, id }: FieldProps) => { const [infoIsOpen, setOpen] = useState(false); function togglePopover() { @@ -60,7 +61,14 @@ export const Field = ({ field, getDetails }: FieldProps) => { } + button={ + + } isOpen={infoIsOpen} closePopover={() => setOpen(false)} anchorPosition="rightUp" @@ -78,9 +86,15 @@ export const Field = ({ field, getDetails }: FieldProps) => { export interface DraggableFieldButtonProps extends Partial { dragValue?: IndexPatternField['name'] | null | typeof COUNT_FIELD; field: Partial & Pick; + index: number; } -export const DraggableFieldButton = ({ dragValue, field, ...rest }: DraggableFieldButtonProps) => { +export const DraggableFieldButton = ({ + dragValue, + field, + index, + ...rest +}: DraggableFieldButtonProps) => { const { name, displayName, type, scripted = false } = field; const [dragProps] = useDrag({ namespace: 'field-data', @@ -110,5 +124,14 @@ export const DraggableFieldButton = ({ dragValue, field, ...rest }: DraggableFie onClick: () => {}, }; - return ; + return ( + + + + ); }; diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field_selector.scss b/src/plugins/vis_builder/public/application/components/data_tab/field_selector.scss index 88cca98db86e..4bf1c21f006e 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/field_selector.scss +++ b/src/plugins/vis_builder/public/application/components/data_tab/field_selector.scss @@ -10,8 +10,6 @@ padding: $euiSizeS; &__fieldGroups { - @include euiYScrollWithShadows; - overflow-y: auto; margin-right: -$euiSizeS; padding-right: $euiSizeS; diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field_selector.test.tsx b/src/plugins/vis_builder/public/application/components/data_tab/field_selector.test.tsx index 980cfb50c666..d86e022d0cc3 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/field_selector.test.tsx +++ b/src/plugins/vis_builder/public/application/components/data_tab/field_selector.test.tsx @@ -7,6 +7,7 @@ import React from 'react'; import { fireEvent, render, screen } from '@testing-library/react'; import { FilterManager, IndexPatternField } from '../../../../../data/public'; import { FieldGroup } from './field_selector'; +import { DropResult, EuiDragDropContext, EuiDroppable } from '@elastic/eui'; const mockUseIndexPatterns = jest.fn(() => ({ selected: 'mockIndexPattern' })); const mockUseOnAddFilter = jest.fn(); @@ -68,7 +69,13 @@ describe('visBuilder sidebar field selector', function () { ...defaultProps, fields: ['bytes', 'machine.ram', 'memory', 'phpmemory'].map(getFields), }; - const { container } = render(); + const { container } = render( + {}}> + + + + + ); expect(container).toHaveTextContent(props.header); expect(container).toHaveTextContent(props.fields.length.toString()); diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field_selector.tsx b/src/plugins/vis_builder/public/application/components/data_tab/field_selector.tsx index 5c82419d5531..3d6a1120b502 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/field_selector.tsx +++ b/src/plugins/vis_builder/public/application/components/data_tab/field_selector.tsx @@ -4,7 +4,7 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { EuiFlexItem, EuiAccordion, EuiNotificationBadge, EuiTitle } from '@elastic/eui'; +import { EuiFlexItem, EuiDroppable } from '@elastic/eui'; import { IndexPattern, IndexPatternField, OSD_FIELD_TYPES } from '../../../../../data/public'; @@ -16,6 +16,8 @@ import { Field, DraggableFieldButton } from './field'; import { FieldDetails } from './types'; import { getAvailableFields, getDetails } from './utils'; import './field_selector.scss'; +import { DraggableAccordion } from '../draggable_accordion'; +import { FIELD_SELECTOR_ID } from './constants'; interface IFieldCategories { categorical: IndexPatternField[]; @@ -75,30 +77,59 @@ export const FieldSelector = () => {
{/* Count Field */} - - - - + + + + + + + + + + + +
); @@ -113,27 +144,14 @@ interface FieldGroupProps { export const FieldGroup = ({ fields, header, id, getDetailsByField }: FieldGroupProps) => { return ( - - {header} - - } - extraAction={ - - {fields?.length || 0} - - } - initialIsOpen - > - {fields?.map((field, i) => ( - - + ( + + ))} - + /> ); }; diff --git a/src/plugins/vis_builder/public/application/components/data_tab/index.tsx b/src/plugins/vis_builder/public/application/components/data_tab/index.tsx index 5f71e38141d3..8c79f680cb02 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/index.tsx +++ b/src/plugins/vis_builder/public/application/components/data_tab/index.tsx @@ -3,19 +3,148 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; +import { DropResult, EuiDragDropContext } from '@elastic/eui'; import { FieldSelector } from './field_selector'; import './index.scss'; import { ConfigPanel } from './config_panel'; +import { useAggs, useVisualizationType } from '../../utils/use'; +import { useTypedDispatch, useTypedSelector } from '../../utils/state_management'; + +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { VisBuilderServices } from '../../../types'; +import { DropboxDisplay } from './dropbox'; +import { FIELD_SELECTOR_ID } from './constants'; +import { addFieldToConfiguration } from './drag_drop/add_field_to_configuration'; +import { replaceFieldInConfiguration } from './drag_drop/replace_field_in_configuration'; +import { reorderFieldsWithinSchema } from './drag_drop/reorder_fields_within_schema'; +import { moveFieldBetweenSchemas } from './drag_drop/move_field_between_schemas'; export const DATA_TAB_ID = 'data_tab'; +export interface SchemaDisplayStates { + [key: string]: DropboxDisplay[]; +} + export const DataTab = () => { + // Config panel States + const vizType = useVisualizationType(); + const editingState = useTypedSelector( + (state) => state.visualization.activeVisualization?.draftAgg + ); + const schemas = vizType.ui.containerConfig.data.schemas; + const { + services: { + data: { + search: { aggs: aggService }, + }, + }, + } = useOpenSearchDashboards(); + + const aggProps = useAggs(); + const [activeSchemaFields, setActiveSchemaFields] = useState(() => { + return schemas.all.reduce((acc, schema) => { + acc[schema.name] = []; + return acc; + }, {}); + }); + const dispatch = useTypedDispatch(); + + useEffect(() => { + const newState = schemas.all.reduce((acc, schema) => { + acc[schema.name] = []; + return acc; + }, {}); + const updatedState = { ...newState, ...activeSchemaFields }; + setActiveSchemaFields(updatedState); + // This useEffect hook should run whenever the upstream params corresponding to schemas and vizType changes hence disabling the eslint which asks to include activeSchemaFields as dependency + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [schemas, vizType]); + + const handleDragEnd = (dropResult: DropResult) => { + try { + const { source, destination, combine } = dropResult; + + const destinationSchemaName = destination?.droppableId || combine?.droppableId; + const sourceSchemaName = source.droppableId; + + if (!sourceSchemaName || !destinationSchemaName) { + // Invalid operation should contain a source and destination + return; + } + + const panelGroups = Array.from(schemas.all.map((schema) => schema.name)); + + if (Object.values(FIELD_SELECTOR_ID).includes(sourceSchemaName as FIELD_SELECTOR_ID)) { + if (panelGroups.includes(destinationSchemaName) && !combine) { + addFieldToConfiguration({ + dropResult, + aggProps, + aggService, + activeSchemaFields, + dispatch, + schemas, + }); + } else if (panelGroups.includes(destinationSchemaName) && combine) { + replaceFieldInConfiguration({ + dropResult, + aggProps, + aggService, + activeSchemaFields, + dispatch, + schemas, + }); + } + } else if (panelGroups.includes(sourceSchemaName)) { + if (panelGroups.includes(destinationSchemaName)) { + if (sourceSchemaName === destinationSchemaName && !combine) { + reorderFieldsWithinSchema({ + dropResult, + aggProps, + aggService, + activeSchemaFields, + dispatch, + schemas, + }); + } else if (sourceSchemaName !== destinationSchemaName && !combine) { + moveFieldBetweenSchemas({ + dropResult, + aggProps, + aggService, + activeSchemaFields, + dispatch, + schemas, + }); + } else if (combine) { + replaceFieldInConfiguration({ + dropResult, + aggProps, + aggService, + activeSchemaFields, + dispatch, + schemas, + }); + } + } + } + } catch (err) { + return; + } + }; + return ( -
- - -
+ +
+ + +
+
); }; diff --git a/src/plugins/vis_builder/public/application/components/data_tab/schema_to_dropbox.tsx b/src/plugins/vis_builder/public/application/components/data_tab/schema_to_dropbox.tsx index 518a6ae7af2f..390e7fb47638 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/schema_to_dropbox.tsx +++ b/src/plugins/vis_builder/public/application/components/data_tab/schema_to_dropbox.tsx @@ -2,15 +2,31 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ - import React from 'react'; import { Schemas } from '../../../../../vis_default_editor/public'; import { Dropbox } from './dropbox'; import { Title } from './title'; +import { AggProps } from './config_panel'; +import { SchemaDisplayStates } from '.'; -export const mapSchemaToAggPanel = (schemas: Schemas) => { +export const mapSchemaToAggPanel = ( + schemas: Schemas, + aggProps: AggProps, + activeSchemaFields: SchemaDisplayStates, + setActiveSchemaFields: React.Dispatch> +) => { const panelComponents = schemas.all.map((schema) => { - return ; + return ( + + ); }); return ( diff --git a/src/plugins/vis_builder/public/application/components/data_tab/use/use_dropbox.tsx b/src/plugins/vis_builder/public/application/components/data_tab/use/use_dropbox.tsx index c41e4bc08662..d2d1be215785 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/use/use_dropbox.tsx +++ b/src/plugins/vis_builder/public/application/components/data_tab/use/use_dropbox.tsx @@ -5,7 +5,6 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { cloneDeep } from 'lodash'; -import { BucketAggType, IndexPatternField, propFilter } from '../../../../../../data/common'; import { Schema } from '../../../../../../vis_default_editor/public'; import { COUNT_FIELD, FieldDragDataType } from '../../../utils/drag_drop/types'; import { useTypedDispatch } from '../../../utils/state_management'; @@ -18,19 +17,30 @@ import { } from '../../../utils/state_management/visualization_slice'; import { useOpenSearchDashboards } from '../../../../../../opensearch_dashboards_react/public'; import { VisBuilderServices } from '../../../../types'; -import { useAggs } from '../../../utils/use'; - -const filterByName = propFilter('name'); -const filterByType = propFilter('type'); +import { getValidAggTypes } from '../utils/get_valid_aggregations'; +import { AggProps } from '../config_panel'; +import { SchemaDisplayStates } from '..'; export interface UseDropboxProps extends Pick { schema: Schema; + aggProps: AggProps; + activeSchemaFields: SchemaDisplayStates; + setActiveSchemaFields: React.Dispatch>; } export const useDropbox = (props: UseDropboxProps): DropboxProps => { - const { id: dropboxId, label, schema } = props; + const { + id: dropboxId, + label, + schema, + aggProps, + activeSchemaFields, + setActiveSchemaFields, + } = props; const [validAggTypes, setValidAggTypes] = useState([]); - const { aggConfigs, indexPattern, aggs, timeRange } = useAggs(); + const { aggConfigs, indexPattern, aggs, timeRange } = aggProps; + const fields = activeSchemaFields[schema.name]; + const dispatch = useTypedDispatch(); const { services: { @@ -60,6 +70,16 @@ export const useDropbox = (props: UseDropboxProps): DropboxProps => { [dropboxAggs, timeRange] ); + useEffect(() => { + if (displayFields && JSON.stringify(fields) !== JSON.stringify(displayFields)) { + const newDisplayState = { ...activeSchemaFields }; + newDisplayState[schema.name] = displayFields; + setActiveSchemaFields(newDisplayState); + } + // useEffect runs whenever disaplyFields changes and this in turn updates the activeSchema fields passed by parent hence disabling eslint that asks activeSchema to be included in dependecy list + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [displayFields]); + // Event handlers for each dropbox action type const onAddField = useCallback(() => { if (!aggConfigs || !indexPattern) { @@ -105,7 +125,6 @@ export const useDropbox = (props: UseDropboxProps): DropboxProps => { const onDeleteField = useCallback( (aggId: string) => { const newAggs = aggConfigs?.aggs.filter((agg) => agg.id !== aggId); - if (newAggs) { dispatch(updateAggConfigParams(newAggs.map((agg) => agg.serialize()))); } @@ -156,43 +175,25 @@ export const useDropbox = (props: UseDropboxProps): DropboxProps => { ); useEffect(() => { - const getValidAggTypes = () => { - if (!dragData || schema.group === 'none') return []; - const isCountField = dragData === COUNT_FIELD; - - const indexField = isCountField - ? { type: 'count' } - : getIndexPatternField(dragData, indexPattern?.fields ?? []); - - if (!indexField) return []; - - // Get all aggTypes allowed by the schema and get a list of all the aggTypes that the dragged index field can use - const aggTypes = aggService.types.getAll(); - // `types` can be either a Bucket or Metric aggType, but both types have the name property. - const allowedAggTypes = filterByName( - aggTypes[schema.group] as BucketAggType[], - schema.aggFilter - ); - - return ( - allowedAggTypes - .filter((aggType) => { - const allowedFieldTypes = aggType.paramByName('field')?.filterFieldTypes; - return filterByType([indexField], allowedFieldTypes).length !== 0; - }) - .filter((aggType) => (isCountField ? true : aggType.name !== 'count')) - // `types` can be either a Bucket or Metric aggType, but both types have the name property. - .map((aggType) => (aggType as BucketAggType).name) - ); - }; - - setValidAggTypes(getValidAggTypes()); + const fieldName = typeof dragData === typeof COUNT_FIELD ? '' : (dragData as string); + const sourceGroup = typeof dragData === typeof COUNT_FIELD ? 'preDefinedCountMetric' : ''; + + setValidAggTypes( + getValidAggTypes({ + fieldName, + sourceGroup, + destinationSchema: schema, + aggProps, + aggService, + sourceAgg: null, + }) + ); return () => { setValidAggTypes([]); }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [aggService.types, dragData, indexPattern?.fields, schema.aggFilter, schema.group]); - const canDrop = validAggTypes.length > 0 && schema.max > dropboxAggs.length; return { @@ -210,6 +211,3 @@ export const useDropbox = (props: UseDropboxProps): DropboxProps => { dropProps, }; }; - -const getIndexPatternField = (indexFieldName: string, availableFields: IndexPatternField[]) => - availableFields.find(({ name }) => name === indexFieldName); diff --git a/src/plugins/vis_builder/public/application/components/data_tab/utils/get_valid_aggregations.tsx b/src/plugins/vis_builder/public/application/components/data_tab/utils/get_valid_aggregations.tsx new file mode 100644 index 000000000000..e88c3099a000 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/utils/get_valid_aggregations.tsx @@ -0,0 +1,99 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AggConfig, + AggsStart, + BucketAggType, + IndexPatternField, + propFilter, +} from '../../../../../../data/common'; +import { Schema } from '../../../../../../vis_default_editor/public'; +import { AggProps } from '../config_panel'; + +export interface CreateNewAggConfig { + fieldName: string; + sourceGroup: string; + destinationSchema: Schema; + aggProps: AggProps; + aggService: AggsStart; + sourceAgg: AggConfig | null | undefined; +} + +const filterByName = propFilter('name'); +const filterByType = propFilter('type'); + +const getIndexPatternField = (indexFieldName: string, availableFields: IndexPatternField[]) => + availableFields.find(({ name }) => name === indexFieldName); + +export const getValidAggTypes = ({ + fieldName, + sourceGroup, + destinationSchema, + aggProps, + aggService, + sourceAgg, +}: CreateNewAggConfig) => { + const isCountField = + sourceGroup === 'preDefinedCountMetric' || + (sourceAgg && Object.keys(sourceAgg.params).length === 0); + + const indexField = isCountField + ? { type: 'count' } + : getIndexPatternField(fieldName, aggProps.indexPattern?.fields ?? []); + + if (!indexField) return []; + + // Get all aggTypes allowed by the schema and get a list of all the aggTypes that the dragged index field can use + const aggTypes = aggService.types.getAll(); + // `types` can be either a Bucket or Metric aggType, but both types have the name property. + const allowedAggTypes = filterByName( + aggTypes[destinationSchema.group] as BucketAggType[], + destinationSchema.aggFilter + ); + + return ( + allowedAggTypes + .filter((aggType) => { + const allowedFieldTypes = aggType.paramByName('field')?.filterFieldTypes; + return filterByType([indexField], allowedFieldTypes).length !== 0; + }) + .filter((aggType) => (isCountField ? true : aggType.name !== 'count')) + // `types` can be either a Bucket or Metric aggType, but both types have the name property. + .map((aggType) => (aggType as BucketAggType).name) + ); +}; + +export const createNewAggConfig = ({ + fieldName, + sourceGroup, + destinationSchema, + aggProps, + aggService, + sourceAgg, +}: CreateNewAggConfig) => { + const schemaAggTypes = (destinationSchema.defaults as any).aggTypes; + + const destinationValidAggType = getValidAggTypes({ + fieldName, + sourceGroup, + destinationSchema, + aggProps, + aggService, + sourceAgg, + }); + + const allowedAggTypes = schemaAggTypes + ? schemaAggTypes.filter((type: string) => destinationValidAggType.includes(type)) + : []; + + aggProps.aggConfigs?.createAggConfig({ + type: allowedAggTypes[0] || destinationValidAggType[0], + schema: destinationSchema.name, + params: { + field: fieldName, + }, + }); +}; diff --git a/src/plugins/vis_builder/public/application/components/draggable_accordion.scss b/src/plugins/vis_builder/public/application/components/draggable_accordion.scss new file mode 100644 index 000000000000..3bfcac218d9c --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/draggable_accordion.scss @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +.draggableAccordion__title { + display: inline-block; +} + +.draggableAccordion__button { + padding: $euiSizeS $euiSizeS $euiSizeS 0; + + &:hover { + text-decoration: none; + + .draggableAccordion__title { + text-decoration: underline; + } + } +} + +.draggableAccordion__badge { + justify-content: center; + align-items: center; +} + +.draggableAccordion { + border-top: $euiBorderThin; + border-bottom: $euiBorderThin; + + & + .draggableAccordion { + border-top: none; + } +} diff --git a/src/plugins/vis_builder/public/application/components/draggable_accordion.tsx b/src/plugins/vis_builder/public/application/components/draggable_accordion.tsx new file mode 100644 index 000000000000..99457800e7f7 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/draggable_accordion.tsx @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiNotificationBadge, + EuiTitle, +} from '@elastic/eui'; +import React from 'react'; +import { useState } from 'react'; +import './draggable_accordion.scss'; + +export const DraggableAccordion = ({ children, title, defaultState = true }) => { + const [isOpen, setIsOpen] = useState(defaultState); + + function handleOnClick() { + setIsOpen(!isOpen); + } + + return ( +
+ + + + + + {title} + + + + + + {children?.length || 0} + + + + + {isOpen && children} + + +
+ ); +};