diff --git a/changelogs/fragments/7696.yml b/changelogs/fragments/7696.yml new file mode 100644 index 000000000000..d8c8485a0306 --- /dev/null +++ b/changelogs/fragments/7696.yml @@ -0,0 +1,2 @@ +feat: +- Refactor Associate data sources to support multi-select ([#7696](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7696)) \ No newline at end of file diff --git a/src/plugins/workspace/common/types.ts b/src/plugins/workspace/common/types.ts index cf621a09143e..4b64f62d9e29 100644 --- a/src/plugins/workspace/common/types.ts +++ b/src/plugins/workspace/common/types.ts @@ -12,3 +12,18 @@ export type DataSource = Pick< // Id defined in SavedObjectAttribute could be single or array, here only should be single string. id: string; }; + +export enum DataSourceConnectionType { + OpenSearchConnection, + DirectQueryConnection, +} + +export interface DataSourceConnection { + id: string; + type: string | undefined; + parentId?: string; + connectionType: DataSourceConnectionType; + name: string; + description?: string; + relatedConnections?: DataSourceConnection[]; +} diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx index 760c4060de58..ef055238e244 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -322,24 +322,31 @@ describe('WorkspaceCreator', () => { fireEvent.click(getByText('Select')); fireEvent.click(getByTitle(dataSourcesList[0].title)); - fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); - expect(workspaceClientCreate).toHaveBeenCalledWith( - expect.objectContaining({ - name: 'test workspace name', - }), - { - dataSources: ['id1'], - permissions: { - library_write: { - users: ['%me%'], - }, - write: { - users: ['%me%'], + waitFor(() => { + const nameInputText = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInputText, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceUseCase-observability')); + fireEvent.click(getByText(dataSourcesList[0].title)); + + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + }), + { + dataSources: ['id1'], + permissions: { + library_write: { + users: ['%me%'], + }, + write: { + users: ['%me%'], + }, }, - }, - } - ); - await waitFor(() => { + } + ); expect(notificationToastsAddSuccess).toHaveBeenCalled(); }); expect(notificationToastsAddDanger).not.toHaveBeenCalled(); diff --git a/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx b/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx index 999ac80f7ad0..2b2558f5ac73 100644 --- a/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Fragment, useEffect, useMemo, useState } from 'react'; +import { Fragment, useEffect, useMemo, useState, useCallback } from 'react'; import React from 'react'; import { EuiText, @@ -15,17 +15,43 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiSelectableOption, + EuiSpacer, + EuiButtonGroup, + EuiButtonGroupOptionProps, + EuiBadge, } from '@elastic/eui'; import { FormattedMessage } from 'react-intl'; +import { i18n } from '@osd/i18n'; import { getDataSourcesList } from '../../utils'; -import { DataSource } from '../../../common/types'; +import { DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; import { SavedObjectsStart } from '../../../../../core/public'; +const tabOptions: EuiButtonGroupOptionProps[] = [ + { + id: 'all', + label: i18n.translate('workspace.form.selectDataSource.subTitle', { + defaultMessage: 'All', + }), + }, + { + id: 'opensearch-connections', + label: i18n.translate('workspace.form.selectDataSource.subTitle', { + defaultMessage: 'OpenSearch connections', + }), + }, + { + id: 'direct-query-connections', + label: i18n.translate('workspace.form.selectDataSource.subTitle', { + defaultMessage: 'Direct query connections', + }), + }, +]; + export interface AssociationDataSourceModalProps { savedObjects: SavedObjectsStart; - assignedDataSources: DataSource[]; + assignedDataSources: DataSourceConnection[]; closeModal: () => void; - handleAssignDataSources: (dataSources: DataSource[]) => Promise; + handleAssignDataSources: (dataSources: DataSourceConnection[]) => Promise; } export const AssociationDataSourceModal = ({ @@ -34,60 +60,184 @@ export const AssociationDataSourceModal = ({ assignedDataSources, handleAssignDataSources, }: AssociationDataSourceModalProps) => { - const [options, setOptions] = useState([]); - const [allDataSources, setAllDataSources] = useState([]); + // const [options, setOptions] = useState>>([]); + // const [allOptions, setAlloptions] = useState([]); + const [allDataSources, setAllDataSources] = useState([]); + const [currentTab, setCurrentTab] = useState('all'); + const [allOptions, setAllOptions] = useState>>([]); useEffect(() => { getDataSourcesList(savedObjects.client, ['*']).then((result) => { - const filteredDataSources = result.filter( - ({ id }: DataSource) => !assignedDataSources.some((ds) => ds.id === id) - ); + const filteredDataSources: DataSourceConnection[] = result + .filter(({ id }) => !assignedDataSources.some((ds) => ds.id === id)) + .map((datasource) => { + return { + ...datasource, + name: datasource.title, + type: datasource.dataSourceEngineType, + connectionType: DataSourceConnectionType.OpenSearchConnection, + }; + }); + + // dqc + filteredDataSources.push({ + name: 's3 connection1', + description: 'this is a s3', + id: filteredDataSources[0].id + '-' + 's3 connection1', + parentId: filteredDataSources[0].id, + type: 's3', + connectionType: DataSourceConnectionType.DirectQueryConnection, + }); + filteredDataSources.push({ + name: 's3 connection2', + description: 'this is a s3', + id: filteredDataSources[0].id + '-' + 's3 connection2', + parentId: filteredDataSources[0].id, + type: 's3', + connectionType: DataSourceConnectionType.DirectQueryConnection, + }); + filteredDataSources[0].relatedConnections = [ + filteredDataSources[filteredDataSources.length - 1], + filteredDataSources[filteredDataSources.length - 2], + ]; + setAllDataSources(filteredDataSources); - setOptions( + setAllOptions( filteredDataSources.map((dataSource) => ({ - label: dataSource.title, + ...dataSource, + label: dataSource.name, key: dataSource.id, + append: dataSource.relatedConnections ? ( + + {i18n.translate('workspace.form.selectDataSource.optionBadge', { + defaultMessage: '+' + dataSource.relatedConnections.length + ' related', + })} + + ) : undefined, })) ); }); }, [assignedDataSources, savedObjects]); const selectedDataSources = useMemo(() => { - const selectedIds = options + const selectedIds = allOptions .filter((option: EuiSelectableOption) => option.checked) .map((option: EuiSelectableOption) => option.key); return allDataSources.filter((ds) => selectedIds.includes(ds.id)); - }, [options, allDataSources]); + }, [allOptions, allDataSources]); + + const options = useMemo(() => { + if (currentTab === 'all') { + return allOptions; + } + if (currentTab === 'opensearch-connections') { + return allOptions.filter( + (dataSource) => dataSource.connectionType === DataSourceConnectionType.OpenSearchConnection + ); + } + if (currentTab === 'direct-query-connections') { + return allOptions.filter( + (dataSource) => dataSource.connectionType === DataSourceConnectionType.DirectQueryConnection + ); + } + }, [allOptions, currentTab]); + + const handleSelectionChange = useCallback( + (newOptions: Array>) => { + const newCheckedOptionIds = newOptions + .filter(({ checked }) => checked === 'on') + .map(({ value }) => value); + + setAllOptions((prevOptions) => { + return prevOptions.map((option) => { + const checkedInNewOptions = newCheckedOptionIds.includes(option.value); + const connection = allDataSources.find(({ id }) => id === option.value); + option.checked = checkedInNewOptions ? 'on' : undefined; + + if (!connection) { + return option; + } + + if (connection.type === 'DS') { + const childDQCIds = allDataSources + .find(({ parentId }) => parentId === connection.id) + ?.relatedConnections?.map(({ id }) => id); + // Check if there any DQC change to checked status this time, set to "on" if exists. + if ( + newCheckedOptionIds.some( + (id) => + childDQCIds.includes(id) && + // This child DQC not checked before + !prevOptions.find(({ value, checked }) => value === id && checked === 'on') + ) + ) { + option.checked = 'on'; + } + } + + if (connection.type === 'DQC') { + const parentConnection = allDataSources.find(({ id }) => id === connection.id); + if (parentConnection) { + const isParentCheckedLastTime = prevOptions.find( + ({ value, checked }) => value === parentConnection.id && checked === 'on' + ); + const isParentCheckedThisTime = newCheckedOptionIds.includes(parentConnection.id); + + // Parent change to checked this time + if (!isParentCheckedLastTime && isParentCheckedThisTime) { + option.checked = 'on'; + } + + if (isParentCheckedLastTime && isParentCheckedThisTime) { + option.checked = undefined; + } + } + } + + return option; + }); + }); + }, + [allDataSources] + ); return ( - + -

- -

+
- + + + setCurrentTab(id)} + buttonSize="compressed" + // isFullWidth={true} + /> + setOptions(newOptions)} + onChange={handleSelectionChange} > {(list, search) => ( @@ -99,7 +249,7 @@ export const AssociationDataSourceModal = ({ - + diff --git a/src/plugins/workspace/public/components/workspace_detail/opensearch_connections_table.tsx b/src/plugins/workspace/public/components/workspace_detail/opensearch_connections_table.tsx index 278b7ffcba85..ddef955ba5c5 100644 --- a/src/plugins/workspace/public/components/workspace_detail/opensearch_connections_table.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/opensearch_connections_table.tsx @@ -18,13 +18,13 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { CoreStart, WorkspaceObject } from '../../../../../core/public'; -import { DataSource } from '../../../common/types'; +import { DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; import { WorkspaceClient } from '../../workspace_client'; import { convertPermissionSettingsToPermissions, useWorkspaceFormContext } from '../workspace_form'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; interface OpenSearchConnectionTableProps { - assignedDataSources: DataSource[]; + assignedDataSources: DataSourceConnection[]; isDashboardAdmin: boolean; currentWorkspace: WorkspaceObject; setIsLoading: React.Dispatch>; @@ -41,32 +41,32 @@ export const OpenSearchConnectionTable = ({ } = useOpenSearchDashboards<{ CoreStart: CoreStart; workspaceClient: WorkspaceClient }>(); const { formData, setSelectedDataSources } = useWorkspaceFormContext(); const [searchTerm, setSearchTerm] = useState(''); - const [selectedItems, setSelectedItems] = useState([]); + const [selectedItems, setSelectedItems] = useState([]); const [modalVisible, setModalVisible] = useState(false); const filteredDataSources = useMemo( () => assignedDataSources.filter((dataSource) => - dataSource.title.toLowerCase().includes(searchTerm.toLowerCase()) + dataSource.name.toLowerCase().includes(searchTerm.toLowerCase()) ), [searchTerm, assignedDataSources] ); - const onSelectionChange = (selectedDataSources: DataSource[]) => { + const onSelectionChange = (selectedDataSources: DataSourceConnection[]) => { setSelectedItems(selectedDataSources); }; - const handleUnassignDataSources = async (dataSources: DataSource[]) => { + const handleUnassignDataSources = async (dataSources: DataSourceConnection[]) => { try { setIsLoading(true); setModalVisible(false); const { permissionSettings, selectedDataSources, useCase, ...attributes } = formData; const savedDataSources = (selectedDataSources ?? [])?.filter( - ({ id }: DataSource) => !dataSources.some((item) => item.id === id) + ({ id }: DataSourceConnection) => !dataSources.some((item) => item.id === id) ); const result = await workspaceClient.update(currentWorkspace.id, attributes, { - dataSources: savedDataSources.map(({ id }: DataSource) => id), + dataSources: savedDataSources.map(({ id }: DataSourceConnection) => id), permissions: convertPermissionSettingsToPermissions(permissionSettings), }); if (result?.success) { @@ -93,7 +93,7 @@ export const OpenSearchConnectionTable = ({ } }; - const columns: Array> = [ + const columns: Array> = [ { field: 'title', name: i18n.translate('workspace.detail.dataSources.table.title', { @@ -135,7 +135,7 @@ export const OpenSearchConnectionTable = ({ ), icon: 'unlink', type: 'icon', - onClick: (item: DataSource) => { + onClick: (item: DataSourceConnection) => { setSelectedItems([item]); setModalVisible(true); }, @@ -147,7 +147,7 @@ export const OpenSearchConnectionTable = ({ : []), ]; - const selection: EuiTableSelectionType = { + const selection: EuiTableSelectionType = { selectable: () => isDashboardAdmin, onSelectionChange, }; diff --git a/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx index 6a81482096ec..943201c41e14 100644 --- a/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx @@ -141,7 +141,7 @@ describe('WorkspaceDetail', () => { getByText('Add OpenSearch connections that will be available in the workspace.') ).toBeInTheDocument(); expect(getByText('Close')).toBeInTheDocument(); - expect(getByText('Save changes')).toBeInTheDocument(); + expect(getByText('Associate data sources')).toBeInTheDocument(); expect(getByText('ds-2-title')).toBeInTheDocument(); }); fireEvent.click(getByText('Close')); @@ -171,11 +171,11 @@ describe('WorkspaceDetail', () => { getByText('Add OpenSearch connections that will be available in the workspace.') ).toBeInTheDocument(); expect(getByText('Close')).toBeInTheDocument(); - expect(getByText('Save changes')).toBeInTheDocument(); + expect(getByText('Associate data sources')).toBeInTheDocument(); expect(getByText('ds-2-title')).toBeInTheDocument(); }); fireEvent.click(getByText('ds-2-title')); - fireEvent.click(getByText('Save changes')); + fireEvent.click(getByText('Associate data sources')); await waitFor(() => { expect(notificationToastsAddSuccess).toHaveBeenCalled(); }); @@ -205,11 +205,11 @@ describe('WorkspaceDetail', () => { getByText('Add OpenSearch connections that will be available in the workspace.') ).toBeInTheDocument(); expect(getByText('Close')).toBeInTheDocument(); - expect(getByText('Save changes')).toBeInTheDocument(); + expect(getByText('Associate data sources')).toBeInTheDocument(); expect(getByText('ds-2-title')).toBeInTheDocument(); }); fireEvent.click(getByText('ds-2-title')); - fireEvent.click(getByText('Save changes')); + fireEvent.click(getByText('Associate data sources')); await waitFor(() => { expect(notificationToastsAddDanger).toHaveBeenCalled(); }); diff --git a/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx index db4c06fa75f4..a415cd8d0b3e 100644 --- a/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx @@ -18,7 +18,7 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from 'react-intl'; -import { DataSource } from '../../../common/types'; +import { DataSourceConnection } from '../../../common/types'; import { WorkspaceClient } from '../../workspace_client'; import { OpenSearchConnectionTable } from './opensearch_connections_table'; import { AssociationDataSourceModal } from './association_data_source_modal'; @@ -28,7 +28,7 @@ import { convertPermissionSettingsToPermissions, useWorkspaceFormContext } from export interface SelectDataSourcePanelProps { savedObjects: SavedObjectsStart; - assignedDataSources: DataSource[]; + assignedDataSources: DataSourceConnection[]; detailTitle: string; isDashboardAdmin: boolean; currentWorkspace: WorkspaceObject; @@ -48,12 +48,12 @@ export const SelectDataSourceDetailPanel = ({ const [isLoading, setIsLoading] = useState(false); const [isVisible, setIsVisible] = useState(false); - const handleAssignDataSources = async (dataSources: DataSource[]) => { + const handleAssignDataSources = async (dataSources: DataSourceConnection[]) => { try { setIsLoading(true); setIsVisible(false); const { permissionSettings, selectedDataSources, useCase, ...attributes } = formData; - const savedDataSources: DataSource[] = [...selectedDataSources, ...dataSources]; + const savedDataSources: DataSourceConnection[] = [...selectedDataSources, ...dataSources]; const result = await workspaceClient.update(currentWorkspace.id, attributes, { dataSources: savedDataSources.map((ds) => { diff --git a/src/plugins/workspace/public/components/workspace_form/constants.ts b/src/plugins/workspace/public/components/workspace_form/constants.ts index 1224d5abb0ec..6c894ebfa97e 100644 --- a/src/plugins/workspace/public/components/workspace_form/constants.ts +++ b/src/plugins/workspace/public/components/workspace_form/constants.ts @@ -42,7 +42,7 @@ export const workspaceUseCaseTitle = i18n.translate('workspace.form.workspaceUse }); export const selectDataSourceTitle = i18n.translate('workspace.form.selectDataSource.title', { - defaultMessage: 'Associate data source', + defaultMessage: 'Associate data sources', }); export const usersAndPermissionsTitle = i18n.translate('workspace.form.usersAndPermissions.title', { diff --git a/src/plugins/workspace/public/components/workspace_form/createpage_opensearch_connections_table.tsx b/src/plugins/workspace/public/components/workspace_form/createpage_opensearch_connections_table.tsx new file mode 100644 index 000000000000..80a4496fb30f --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/createpage_opensearch_connections_table.tsx @@ -0,0 +1,149 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Dispatch, SetStateAction, useState } from 'react'; +import { + EuiSpacer, + EuiFlexItem, + EuiFlexGroup, + EuiInMemoryTable, + EuiBasicTableColumn, + EuiTableSelectionType, + EuiTableActionsColumnType, + EuiSmallButton, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { DataSourceConnection } from '../../../common/types'; + +interface OpenSearchConnectionTableProps { + assignedDataSources: DataSourceConnection[]; + isDashboardAdmin: boolean; + setAssignedDataSources: (value: DataSourceConnection[]) => void; + setModalVisible: Dispatch>; +} + +export const CreatePageOpenSearchConnectionTable = ({ + assignedDataSources, + setAssignedDataSources, + isDashboardAdmin, + setModalVisible, +}: OpenSearchConnectionTableProps) => { + const [selectedItems, setSelectedItems] = useState([]); + + const onSelectionChange = (currentSelectedItems: DataSourceConnection[]) => { + setSelectedItems(currentSelectedItems); + }; + + const handleUnassignDataSources = (dataSources: DataSourceConnection[]) => { + const savedDataSources = (assignedDataSources ?? [])?.filter( + ({ id }: DataSourceConnection) => !dataSources.some((item) => item.id === id) + ); + setAssignedDataSources(savedDataSources); + setSelectedItems(savedDataSources); + }; + + const columns: Array> = [ + { + field: 'title', + name: i18n.translate('workspace.creator.dataSources.table.title', { + defaultMessage: 'Title', + }), + truncateText: true, + }, + { + field: 'dataSourceEngineType', + name: i18n.translate('workspace.creator.dataSources.table.type', { + defaultMessage: 'Type', + }), + truncateText: true, + }, + { + field: 'description', + name: i18n.translate('workspace.creator.dataSources.table.description', { + defaultMessage: 'Description', + }), + truncateText: true, + }, + ...(isDashboardAdmin + ? [ + { + name: i18n.translate('workspace.creator.dataSources.table.actions', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: i18n.translate('workspace.creator.dataSources.table.actions.remove.name', { + defaultMessage: 'Remove association', + }), + isPrimary: true, + description: i18n.translate( + 'workspace.creator.dataSources.table.actions.remove.description', + { + defaultMessage: 'Remove association', + } + ), + icon: 'unlink', + type: 'icon', + onClick: (item: DataSourceConnection) => { + handleUnassignDataSources([item]); + }, + 'data-test-subj': 'workspace-creator-dataSources-table-actions-remove', + }, + ], + } as EuiTableActionsColumnType, + ] + : []), + ]; + + const selection: EuiTableSelectionType = { + selectable: () => isDashboardAdmin, + onSelectionChange, + }; + + const associationButton = ( + setModalVisible(true)} + data-test-subj="workspace-creator-dataSources-assign-button" + > + {i18n.translate('workspace.form.selectDataSourcePanel.addNew', { + defaultMessage: 'Add data sources', + })} + + ); + + const removeButton = ( + { + handleUnassignDataSources(selectedItems); + }} + data-test-subj="workspace-creator-dataSources-assign-button" + > + {i18n.translate('workspace.form.selectDataSourcePanel.remove', { + defaultMessage: 'Remove selected', + })} + + ); + + return ( + <> + + {selectedItems.length > 0 && {removeButton}} + {isDashboardAdmin && {associationButton}} + + + {assignedDataSources.length > 0 && ( + + )} + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.test.tsx b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.test.tsx index 2890333f1268..c919995a4e90 100644 --- a/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.test.tsx +++ b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.test.tsx @@ -4,7 +4,7 @@ */ import React from 'react'; -import { fireEvent, render, act } from '@testing-library/react'; +import { fireEvent, render, waitFor, screen } from '@testing-library/react'; import { SelectDataSourcePanel, SelectDataSourcePanelProps } from './select_data_source_panel'; import { coreMock } from '../../../../../core/public/mocks'; @@ -24,66 +24,111 @@ const mockCoreStart = coreMock.createStart(); const setup = ({ savedObjects = mockCoreStart.savedObjects, - selectedDataSources = [], + assignedDataSources = [], onChange = jest.fn(), errors = undefined, + isDashboardAdmin = true, }: Partial) => { return render( ); }; describe('SelectDataSourcePanel', () => { - it('should render consistent data sources when selected data sources passed', () => { - const { getByText } = setup({ selectedDataSources: dataSources }); + const originalOffsetHeight = Object.getOwnPropertyDescriptor( + HTMLElement.prototype, + 'offsetHeight' + ); + const originalOffsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth'); + beforeEach(() => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 600, + }); + }); - expect(getByText(dataSources[0].title)).toBeInTheDocument(); - expect(getByText(dataSources[1].title)).toBeInTheDocument(); + afterEach(() => { + Object.defineProperty( + HTMLElement.prototype, + 'offsetHeight', + originalOffsetHeight as PropertyDescriptor + ); + Object.defineProperty( + HTMLElement.prototype, + 'offsetWidth', + originalOffsetWidth as PropertyDescriptor + ); }); - it('should call onChange when clicking add new data source button', () => { - const onChangeMock = jest.fn(); - const { getByTestId } = setup({ onChange: onChangeMock }); + it('should click on Add data sources button', async () => { + const { getByText } = setup({}); + expect(getByText('Add data sources')).toBeInTheDocument(); - expect(onChangeMock).not.toHaveBeenCalled(); - fireEvent.click(getByTestId('workspaceForm-select-dataSource-addNew')); - expect(onChangeMock).toHaveBeenCalledWith([ - { - id: '', - title: '', - }, - ]); + fireEvent.click(getByText('Add data sources')); + await waitFor(() => { + expect( + getByText('Add OpenSearch connections that will be available in the workspace.') + ).toBeInTheDocument(); + expect(getByText('Close')).toBeInTheDocument(); + expect(getByText('Associate data sources')).toBeInTheDocument(); + expect(getByText(dataSources[0].title)).toBeInTheDocument(); + }); + fireEvent.click(getByText('Close')); }); - it('should call onChange when updating selected data sources in combo box', async () => { + it('should render consistent data sources when assigned data sources passed', async () => { + const { getByText } = setup({ assignedDataSources: [] }); + fireEvent.click(getByText('Add data sources')); + await waitFor(() => { + expect(getByText(dataSources[0].title)).toBeInTheDocument(); + expect(getByText(dataSources[1].title)).toBeInTheDocument(); + }); + }); + + it('should call onChange when updating assigned data sources', async () => { const onChangeMock = jest.fn(); - const { getByTitle, getByText } = setup({ + const { getByText } = await setup({ onChange: onChangeMock, - selectedDataSources: [{ id: '', title: '' }], + assignedDataSources: [], }); expect(onChangeMock).not.toHaveBeenCalled(); - await act(() => { - fireEvent.click(getByText('Select')); + fireEvent.click(getByText('Add data sources')); + await waitFor(() => { + expect(getByText(dataSources[0].title)).toBeInTheDocument(); }); - fireEvent.click(getByTitle(dataSources[0].title)); + fireEvent.click(getByText(dataSources[0].title)); + fireEvent.click(getByText('Associate data sources')); expect(onChangeMock).toHaveBeenCalledWith([{ id: 'id1', title: 'title1' }]); }); - it('should call onChange when deleting selected data source', async () => { + it('should call onChange when remove assigned data sources', async () => { const onChangeMock = jest.fn(); - const { getByLabelText } = setup({ + const { getByText, getAllByTestId } = await setup({ onChange: onChangeMock, - selectedDataSources: [{ id: '', title: '' }], + assignedDataSources: dataSources, }); expect(onChangeMock).not.toHaveBeenCalled(); - await act(() => { - fireEvent.click(getByLabelText('Delete data source')); - }); + + // Remove by unlink icon + const button = getAllByTestId('workspace-creator-dataSources-table-actions-remove')[0]; + fireEvent.click(button); + expect(onChangeMock).toHaveBeenCalledWith([{ id: 'id2', title: 'title2' }]); + + // Remove by clicking the checkbox and remove button + const checkbox = screen.getAllByRole('checkbox')[0]; + fireEvent.click(checkbox); + expect(getByText('Remove selected')).toBeInTheDocument(); + fireEvent.click(getByText('Remove selected')); expect(onChangeMock).toHaveBeenCalledWith([]); }); }); diff --git a/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx index 9c990d7e7195..5a0bb03c4ebd 100644 --- a/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx @@ -3,149 +3,68 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useEffect, useState } from 'react'; -import { - EuiSmallButton, - EuiCompressedFormRow, - EuiSpacer, - EuiFlexGroup, - EuiFlexItem, - EuiButtonIcon, - EuiCompressedComboBox, - EuiComboBoxOptionOption, - EuiFormLabel, -} from '@elastic/eui'; +import React, { useState } from 'react'; +import { EuiSpacer, EuiFormLabel, EuiText, EuiFlexItem } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { SavedObjectsStart } from '../../../../../core/public'; -import { getDataSourcesList } from '../../utils'; -import { DataSource } from '../../../common/types'; +import { DataSourceConnection } from '../../../common/types'; import { WorkspaceFormError } from './types'; +import { AssociationDataSourceModal } from '../workspace_detail/association_data_source_modal'; +import { CreatePageOpenSearchConnectionTable } from './createpage_opensearch_connections_table'; export interface SelectDataSourcePanelProps { errors?: { [key: number]: WorkspaceFormError }; savedObjects: SavedObjectsStart; - selectedDataSources: DataSource[]; - onChange: (value: DataSource[]) => void; + assignedDataSources: DataSourceConnection[]; + onChange: (value: DataSourceConnection[]) => void; + isDashboardAdmin: boolean; } export const SelectDataSourcePanel = ({ errors, onChange, - selectedDataSources, + assignedDataSources, savedObjects, + isDashboardAdmin, }: SelectDataSourcePanelProps) => { - const [dataSourcesOptions, setDataSourcesOptions] = useState([]); - useEffect(() => { - if (!savedObjects) return; - getDataSourcesList(savedObjects.client, ['*']).then((result) => { - const options = result.map(({ title, id }) => ({ - label: title, - value: id, - })); - setDataSourcesOptions(options); - }); - }, [savedObjects, setDataSourcesOptions]); - const handleAddNewOne = useCallback(() => { - onChange?.([ - ...selectedDataSources, - { - title: '', - id: '', - }, - ]); - }, [onChange, selectedDataSources]); + const [modalVisible, setModalVisible] = useState(false); - const handleSelect = useCallback( - (selectedOptions, index) => { - const newOption = selectedOptions[0] - ? // Select new data source - { - title: selectedOptions[0].label, - id: selectedOptions[0].value, - } - : // Click reset button - { - title: '', - id: '', - }; - const newSelectedOptions = [...selectedDataSources]; - newSelectedOptions.splice(index, 1, newOption); + const handleAssignDataSources = async (dataSources: DataSourceConnection[]) => { + setModalVisible(false); + const savedDataSources: DataSourceConnection[] = [...assignedDataSources, ...dataSources]; + onChange(savedDataSources); + }; - onChange(newSelectedOptions); - }, - [onChange, selectedDataSources] - ); - - const handleDelete = useCallback( - (index) => { - const newSelectedOptions = [...selectedDataSources]; - newSelectedOptions.splice(index, 1); - - onChange(newSelectedOptions); - }, - [onChange, selectedDataSources] - ); + const renderTableContent = () => { + return ( + + ); + }; return (
- {i18n.translate('workspace.form.selectDataSource.subTitle', { - defaultMessage: 'Data source', - })} + + {i18n.translate('workspace.form.selectDataSource.subTitle', { + defaultMessage: 'Add data sources that will be available in the workspace', + })} + - - {selectedDataSources.map(({ id, title }, index) => ( - - - - handleSelect(selectedOptions, index)} - placeholder="Select" - /> - - - handleDelete(index)} - isDisabled={false} - /> - - - - ))} - - - {i18n.translate('workspace.form.selectDataSourcePanel.addNew', { - defaultMessage: 'Add New', - })} - + + {renderTableContent()} + {modalVisible && ( + setModalVisible(false)} + handleAssignDataSources={handleAssignDataSources} + /> + )}
); }; diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts index 5ec929e17b0c..a9a7df9005d4 100644 --- a/src/plugins/workspace/public/components/workspace_form/types.ts +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -6,7 +6,7 @@ import type { ApplicationStart, SavedObjectsStart } from '../../../../../core/public'; import type { WorkspacePermissionMode } from '../../../common/constants'; import type { DetailTab, WorkspaceOperationType, WorkspacePermissionItemType } from './constants'; -import { DataSource } from '../../../common/types'; +import { DataSourceConnection } from '../../../common/types'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; import { WorkspaceUseCase } from '../../types'; @@ -34,7 +34,7 @@ export interface WorkspaceFormSubmitData { features?: string[]; color?: string; permissionSettings?: WorkspacePermissionSetting[]; - selectedDataSources?: DataSource[]; + selectedDataSources?: DataSourceConnection[]; } export interface WorkspaceFormData extends WorkspaceFormSubmitData { diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts index fb8bf40e497c..12dee9792652 100644 --- a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts @@ -12,7 +12,7 @@ import { getUseCaseFeatureConfig, isUseCaseFeatureConfig, } from '../../utils'; -import { DataSource } from '../../../common/types'; +import { DataSourceConnection } from '../../../common/types'; import { WorkspaceFormProps, WorkspaceFormErrors, WorkspacePermissionSetting } from './types'; import { appendDefaultFeatureIds, @@ -52,7 +52,7 @@ export const useWorkspaceForm = ({ Array & Partial> >(initialPermissionSettingsRef.current); - const [selectedDataSources, setSelectedDataSources] = useState( + const [selectedDataSources, setSelectedDataSources] = useState( defaultValues?.selectedDataSources && defaultValues.selectedDataSources.length > 0 ? defaultValues.selectedDataSources : [] diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.test.tsx index 66279a68a1b6..2acd64f9b1b8 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.test.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.test.tsx @@ -53,19 +53,19 @@ describe('WorkspaceForm', () => { it('should enable data source panel for dashboard admin and when data source is enabled', () => { const { getByText } = setup(true, mockDataSourceManagementSetup); - expect(getByText('Associate data source')).toBeInTheDocument(); + expect(getByText('Associate data sources')).toBeInTheDocument(); }); it('should not display data source panel for non dashboard admin', () => { const { queryByText } = setup(false, mockDataSourceManagementSetup); - expect(queryByText('Associate data source')).not.toBeInTheDocument(); + expect(queryByText('Associate data sources')).not.toBeInTheDocument(); }); it('should not display data source panel when data source is disabled', () => { const { queryByText } = setup(true, undefined); - expect(queryByText('Associate data source')).not.toBeInTheDocument(); + expect(queryByText('Associate data sources')).not.toBeInTheDocument(); }); it('should automatic update workspace name after use case changed', () => { diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx index e33005ec81eb..bad7b1486168 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -48,6 +48,7 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { const disabledUserOrGroupInputIdsRef = useRef( defaultValues?.permissionSettings?.map((item) => item.id) ?? [] ); + const isDashboardAdmin = application?.capabilities?.dashboards?.isDashboardAdmin ?? false; const handleNameInputChange = useCallback( (newName) => { @@ -140,8 +141,9 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { errors={formErrors.selectedDataSources} onChange={setSelectedDataSources} savedObjects={savedObjects} - selectedDataSources={formData.selectedDataSources} data-test-subj={`workspaceForm-dataSourcePanel`} + isDashboardAdmin={isDashboardAdmin} + assignedDataSources={formData.selectedDataSources} /> )} diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx index 417921f170d3..6c034a039e29 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx @@ -5,7 +5,7 @@ import React, { createContext, useContext, FormEventHandler, ReactNode } from 'react'; import { EuiColorPickerOutput } from '@elastic/eui/src/components/color_picker/color_picker'; -import { DataSource } from '../../../common/types'; +import { DataSourceConnection } from '../../../common/types'; import { WorkspaceFormProps, WorkspaceFormErrors, WorkspacePermissionSetting } from './types'; import { PublicAppInfo } from '../../../../../core/public'; import { useWorkspaceForm } from './use_workspace_form'; @@ -30,7 +30,7 @@ interface WorkspaceFormContextProps { Array & Partial> > >; - setSelectedDataSources: React.Dispatch>; + setSelectedDataSources: React.Dispatch>; } const initialContextValue: WorkspaceFormContextProps = {} as WorkspaceFormContextProps;