From 6e1483e81805bbe30939cd481db9b7358ab97661 Mon Sep 17 00:00:00 2001 From: Suchit Sahoo Date: Wed, 19 Jun 2024 22:59:43 +0000 Subject: [PATCH] Add Drag Across Axis Functionality to Vis Builder Signed-off-by: Suchit Sahoo --- .../components/data_tab/config_panel.tsx | 13 +- .../components/data_tab/dropbox.scss | 4 + .../components/data_tab/dropbox.tsx | 132 ++++---- .../application/components/data_tab/field.tsx | 33 +- .../components/data_tab/field_selector.scss | 1 - .../components/data_tab/field_selector.tsx | 101 +++--- .../application/components/data_tab/index.tsx | 305 +++++++++++++++++- .../components/data_tab/schema_to_dropbox.tsx | 14 +- .../components/data_tab/use/use_dropbox.tsx | 21 +- .../components/draggable_accordion.scss | 34 ++ .../components/draggable_accordion.tsx | 54 ++++ 11 files changed, 567 insertions(+), 145 deletions(-) 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/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..c8fa61872b09 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,15 @@ 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'; -export function ConfigPanel() { - const vizType = useVisualizationType(); - const editingState = useTypedSelector( - (state) => state.visualization.activeVisualization?.draftAgg - ); - const schemas = vizType.ui.containerConfig.data.schemas; - +export function ConfigPanel({ schemas, editingState, aggProps, configStates }) { if (!schemas) return null; - const mainPanel = mapSchemaToAggPanel(schemas); + const mainPanel = mapSchemaToAggPanel(schemas, aggProps, configStates); return ( 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..7b00643df76b 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 @@ -15,7 +15,7 @@ import { DropResult, } from '@elastic/eui'; import React, { useCallback, useState } from 'react'; -import { IDropAttributes, IDropState } from '../../utils/drag_drop'; +import { IDropAttributes, IDropState, useDrag } from '../../utils/drag_drop'; import './dropbox.scss'; import { useDropbox } from './use'; import { UseDropboxProps } from './use/use_dropbox'; @@ -59,17 +59,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 +75,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.tsx b/src/plugins/vis_builder/public/application/components/data_tab/field.tsx index 287c6aed621c..d06f6e7ccd64 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" @@ -77,9 +85,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', @@ -109,5 +123,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..d6b928ca5af4 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,7 +10,6 @@ padding: $euiSizeS; &__fieldGroups { - @include euiYScrollWithShadows; overflow-y: auto; margin-right: -$euiSizeS; 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..45431865e170 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,7 @@ import { Field, DraggableFieldButton } from './field'; import { FieldDetails } from './types'; import { getAvailableFields, getDetails } from './utils'; import './field_selector.scss'; +import { DraggableAccordion } from '../draggable_accordion'; interface IFieldCategories { categorical: IndexPatternField[]; @@ -75,30 +76,55 @@ export const FieldSelector = () => {
{/* Count Field */} - - - - + + + + + + + + + + + +
); @@ -113,27 +139,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..65b1a740f9c9 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,314 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useState } from 'react'; +import { 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 { + reorderAgg, + updateAggConfigParams, +} from '../../utils/state_management/visualization_slice'; +import { BucketAggType, IndexPatternField, propFilter } from '../../../../../data/common'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { VisBuilderServices } from '../../../types'; export const DATA_TAB_ID = 'data_tab'; +const getIndexPatternField = (indexFieldName: string, availableFields: IndexPatternField[]) => + availableFields.find(({ name }) => name === indexFieldName); + +const filterByName = propFilter('name'); +const filterByType = propFilter('type'); + +export const getValidAggTypes = ({ + fieldName, + sourceGroup, + destinationSchema, + aggProps, + aggService, +}) => { + if (!fieldName || destinationSchema.group === 'none') return []; + const isCountField = sourceGroup === 'preDefinedCountMetric'; + + 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, + configStates, +}) => { + const schemaAggTypes = (destinationSchema.defaults as any).aggTypes; + + const [, setDestSchemaValidAggType] = configStates.get(destinationSchema.name).validAggTypes; + + const destinationValidAggType = getValidAggTypes({ + fieldName, + sourceGroup, + destinationSchema, + aggProps, + aggService, + }); + setDestSchemaValidAggType(destinationValidAggType); + + const allowedAggTypes = schemaAggTypes + ? schemaAggTypes.filter((type: string) => destinationValidAggType.includes(type)) + : []; + + aggProps.aggConfigs?.createAggConfig({ + type: allowedAggTypes[0] || destinationValidAggType[0], + schema: destinationSchema.name, + params: { + field: fieldName, + }, + }); +}; + export const DataTab = () => { + // Field Selector panel States + const fieldSelectorGroups = [ + 'preDefinedCountMetric', + 'categoricalFields', + 'numericalFields', + 'metaFields', + ]; + + // 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 configStates = new Map(); + const dispatch = useTypedDispatch(); + + schemas.all.map((schema) => { + configStates.set(schema.name, { + // eslint-disable-next-line react-hooks/rules-of-hooks + displayFields: useState([]), + // eslint-disable-next-line react-hooks/rules-of-hooks + validAggTypes: useState([]), + }); + }); + + const panelGroups = Array.from(configStates.keys()); + + const handleFieldSelectorToConfigurationPanelTransition = ({ + source, + destination, + combine, + draggableId, + }) => { + // destination Schema + const destinationSchemaName = destination?.droppableId || combine?.droppableId; + const destinationSchema = schemas.all.find((schema) => schema.name === destinationSchemaName); + const destinationFieldToCombine = combine?.draggableId; + + const newFieldToAdd = draggableId; + + if (!destinationSchema) { + // Invalid drop target selected + return; + } + + // Case 1 we are adding a new field + + createNewAggConfig({ + fieldName: newFieldToAdd, + sourceGroup: source.droppableId, + destinationSchema, + aggProps, + aggService, + configStates, + }); + + let updatedAggConfigs = aggProps.aggConfigs?.aggs; + + if (combine) { + // remove the previously selected Aggreagtion + updatedAggConfigs = updatedAggConfigs?.filter((agg) => agg.id !== destinationFieldToCombine); + } + if (updatedAggConfigs) { + dispatch(updateAggConfigParams(updatedAggConfigs.map((agg) => agg.serialize()))); + } + }; + + const handleConfigurationPanelTransition = ({ source, destination, combine, draggableId }) => { + const [sourceAggFields] = configStates.get(source?.droppableId).displayFields; + const [destinationAggFields] = configStates?.get( + destination?.droppableId || combine?.droppableId + ).displayFields; + const sourceAggId = sourceAggFields[source?.index]?.id; + const destinationAggId = destinationAggFields[destination?.index]?.id || combine?.draggableId; + + const destinationSchema = schemas.all.find( + (schema) => schema.name === (destination?.droppableId || combine?.droppableId) + ); + + const sourceAgg = aggProps.aggConfigs?.aggs.find((agg) => agg.id === sourceAggId); + const sourceFieldName = sourceAgg?.fieldName(); + + if (!combine) { + if (source?.droppableId === destination?.droppableId && source !== null) { + if (source.index === destination.index) { + // Moving the same element + return; + } else { + // Reordering of the selections within a same group + dispatch( + reorderAgg({ + sourceId: sourceAggId, + destinationId: destinationAggId, + }) + ); + } + } else if ( + source?.droppableId !== destination?.droppableId && + source !== null && + destination !== null + ) { + // Moving a element from one Dropable Box to another + + const destinationLimit = destinationSchema?.max; + + if (destinationLimit && 1 + destinationAggFields.length <= destinationLimit) { + // Case 1: Destination has space + // We Need to update sourceAgg + + createNewAggConfig({ + fieldName: sourceFieldName, + sourceGroup: source.droppableId, + destinationSchema, + aggProps, + aggService, + configStates, + }); + + // 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()))); + } + } else { + // Case 2 : Destination has no space + return; + } + } + } else if (combine !== null) { + // Combining Elements + // TODO: Do we need to restrict drag and drop features amongst the Dragables in one Droppables + + // Creating an Aggregation of the Source Field in the destination Schema + createNewAggConfig({ + fieldName: sourceFieldName, + sourceGroup: source.droppableId, + destinationSchema, + aggProps, + aggService, + configStates, + }); + + // Removing the previous destination and source AggId's + const updatedAggConfig = aggProps.aggConfigs?.aggs.filter( + (agg) => agg.id !== destinationAggId && agg.id !== sourceAggId + ); + + if (updatedAggConfig) { + dispatch(updateAggConfigParams(updatedAggConfig.map((agg) => agg.serialize()))); + } + } + }; + + const handleDragEnd = ({ source, destination, combine, draggableId }) => { + try { + const destinationSchemaName = destination?.droppableId || combine?.droppableId; + const sourceSchemaName = source.droppableId; + + if (!sourceSchemaName || !destinationSchemaName) { + // Invalid Scenario source should be present + return; + } + + // Transition from FieldSelector to Conifg panel + if (fieldSelectorGroups.includes(sourceSchemaName)) { + if (panelGroups.includes(destinationSchemaName)) { + handleFieldSelectorToConfigurationPanelTransition({ + source, + destination, + combine, + draggableId, + }); + } + } else if (panelGroups.includes(sourceSchemaName)) { + if (panelGroups.includes(destinationSchemaName)) { + handleConfigurationPanelTransition({ source, destination, combine, draggableId }); + } + } + } catch (err) { + return; + } + }; + + // What all state variables do we need + // we don't need any thing for the meta ones since we only need the field names + // For the next pane we need the same + // We need is to have a list of Meta Field + 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..2e187ae70c34 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,23 @@ * 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'; -export const mapSchemaToAggPanel = (schemas: Schemas) => { +export const mapSchemaToAggPanel = (schemas: Schemas, aggProps: any, configStates: any) => { 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..b6c64ecec97e 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 @@ -18,19 +18,22 @@ 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'); export interface UseDropboxProps extends Pick { schema: Schema; + aggProps: any; + schemaConfig: any; } export const useDropbox = (props: UseDropboxProps): DropboxProps => { - const { id: dropboxId, label, schema } = props; - const [validAggTypes, setValidAggTypes] = useState([]); - const { aggConfigs, indexPattern, aggs, timeRange } = useAggs(); + const { id: dropboxId, label, schema, aggProps, schemaConfig } = props; + const [validAggTypes, setValidAggTypes] = schemaConfig.validAggTypes; + const { aggConfigs, indexPattern, aggs, timeRange } = aggProps; + const [fields, setFields] = schemaConfig.displayFields; + const dispatch = useTypedDispatch(); const { services: { @@ -60,6 +63,13 @@ export const useDropbox = (props: UseDropboxProps): DropboxProps => { [dropboxAggs, timeRange] ); + useEffect(() => { + if (displayFields && JSON.stringify(fields) !== JSON.stringify(displayFields)) { + setFields(displayFields); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [displayFields]); + // Event handlers for each dropbox action type const onAddField = useCallback(() => { if (!aggConfigs || !indexPattern) { @@ -105,7 +115,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()))); } @@ -191,8 +200,8 @@ export const useDropbox = (props: UseDropboxProps): DropboxProps => { 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 { 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..42d066bb5d22 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/draggable_accordion.tsx @@ -0,0 +1,54 @@ +/* + * 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} + + +
+ ); +};