diff --git a/changelogs/fragments/7961.yml b/changelogs/fragments/7961.yml new file mode 100644 index 000000000000..bdc020962e51 --- /dev/null +++ b/changelogs/fragments/7961.yml @@ -0,0 +1,2 @@ +feat: +- Support DQCs in create page ([#7961](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7961)) \ No newline at end of file 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 5ca958ced23e..4efbc30680ce 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 @@ -15,6 +15,9 @@ import { WorkspaceCreator as WorkspaceCreatorComponent, WorkspaceCreatorProps, } from './workspace_creator'; +import { DataSourceEngineType } from '../../../../data_source/common/data_sources'; +import { DataSourceConnectionType } from '../../../common/types'; +import * as utils from '../../utils'; const workspaceClientCreate = jest .fn() @@ -32,21 +35,50 @@ const dataSourcesList = [ { id: 'id1', title: 'ds1', + description: 'Description of data source 1', + auth: '', + dataSourceEngineType: '' as DataSourceEngineType, + workspaces: [], // This is used for mocking saved object function get: () => { return 'ds1'; }, }, { - id: '2', + id: 'id2', title: 'ds2', + description: 'Description of data source 1', + auth: '', + dataSourceEngineType: '' as DataSourceEngineType, + workspaces: [], get: () => { return 'ds2'; }, }, ]; +const dataSourceConnectionsList = [ + { + id: 'id1', + name: 'ds1', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', + relatedConnections: [], + }, + { + id: 'id2', + name: 'ds2', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', + }, +]; + const mockCoreStart = coreMock.createStart(); +jest.spyOn(utils, 'fetchDataSourceConnections').mockImplementation(async (passedDataSources) => { + return dataSourceConnectionsList.filter(({ id }) => + passedDataSources.some((dataSource) => dataSource.id === id) + ); +}); const WorkspaceCreator = ({ isDashboardAdmin = false, @@ -304,7 +336,15 @@ describe('WorkspaceCreator', () => { }); it('create workspace with customized selected dataSources', async () => { - const { getByTestId, getByTitle, getByText } = render( + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 600, + }); + const { getByTestId, getAllByText, getByText } = render( ); @@ -317,10 +357,17 @@ describe('WorkspaceCreator', () => { target: { value: 'test workspace name' }, }); fireEvent.click(getByTestId('workspaceUseCase-observability')); - fireEvent.click(getByTestId('workspaceForm-select-dataSource-addNew')); - fireEvent.click(getByTestId('workspaceForm-select-dataSource-comboBox')); - fireEvent.click(getByText('Select')); - fireEvent.click(getByTitle(dataSourcesList[0].title)); + fireEvent.click(getByTestId('workspace-creator-dataSources-assign-button')); + await waitFor(() => { + expect( + getByText( + 'Add data sources that will be available in the workspace. If a selected data source has related Direct Query connection, they will also be available in the workspace.' + ) + ).toBeInTheDocument(); + expect(getByText(dataSourcesList[0].title)).toBeInTheDocument(); + }); + fireEvent.click(getByText(dataSourcesList[0].title)); + fireEvent.click(getAllByText('Associate data sources')[1]); fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); expect(workspaceClientCreate).toHaveBeenCalledWith( diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index b8f32eb4a33e..ed4370a7b3f5 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -15,12 +15,12 @@ import { getUseCaseFeatureConfig } from '../../../common/utils'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { WorkspaceClient } from '../../workspace_client'; import { convertPermissionSettingsToPermissions } from '../workspace_form'; -import { DataSource } from '../../../common/types'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; import { WorkspaceUseCase } from '../../types'; import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; import { useFormAvailableUseCases } from '../workspace_form/use_form_available_use_cases'; import { NavigationPublicPluginStart } from '../../../../../plugins/navigation/public'; +import { DataSourceConnectionType } from '../../../common/types'; import { WorkspaceCreatorForm } from './workspace_creator_form'; export interface WorkspaceCreatorProps { @@ -72,10 +72,14 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { } setIsFormSubmitting(true); try { - const { permissionSettings, selectedDataSources, ...attributes } = data; - const selectedDataSourceIds = (selectedDataSources ?? []).map((ds: DataSource) => { - return ds.id; - }); + const { permissionSettings, selectedDataSourceConnections, ...attributes } = data; + const selectedDataSourceIds = (selectedDataSourceConnections ?? []) + .filter( + ({ connectionType }) => connectionType === DataSourceConnectionType.OpenSearchConnection + ) + .map(({ id }) => { + return id; + }); result = await workspaceClient.create(attributes, { dataSources: selectedDataSourceIds, permissions: convertPermissionSettingsToPermissions(permissionSettings), diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator_form.tsx index 9ab0a35e722b..4a99fc006524 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator_form.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator_form.tsx @@ -58,7 +58,7 @@ export const WorkspaceCreatorForm = (props: WorkspaceCreatorFormProps) => { handleColorChange, handleUseCaseChange: handleUseCaseChangeInHook, setPermissionSettings, - setSelectedDataSources, + setSelectedDataSourceConnections, } = useWorkspaceForm(props); const nameManualChangedRef = useRef(false); @@ -86,7 +86,7 @@ export const WorkspaceCreatorForm = (props: WorkspaceCreatorFormProps) => { return ( - + { })} diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.test.tsx index 9ce350af85e2..cfe9e5833631 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form_summary_panel.test.tsx @@ -20,10 +20,10 @@ describe('WorkspaceFormSummaryPanel', () => { name: 'Test Workspace', description: 'This is a test workspace', color: '#000000', - selectedDataSources: [ - { id: 'data-source-1', title: 'Data Source 1' }, - { id: 'data-source-2', title: 'Data Source 2' }, - { id: 'data-source-3', title: 'Data Source 3' }, + selectedDataSourceConnections: [ + { id: 'data-source-1', name: 'Data Source 1' }, + { id: 'data-source-2', name: 'Data Source 2' }, + { id: 'data-source-3', name: 'Data Source 3' }, ], permissionSettings: [ { id: 1, type: WorkspacePermissionItemType.User, userId: 'user1' }, @@ -74,7 +74,7 @@ describe('WorkspaceFormSummaryPanel', () => { - {formData.selectedDataSources.length > 0 && ( + {formData.selectedDataSourceConnections.length > 0 && ( title)} + texts={formData.selectedDataSourceConnections.map(({ name }) => name)} collapseDisplayCount={2} /> )} 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 1329bef04beb..1c15eac21193 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 @@ -8,10 +8,13 @@ import React from 'react'; import { coreMock } from '../../../../../core/public/mocks'; import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; import { WorkspaceFormProvider, WorkspaceOperationType } from '../workspace_form'; -import { SelectDataSourceDetailPanel } from './select_data_source_panel'; +import { + SelectDataSourceDetailPanel, + SelectDataSourceDetailPanelProps, +} from './select_data_source_panel'; import * as utils from '../../utils'; import { IntlProvider } from 'react-intl'; -import { DataSourceConnectionType } from '../../../common/types'; +import { DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; const mockCoreStart = coreMock.createStart(); @@ -81,8 +84,7 @@ const defaultValues = { }; const defaultProps = { - savedObjects: {}, - assignedDataSources: [], + savedObjects: mockCoreStart.savedObjects, detailTitle: 'Data sources', isDashboardAdmin: true, currentWorkspace: workspaceObject, @@ -96,7 +98,14 @@ const success = jest.fn().mockResolvedValue({ }); const failed = jest.fn().mockResolvedValue({}); -const selectDataSourceDetailPanel = (props: any) => { +const selectDataSourceDetailPanel = ({ + action, + selectedDataSourceConnections, + ...props +}: { + action?: Function; + selectedDataSourceConnections?: DataSourceConnection[]; +} & Partial) => { const { Provider } = createOpenSearchDashboardsReactContext({ ...mockCoreStart, ...{ @@ -109,7 +118,7 @@ const selectDataSourceDetailPanel = (props: any) => { }, }, workspaceClient: { - update: props.action, + update: action, }, }, }); @@ -122,11 +131,16 @@ const selectDataSourceDetailPanel = (props: any) => { operationType={WorkspaceOperationType.Update} permissionEnabled={true} onSubmit={jest.fn()} - defaultValues={defaultValues} + defaultValues={{ ...defaultValues, selectedDataSourceConnections }} availableUseCases={[]} > - + @@ -134,12 +148,11 @@ const selectDataSourceDetailPanel = (props: any) => { }; describe('SelectDataSourceDetailPanel', () => { - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); }); it('should show message when no data sources are assigned', async () => { - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValue([]); const { getByText, getAllByText } = render(selectDataSourceDetailPanel(defaultProps)); await waitFor(() => { expect(getByText('No data sources to display')).toBeInTheDocument(); @@ -151,7 +164,6 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should not show assocition button when user is not OSD admin', async () => { - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValue([]); const { getByText, queryByText } = render( selectDataSourceDetailPanel({ ...defaultProps, @@ -167,11 +179,9 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should not show remove associations button when user is not OSD admin', async () => { - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValue(dataSourceConnectionsMock); const { queryByTestId } = render( selectDataSourceDetailPanel({ ...defaultProps, - assignedDataSources: dataSources, isDashboardAdmin: false, }) ); @@ -181,7 +191,6 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should switch toggle button', async () => { - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValue(dataSourceConnectionsMock); const { getByText } = render(selectDataSourceDetailPanel(defaultProps)); await waitFor(() => { const dqcButton = getByText('Direct query connections'); @@ -200,7 +209,6 @@ describe('SelectDataSourceDetailPanel', () => { value: 600, }); jest.spyOn(utils, 'getDataSourcesList').mockResolvedValue([]); - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValueOnce([]); jest .spyOn(utils, 'fetchDataSourceConnections') .mockResolvedValueOnce(dataSourceConnectionsMock); @@ -245,7 +253,6 @@ describe('SelectDataSourceDetailPanel', () => { value: 600, }); jest.spyOn(utils, 'getDataSourcesList').mockResolvedValue([]); - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValueOnce([]); jest .spyOn(utils, 'fetchDataSourceConnections') .mockResolvedValueOnce(dataSourceConnectionsMock); @@ -317,13 +324,10 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should success to remove data sources', async () => { - jest - .spyOn(utils, 'fetchDataSourceConnections') - .mockResolvedValueOnce([dataSourceConnectionsMock[0]]); const { getByText, getByTestId, getByRole } = render( selectDataSourceDetailPanel({ ...defaultProps, - assignedDataSources: [dataSources[0]], + selectedDataSourceConnections: [dataSourceConnectionsMock[0]], action: success, }) ); @@ -341,13 +345,10 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should fail to remove data sources', async () => { - jest - .spyOn(utils, 'fetchDataSourceConnections') - .mockResolvedValueOnce([dataSourceConnectionsMock[0]]); const { getByText, getByTestId, getByRole } = render( selectDataSourceDetailPanel({ ...defaultProps, - assignedDataSources: [dataSources[0]], + selectedDataSourceConnections: [dataSourceConnectionsMock[0]], action: failed, }) ); @@ -365,13 +366,10 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should remove selected data sources successfully', async () => { - jest - .spyOn(utils, 'fetchDataSourceConnections') - .mockResolvedValueOnce([dataSourceConnectionsMock[0]]); const { getByText, queryByTestId, getAllByRole, getByRole } = render( selectDataSourceDetailPanel({ ...defaultProps, - assignedDataSources: [dataSources[0]], + selectedDataSourceConnections: [dataSourceConnectionsMock[0]], action: success, }) ); @@ -392,22 +390,38 @@ describe('SelectDataSourceDetailPanel', () => { }); it('should handle input in the search box', async () => { - jest.spyOn(utils, 'fetchDataSourceConnections').mockResolvedValue(dataSourceConnectionsMock); const { getByText, queryByText } = render( selectDataSourceDetailPanel({ ...defaultProps, - assignedDataSources: dataSources, + selectedDataSourceConnections: dataSourceConnectionsMock, }) ); await waitFor(() => { expect(getByText('Data Source 1')).toBeInTheDocument(); expect(getByText('Data Source 2')).toBeInTheDocument(); + }); + + const searchInput = screen.getByPlaceholderText('Search...'); + // Simulate typing in the search input + fireEvent.change(searchInput, { target: { value: 'Data Source 1' } }); - const searchInput = screen.getByPlaceholderText('Search...'); - // Simulate typing in the search input - fireEvent.change(searchInput, { target: { value: 'Data Source 1' } }); + await waitFor(() => { expect(getByText('Data Source 1')).toBeInTheDocument(); expect(queryByText('Data Source 2')).toBeNull(); }); }); + + it('should show loading message when loading', async () => { + const { queryByText, getByText, rerender } = render( + selectDataSourceDetailPanel({ loading: false }) + ); + await waitFor(() => { + expect(queryByText('Loading data sources...')).not.toBeInTheDocument(); + }); + + rerender(selectDataSourceDetailPanel({ loading: true })); + await waitFor(() => { + expect(getByText('Loading data sources...')).toBeInTheDocument(); + }); + }); }); 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 1f13da230f9e..7a503265983d 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 @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiText, EuiTitle, @@ -21,9 +21,10 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from 'react-intl'; -import { DataSource, DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; +import { useUpdateEffect } from 'react-use'; +import { DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; import { WorkspaceClient } from '../../workspace_client'; -import { DataSourceConnectionTable } from './data_source_connection_table'; +import { WorkspaceDetailConnectionTable } from './workspace_detail_connection_table'; import { AssociationDataSourceModal } from './association_data_source_modal'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { @@ -32,8 +33,11 @@ import { WorkspaceObject, ChromeStart, } from '../../../../../core/public'; -import { convertPermissionSettingsToPermissions, useWorkspaceFormContext } from '../workspace_form'; -import { fetchDataSourceConnections } from '../../utils'; +import { + convertPermissionSettingsToPermissions, + isWorkspacePermissionSetting, + useWorkspaceFormContext, +} from '../workspace_form'; import { AssociationDataSourceModalMode } from '../../../common/constants'; const toggleButtons: EuiButtonGroupOptionProps[] = [ @@ -50,66 +54,59 @@ const toggleButtons: EuiButtonGroupOptionProps[] = [ }), }, ]; -export interface SelectDataSourcePanelProps { +export interface SelectDataSourceDetailPanelProps { savedObjects: SavedObjectsStart; - assignedDataSources: DataSource[]; detailTitle: string; isDashboardAdmin: boolean; currentWorkspace: WorkspaceObject; chrome: ChromeStart; + loading?: boolean; } export const SelectDataSourceDetailPanel = ({ - assignedDataSources, savedObjects, detailTitle, isDashboardAdmin, currentWorkspace, chrome, -}: SelectDataSourcePanelProps) => { + loading = false, +}: SelectDataSourceDetailPanelProps) => { const { services: { notifications, workspaceClient, http }, } = useOpenSearchDashboards<{ CoreStart: CoreStart; workspaceClient: WorkspaceClient }>(); - const { formData, setSelectedDataSources } = useWorkspaceFormContext(); - const [isLoading, setIsLoading] = useState(false); + const { formData, setSelectedDataSourceConnections } = useWorkspaceFormContext(); + const [isLoading, setIsLoading] = useState(loading); const [isVisible, setIsVisible] = useState(false); - const [assignedDataSourceConnections, setAssignedDataSourceConnections] = useState< - DataSourceConnection[] - >([]); const [toggleIdSelected, setToggleIdSelected] = useState(toggleButtons[0].id); - useEffect(() => { - setIsLoading(true); - fetchDataSourceConnections(assignedDataSources, http, notifications).then((connections) => { - setAssignedDataSourceConnections(connections); - setIsLoading(false); - }); - }, [assignedDataSources, http, notifications]); - const handleAssignDataSourceConnections = async ( - dataSourceConnections: DataSourceConnection[] + newAssignedDataSourceConnections: DataSourceConnection[] ) => { - const dataSources = dataSourceConnections - .filter( - ({ connectionType }) => connectionType === DataSourceConnectionType.OpenSearchConnection - ) - .map(({ id, type, name, description }) => ({ - id, - title: name, - description, - dataSourceEngineType: type, - })); try { setIsLoading(true); setIsVisible(false); - const { permissionSettings, selectedDataSources, useCase, ...attributes } = formData; - const savedDataSources: DataSource[] = [...selectedDataSources, ...dataSources]; + const { + permissionSettings, + selectedDataSourceConnections, + useCase, + ...attributes + } = formData; + + const savedDataSourceConnections = [ + ...formData.selectedDataSourceConnections, + ...newAssignedDataSourceConnections, + ]; const result = await workspaceClient.update(currentWorkspace.id, attributes, { - dataSources: savedDataSources.map((ds) => { - return ds.id; - }), - permissions: convertPermissionSettingsToPermissions(permissionSettings), + dataSources: savedDataSourceConnections + .filter( + ({ connectionType }) => connectionType === DataSourceConnectionType.OpenSearchConnection + ) + .map(({ id }) => id), + // Todo: Make permissions be an optional parameter when update workspace + permissions: convertPermissionSettingsToPermissions( + permissionSettings.filter(isWorkspacePermissionSetting) + ), }); if (result?.success) { notifications?.toasts.addSuccess({ @@ -117,7 +114,7 @@ export const SelectDataSourceDetailPanel = ({ defaultMessage: 'Associate OpenSearch connections successfully', }), }); - setSelectedDataSources(savedDataSources); + setSelectedDataSourceConnections(savedDataSourceConnections); } else { throw new Error(result?.error ? result?.error : 'Associate OpenSearch connections failed'); } @@ -134,17 +131,30 @@ export const SelectDataSourceDetailPanel = ({ }; const handleUnassignDataSources = useCallback( - async (dataSources: DataSourceConnection[]) => { + async (unAssignedDataSources: DataSourceConnection[]) => { try { setIsLoading(true); - const { permissionSettings, selectedDataSources, useCase, ...attributes } = formData; - const savedDataSources = (selectedDataSources ?? [])?.filter( - ({ id }: DataSource) => !dataSources.some((item) => item.id === id) + const { + permissionSettings, + selectedDataSourceConnections, + useCase, + ...attributes + } = formData; + const savedDataSourceConnections = selectedDataSourceConnections.filter( + ({ id }) => !unAssignedDataSources.some((item) => item.id === id) ); const result = await workspaceClient.update(currentWorkspace.id, attributes, { - dataSources: savedDataSources.map(({ id }: DataSource) => id), - permissions: convertPermissionSettingsToPermissions(permissionSettings), + dataSources: savedDataSourceConnections + .filter( + ({ connectionType }) => + connectionType === DataSourceConnectionType.OpenSearchConnection + ) + .map(({ id }) => id), + // Todo: Make permissions be an optional parameter when update workspace + permissions: convertPermissionSettingsToPermissions( + permissionSettings.filter(isWorkspacePermissionSetting) + ), }); if (result?.success) { notifications?.toasts.addSuccess({ @@ -152,7 +162,7 @@ export const SelectDataSourceDetailPanel = ({ defaultMessage: 'Remove associated OpenSearch connections successfully', }), }); - setSelectedDataSources(savedDataSources); + setSelectedDataSourceConnections(savedDataSourceConnections); } else { throw new Error( result?.error ? result?.error : 'Remove associated OpenSearch connections failed' @@ -169,7 +179,13 @@ export const SelectDataSourceDetailPanel = ({ setIsLoading(false); } }, - [currentWorkspace.id, formData, notifications?.toasts, setSelectedDataSources, workspaceClient] + [ + currentWorkspace.id, + formData, + notifications?.toasts, + setSelectedDataSourceConnections, + workspaceClient, + ] ); const associationButton = ( @@ -194,7 +210,7 @@ export const SelectDataSourceDetailPanel = ({ @@ -240,19 +256,23 @@ export const SelectDataSourceDetailPanel = ({ if (isLoading) { return loadingMessage; } - if (assignedDataSources.length === 0) { + if (formData.selectedDataSourceConnections.length === 0) { return noAssociationMessage; } return ( - ); }; + useUpdateEffect(() => { + setIsLoading(loading); + }, [loading]); + return ( @@ -284,7 +304,7 @@ export const SelectDataSourceDetailPanel = ({ notifications={notifications} savedObjects={savedObjects} closeModal={() => setIsVisible(false)} - assignedConnections={assignedDataSourceConnections} + assignedConnections={formData.selectedDataSourceConnections} handleAssignDataSourceConnections={handleAssignDataSourceConnections} mode={toggleIdSelected as AssociationDataSourceModalMode} logos={chrome.logos} diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx index 6004e4de206b..dffc12da3bbc 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.test.tsx @@ -3,16 +3,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; import { BehaviorSubject } from 'rxjs'; +import { MemoryRouter } from 'react-router-dom'; import { PublicAppInfo, WorkspaceObject } from 'opensearch-dashboards/public'; import { coreMock } from '../../../../../core/public/mocks'; import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; import { createMockedRegisteredUseCases$ } from '../../mocks'; import { WorkspaceDetail } from './workspace_detail'; import { WorkspaceFormProvider, WorkspaceOperationType } from '../workspace_form'; -import { MemoryRouter } from 'react-router-dom'; +import { DataSourceConnectionType } from '../../../common/types'; +import * as utilsExports from '../../utils'; // all applications const PublicAPPInfoMap = new Map([ @@ -52,12 +54,13 @@ const defaultValues = { modes: ['library_write', 'write'], }, ], - selectedDataSources: [ + selectedDataSourceConnections: [ { id: 'ds-1', - title: 'ds-1-title', + name: 'ds-1-title', description: 'ds-1-description', - dataSourceEngineType: 'OpenSearch', + type: 'OpenSearch', + connectionType: DataSourceConnectionType.OpenSearchConnection, }, ], }; @@ -200,6 +203,9 @@ describe('WorkspaceDetail', () => { const { getByText } = render(WorkspaceDetailPage({ workspacesService: workspaceService })); fireEvent.click(getByText('Data sources')); expect(document.querySelector('#dataSources')).toHaveClass('euiTab-isSelected'); + await waitFor(() => { + expect(getByText('Loading data sources...')).toBeInTheDocument(); + }); }); it('delete button will been shown at page header', async () => { @@ -297,4 +303,34 @@ describe('WorkspaceDetail', () => { expect(alertSpy).toBeCalledTimes(0); alertSpy.mockRestore(); }); + + it('should show loaded data sources', async () => { + jest.spyOn(utilsExports, 'fetchDataSourceConnectionsByDataSourceIds').mockResolvedValue([ + { + id: 'dqc-1', + name: 'dqc-1-title', + description: 'dqc-1-description', + type: 'Amazon S3', + parentId: 'ds-1', + connectionType: DataSourceConnectionType.DirectQueryConnection, + }, + { + id: 'dqc-2', + name: 'dqc-1-title', + description: 'dqc-1-description', + type: 'Amazon S3', + parentId: 'ds-1', + connectionType: DataSourceConnectionType.DirectQueryConnection, + }, + ]); + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { getByText, getByRole } = render( + WorkspaceDetailPage({ workspacesService: workspaceService }) + ); + fireEvent.click(getByText('Data sources')); + await waitFor(() => { + expect(getByText('ds-1-title')).toBeInTheDocument(); + expect(getByRole('button', { name: '2' })).toBeInTheDocument(); + }); + }); }); diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx index 3543795f7e44..16f6c9c2fcf6 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx @@ -24,7 +24,12 @@ import { WORKSPACE_LIST_APP_ID } from '../../../common/constants'; import { cleanWorkspaceId } from '../../../../../core/public/utils'; import { DetailTab, DetailTabTitles, WorkspaceOperationType } from '../workspace_form/constants'; import { CoreStart, WorkspaceAttribute } from '../../../../../core/public'; -import { getFirstUseCaseOfFeatureConfigs, getUseCaseUrl } from '../../utils'; +import { + fetchDataSourceConnectionsByDataSourceIds, + fulfillRelatedConnections, + getFirstUseCaseOfFeatureConfigs, + getUseCaseUrl, +} from '../../utils'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; import { SelectDataSourceDetailPanel } from './select_data_source_panel'; @@ -50,6 +55,7 @@ export const WorkspaceDetail = (props: WorkspaceDetailProps) => { uiSettings, navigationUI: { HeaderControl }, chrome, + notifications, }, } = useOpenSearchDashboards<{ CoreStart: CoreStart; @@ -58,18 +64,20 @@ export const WorkspaceDetail = (props: WorkspaceDetailProps) => { }>(); const { - formData, isEditing, formId, numberOfErrors, handleResetForm, numberOfChanges, setIsEditing, + formData, + setSelectedDataSourceConnections, } = useWorkspaceFormContext(); const [deletedWorkspace, setDeletedWorkspace] = useState(null); const [selectedTabId, setSelectedTabId] = useState(DetailTab.Details); const [modalVisible, setModalVisible] = useState(false); const [tabId, setTabId] = useState(DetailTab.Details); + const [isDQCFilled, setIsDQCFilled] = useState(false); const availableUseCases = useObservable(props.registeredUseCases$, []); const isDashboardAdmin = !!application?.capabilities?.dashboards?.isDashboardAdmin; @@ -89,6 +97,31 @@ export const WorkspaceDetail = (props: WorkspaceDetailProps) => { } }, [location.search]); + useEffect(() => { + if (selectedTabId !== DetailTab.DataSources || isDQCFilled || !http || !notifications) { + return; + } + fetchDataSourceConnectionsByDataSourceIds( + formData.selectedDataSourceConnections.map(({ id }) => id), + http + ) + .then((directQueryConnections) => { + setSelectedDataSourceConnections( + fulfillRelatedConnections(formData.selectedDataSourceConnections, directQueryConnections) + ); + }) + .finally(() => { + setIsDQCFilled(true); + }); + }, [ + http, + isDQCFilled, + selectedTabId, + notifications, + setSelectedDataSourceConnections, + formData.selectedDataSourceConnections, + ]); + if (!currentWorkspace || !application || !http || !savedObjects || !uiSettings || !chrome) { return null; } @@ -141,8 +174,8 @@ export const WorkspaceDetail = (props: WorkspaceDetailProps) => { name: DetailTabTitles.dataSources, content: ( ({ + ...jest.requireActual('../../../../opensearch_dashboards_react/public'), + useOpenSearchDashboards: jest.fn(), +})); const handleUnassignDataSources = jest.fn(); const dataSourceConnectionsMock = [ { @@ -57,14 +62,27 @@ const dataSourceConnectionsMock = [ }, ]; -describe('DataSourceConnectionTable', () => { +describe('WorkspaceDetailConnectionTable', () => { + beforeEach(() => { + const mockPrepend = jest.fn().mockImplementation((path) => path); + const mockHttp = { + basePath: { + prepend: mockPrepend, + }, + }; + (useOpenSearchDashboards as jest.Mock).mockImplementation(() => ({ + services: { + http: mockHttp, + }, + })); + }); afterEach(() => { jest.clearAllMocks(); }); describe('OpenSearch connections', () => { it('renders the table with OpenSearch connections', () => { const { getByText, queryByText } = render( - { it('should show dqc popover when click the Related connections number ', () => { const { getByText } = render( - { it('should remove selected OpenSearch connections by dashboard admin', () => { const { getByText, queryByTestId, getAllByRole, getByRole } = render( - { it('should remove single OpenSearch connections by dashboard admin', () => { const { queryAllByTestId, getByText, getByRole } = render( - { it('should hide remove action iif user is not dashboard admin', () => { const { queryByText, queryByTestId, getAllByRole } = render( - { describe('Direct query connections', () => { it('renders the table with Direct query connections', () => { const { getByText, queryByText, getByTestId } = render( - void; +} + +export const WorkspaceDetailConnectionTable = ({ + isDashboardAdmin, + connectionType, + dataSourceConnections, + handleUnassignDataSources, +}: WorkspaceDetailConnectionTableProps) => { + const [selectedItems, setSelectedItems] = useState([]); + const [modalVisible, setModalVisible] = useState(false); + + useEffect(() => { + // Reset selected items when connectionType changes + setSelectedItems([]); + }, [connectionType]); + + const openSearchConnections = useMemo(() => { + return dataSourceConnections.filter((dsc) => + connectionType === AssociationDataSourceModalMode.OpenSearchConnections + ? dsc.connectionType === DataSourceConnectionType.OpenSearchConnection + : dsc?.relatedConnections && dsc.relatedConnections?.length > 0 + ); + }, [connectionType, dataSourceConnections]); + + const renderToolsLeft = useCallback(() => { + return selectedItems.length > 0 && !modalVisible + ? [ + setModalVisible(true)} + data-test-subj="workspace-detail-dataSources-table-bulkRemove" + > + {i18n.translate('workspace.detail.dataSources.table.remove.button', { + defaultMessage: 'Remove {numberOfSelect} association(s)', + values: { numberOfSelect: selectedItems.length }, + })} + , + ] + : []; + }, [selectedItems, modalVisible]); + + const search: EuiSearchBarProps = { + toolsLeft: renderToolsLeft(), + box: { + incremental: true, + }, + filters: [ + { + type: 'field_value_selection', + field: 'type', + name: 'Type', + multiSelect: 'or', + options: Array.from( + new Set(openSearchConnections.map(({ type }) => type).filter(Boolean)) + ).map((type) => ({ + value: type!, + name: type!, + })), + }, + ], + }; + + return ( + <> + { + { + setSelectedItems([item]); + setModalVisible(true); + }} + onSelectionChange={setSelectedItems} + tableProps={{ + search, + pagination: { + initialPageSize: 10, + pageSizeOptions: [10, 20, 30], + }, + }} + /* Unmount table after connection type */ + key={connectionType} + /> + } + {modalVisible && ( + { + setModalVisible(false); + setSelectedItems([]); + }} + onConfirm={() => { + setModalVisible(false); + handleUnassignDataSources(selectedItems); + }} + cancelButtonText={i18n.translate('workspace.detail.dataSources.modal.cancelButton', { + defaultMessage: 'Cancel', + })} + confirmButtonText={i18n.translate('workspace.detail.dataSources.Modal.confirmButton', { + defaultMessage: 'Remove data source(s)', + })} + buttonColor="danger" + defaultFocusedButton="confirm" + /> + )} + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_detail_app.tsx b/src/plugins/workspace/public/components/workspace_detail_app.tsx index 1347b130575b..9518b5707bb3 100644 --- a/src/plugins/workspace/public/components/workspace_detail_app.tsx +++ b/src/plugins/workspace/public/components/workspace_detail_app.tsx @@ -19,11 +19,11 @@ import { convertPermissionSettingsToPermissions, convertPermissionsToPermissionSettings, } from './workspace_form'; -import { DataSource } from '../../common/types'; +import { DataSourceConnectionType } from '../../common/types'; import { WorkspaceClient } from '../workspace_client'; import { formatUrlWithWorkspaceId } from '../../../../core/public/utils'; import { WORKSPACE_DETAIL_APP_ID } from '../../common/constants'; -import { getDataSourcesList } from '../utils'; +import { getDataSourcesList, mergeDataSourcesWithConnections } from '../utils'; import { WorkspaceAttributeWithPermission } from '../../../../core/types'; function getFormDataFromWorkspace( @@ -34,16 +34,13 @@ function getFormDataFromWorkspace( } return { ...currentWorkspace, + features: currentWorkspace.features ?? [], permissionSettings: currentWorkspace.permissions ? convertPermissionsToPermissionSettings(currentWorkspace.permissions) : currentWorkspace.permissions, }; } -type FormDataFromWorkspace = ReturnType & { - selectedDataSources: DataSource[]; -}; - export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { const { services: { @@ -56,7 +53,9 @@ export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { http, }, } = useOpenSearchDashboards<{ CoreStart: CoreStart; workspaceClient: WorkspaceClient }>(); - const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState(); + const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState< + WorkspaceFormSubmitData + >(); const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); const availableUseCases = useObservable(props.registeredUseCases$, []); const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; @@ -93,14 +92,15 @@ export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { const rawFormData = getFormDataFromWorkspace(currentWorkspace); if (rawFormData && savedObjects && currentWorkspace) { - getDataSourcesList(savedObjects.client, [currentWorkspace.id]).then((selectedDataSources) => { + getDataSourcesList(savedObjects.client, [currentWorkspace.id]).then((dataSources) => { setCurrentWorkspaceFormData({ ...rawFormData, - selectedDataSources, + // Direct query connections info is not required for all tabs, it can be fetched later + selectedDataSourceConnections: mergeDataSourcesWithConnections(dataSources, []), }); }); } - }, [currentWorkspace, savedObjects]); + }, [currentWorkspace, savedObjects, http, notifications]); const handleWorkspaceFormSubmit = useCallback( async (data: WorkspaceFormSubmitData) => { @@ -115,10 +115,14 @@ export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { } try { - const { permissionSettings, selectedDataSources, ...attributes } = data; - const selectedDataSourceIds = (selectedDataSources ?? []).map((ds: DataSource) => { - return ds.id; - }); + const { permissionSettings, selectedDataSourceConnections, ...attributes } = data; + const selectedDataSourceIds = (selectedDataSourceConnections ?? []) + .filter( + ({ connectionType }) => connectionType === DataSourceConnectionType.OpenSearchConnection + ) + .map((connection) => { + return connection.id; + }); result = await workspaceClient.update(currentWorkspace.id, attributes, { dataSources: selectedDataSourceIds, diff --git a/src/plugins/workspace/public/components/workspace_form/constants.ts b/src/plugins/workspace/public/components/workspace_form/constants.ts index ed2268bec8d7..c09ac11f5489 100644 --- a/src/plugins/workspace/public/components/workspace_form/constants.ts +++ b/src/plugins/workspace/public/components/workspace_form/constants.ts @@ -123,3 +123,7 @@ export const DetailTabTitles: { [key in DetailTab]: string } = { defaultMessage: 'Collaborators', }), }; + +export const PERMISSION_TYPE_LABEL_ID = 'workspace-form-permission-type-label'; +export const PERMISSION_COLLABORATOR_LABEL_ID = 'workspace-form-permission-collaborator-label'; +export const PERMISSION_ACCESS_LEVEL_LABEL_ID = 'workspace-form-permission-access-level-label'; diff --git a/src/plugins/workspace/public/components/workspace_detail/data_source_connection_table.scss b/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.scss similarity index 100% rename from src/plugins/workspace/public/components/workspace_detail/data_source_connection_table.scss rename to src/plugins/workspace/public/components/workspace_form/data_source_connection_table.scss diff --git a/src/plugins/workspace/public/components/workspace_detail/data_source_connection_table.tsx b/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx similarity index 63% rename from src/plugins/workspace/public/components/workspace_detail/data_source_connection_table.tsx rename to src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx index c11f9fbee254..d440d3e751d6 100644 --- a/src/plugins/workspace/public/components/workspace_detail/data_source_connection_table.tsx +++ b/src/plugins/workspace/public/components/workspace_form/data_source_connection_table.tsx @@ -3,106 +3,55 @@ * SPDX-License-Identifier: Apache-2.0 */ -import './data_source_connection_table.scss'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { - EuiSpacer, EuiInMemoryTable, EuiBasicTableColumn, - EuiTableSelectionType, EuiTableActionsColumnType, - EuiConfirmModal, - EuiSearchBarProps, EuiText, EuiListGroup, EuiListGroupItem, EuiPopover, EuiButtonEmpty, EuiPopoverTitle, - EuiSmallButton, EuiLink, EuiButtonIcon, + EuiInMemoryTableProps, + EuiTableSelectionType, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; import { AssociationDataSourceModalMode } from '../../../common/constants'; -import { DirectQueryConnectionIcon } from '../workspace_form'; +import { DirectQueryConnectionIcon } from './direct_query_connection_icon'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { CoreStart } from '../../../../../core/public'; + +import './data_source_connection_table.scss'; interface DataSourceConnectionTableProps { isDashboardAdmin: boolean; connectionType: string; + onUnlinkDataSource: (dataSources: DataSourceConnection) => void; + onSelectionChange: (selections: DataSourceConnection[]) => void; dataSourceConnections: DataSourceConnection[]; - handleUnassignDataSources: (dataSources: DataSourceConnection[]) => Promise; + tableProps?: Pick, 'pagination' | 'search'>; } export const DataSourceConnectionTable = ({ isDashboardAdmin, connectionType, + onUnlinkDataSource, + onSelectionChange, + tableProps, dataSourceConnections, - handleUnassignDataSources, }: DataSourceConnectionTableProps) => { - const [selectedItems, setSelectedItems] = useState([]); - const [modalVisible, setModalVisible] = useState(false); const [popoversState, setPopoversState] = useState>({}); const [itemIdToExpandedRowMap, setItemIdToExpandedRowMap] = useState< Record >({}); - - useEffect(() => { - // Reset selected items when connectionType changes - setSelectedItems([]); - setItemIdToExpandedRowMap({}); - }, [connectionType]); - - const openSearchConnections = useMemo(() => { - return dataSourceConnections.filter((dsc) => - connectionType === AssociationDataSourceModalMode.OpenSearchConnections - ? dsc.connectionType === DataSourceConnectionType.OpenSearchConnection - : dsc?.relatedConnections && dsc.relatedConnections?.length > 0 - ); - }, [connectionType, dataSourceConnections]); - - const renderToolsLeft = useCallback(() => { - return selectedItems.length > 0 && !modalVisible - ? [ - setModalVisible(true)} - data-test-subj="workspace-detail-dataSources-table-bulkRemove" - > - {i18n.translate('workspace.detail.dataSources.table.remove.button', { - defaultMessage: 'Remove {numberOfSelect} association(s)', - values: { numberOfSelect: selectedItems.length }, - })} - , - ] - : []; - }, [selectedItems, modalVisible]); - - const onSelectionChange = (selectedDataSources: DataSourceConnection[]) => { - setSelectedItems(selectedDataSources); - }; - - const search: EuiSearchBarProps = { - toolsLeft: renderToolsLeft(), - box: { - incremental: true, - }, - filters: [ - { - type: 'field_value_selection', - field: 'type', - name: 'Type', - multiSelect: 'or', - options: Array.from( - new Set(openSearchConnections.map(({ type }) => type).filter(Boolean)) - ).map((type) => ({ - value: type!, - name: type!, - })), - }, - ], - }; + const { + services: { http }, + } = useOpenSearchDashboards(); const togglePopover = (itemId: string) => { setPopoversState((prevState) => ({ @@ -152,19 +101,20 @@ export const DataSourceConnectionTable = ({ ] : []), { - width: '25%', + width: '20%', field: 'name', name: i18n.translate('workspace.detail.dataSources.table.title', { defaultMessage: 'Title', }), truncateText: true, render: (name: string, record) => { - const origin = window.location.origin; let url: string; if (record.connectionType === DataSourceConnectionType.OpenSearchConnection) { - url = `${origin}/app/dataSources/${record.id}`; + url = http.basePath.prepend(`/app/dataSources/${record.id}`); } else { - url = `${origin}/app/dataSources/manage/${name}?dataSourceMDSId=${record.parentId}`; + url = http.basePath.prepend( + `/app/dataSources/manage/${name}?dataSourceMDSId=${record.parentId}` + ); } return ( @@ -174,7 +124,7 @@ export const DataSourceConnectionTable = ({ }, }, { - width: '10%', + width: '20%', field: 'type', name: i18n.translate('workspace.detail.dataSources.table.type', { defaultMessage: 'Type', @@ -182,7 +132,6 @@ export const DataSourceConnectionTable = ({ truncateText: true, }, { - width: '35%', field: 'description', name: i18n.translate('workspace.detail.dataSources.table.description', { defaultMessage: 'Description', @@ -190,11 +139,11 @@ export const DataSourceConnectionTable = ({ truncateText: true, }, { + width: '140px', field: 'relatedConnections', name: i18n.translate('workspace.detail.dataSources.table.relatedConnections', { defaultMessage: 'Related connections', }), - align: 'right', truncateText: true, render: (relatedConnections: DataSourceConnection[], record) => relatedConnections?.length > 0 ? ( @@ -267,12 +216,12 @@ export const DataSourceConnectionTable = ({ icon: 'unlink', type: 'icon', onClick: (item: DataSourceConnection) => { - setSelectedItems([item]); - setModalVisible(true); + onUnlinkDataSource(item); }, 'data-test-subj': 'workspace-detail-dataSources-table-actions-remove', }, ], + width: '10%', } as EuiTableActionsColumnType, ] : []), @@ -284,48 +233,15 @@ export const DataSourceConnectionTable = ({ }; return ( - <> - - - {modalVisible && ( - { - setModalVisible(false); - setSelectedItems([]); - }} - onConfirm={() => { - setModalVisible(false); - handleUnassignDataSources(selectedItems); - }} - cancelButtonText={i18n.translate('workspace.detail.dataSources.modal.cancelButton', { - defaultMessage: 'Cancel', - })} - confirmButtonText={i18n.translate('workspace.detail.dataSources.Modal.confirmButton', { - defaultMessage: 'Remove data source(s)', - })} - buttonColor="danger" - defaultFocusedButton="confirm" - /> - )} - + ); }; diff --git a/src/plugins/workspace/public/components/workspace_form/index.ts b/src/plugins/workspace/public/components/workspace_form/index.ts index 79d934fb80b9..2fae243a37a2 100644 --- a/src/plugins/workspace/public/components/workspace_form/index.ts +++ b/src/plugins/workspace/public/components/workspace_form/index.ts @@ -11,6 +11,7 @@ export { WorkspacePermissionSettingPanel } from './workspace_permission_setting_ export { WorkspaceCancelModal } from './workspace_cancel_modal'; export { WorkspaceNameField, WorkspaceDescriptionField } from './fields'; export { DirectQueryConnectionIcon } from './direct_query_connection_icon'; +export { DataSourceConnectionTable } from './data_source_connection_table'; export { WorkspaceFormSubmitData, WorkspaceFormProps, WorkspaceFormDataState } from './types'; export { @@ -26,6 +27,7 @@ export { export { convertPermissionsToPermissionSettings, convertPermissionSettingsToPermissions, + isWorkspacePermissionSetting, } from './utils'; export { WorkspaceFormProvider, useWorkspaceFormContext } from './workspace_form_context'; 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..460921530d4b 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,86 +4,229 @@ */ import React from 'react'; -import { fireEvent, render, act } from '@testing-library/react'; -import { SelectDataSourcePanel, SelectDataSourcePanelProps } from './select_data_source_panel'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { I18nProvider } from '@osd/i18n/react'; import { coreMock } from '../../../../../core/public/mocks'; +import * as utils from '../../utils'; +import { DataSourceEngineType } from 'src/plugins/data_source/common/data_sources'; +import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public'; +import { DataSourceConnectionType } from '../../../common/types'; + +import { SelectDataSourcePanel, SelectDataSourcePanelProps } from './select_data_source_panel'; + +const dataSourceConnectionsMock = [ + { + id: 'ds1', + name: 'Data Source 1', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', + relatedConnections: [ + { + id: 'ds1-dqc1', + name: 'dqc1', + parentId: 'ds1', + connectionType: DataSourceConnectionType.DirectQueryConnection, + type: 'Amazon S3', + }, + ], + }, + { + id: 'ds1-dqc1', + name: 'dqc1', + parentId: 'ds1', + connectionType: DataSourceConnectionType.DirectQueryConnection, + type: 'Amazon S3', + }, + { + id: 'ds2', + name: 'Data Source 2', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', + }, +]; + +const assignedDataSourcesConnections = [dataSourceConnectionsMock[0], dataSourceConnectionsMock[2]]; const dataSources = [ { - id: 'id1', - title: 'title1', + id: 'ds1', + title: 'Data Source 1', + description: 'Description of data source 1', + auth: '', + dataSourceEngineType: '' as DataSourceEngineType, + workspaces: [], + }, + { + id: 'ds2', + title: 'Data Source 2', + description: 'Description of data source 2', + auth: '', + dataSourceEngineType: '' as DataSourceEngineType, + workspaces: [], }, - { id: 'id2', title: 'title2' }, ]; -jest.mock('../../utils', () => ({ - getDataSourcesList: jest.fn().mockResolvedValue(dataSources), -})); +jest.spyOn(utils, 'getDataSourcesList').mockResolvedValue(dataSources); +jest.spyOn(utils, 'fetchDataSourceConnections').mockImplementation(async (passedDataSources) => { + return dataSourceConnectionsMock.filter(({ id }) => + passedDataSources.some((dataSource) => dataSource.id === id) + ); +}); const mockCoreStart = coreMock.createStart(); const setup = ({ savedObjects = mockCoreStart.savedObjects, - selectedDataSources = [], + assignedDataSourceConnections = [], onChange = jest.fn(), errors = undefined, + showDataSourceManagement = 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, + }); + }); + afterEach(() => { + Object.defineProperty( + HTMLElement.prototype, + 'offsetHeight', + originalOffsetHeight as PropertyDescriptor + ); + Object.defineProperty( + HTMLElement.prototype, + 'offsetWidth', + originalOffsetWidth as PropertyDescriptor + ); + }); + it('should render consistent data sources when selected data sources passed', async () => { + const { getByText, getByTestId, queryByText } = setup({ + assignedDataSourceConnections: [assignedDataSourcesConnections[0]], + }); + + await waitFor(() => { + expect(getByText(assignedDataSourcesConnections[0].name)).toBeInTheDocument(); + expect(queryByText(assignedDataSourcesConnections[1].name)).not.toBeInTheDocument(); + }); - expect(getByText(dataSources[0].title)).toBeInTheDocument(); - expect(getByText(dataSources[1].title)).toBeInTheDocument(); + fireEvent.click(getByTestId('workspace-creator-dataSources-assign-button')); + + await waitFor(() => { + expect(getByText(assignedDataSourcesConnections[1].name)).toBeInTheDocument(); + }); }); - it('should call onChange when clicking add new data source button', () => { + it('should call onChange when updating data sources', async () => { const onChangeMock = jest.fn(); - const { getByTestId } = setup({ onChange: onChangeMock }); + const { getByTestId, getByText } = setup({ + onChange: onChangeMock, + assignedDataSourceConnections: [], + }); expect(onChangeMock).not.toHaveBeenCalled(); - fireEvent.click(getByTestId('workspaceForm-select-dataSource-addNew')); + fireEvent.click(getByTestId('workspace-creator-dataSources-assign-button')); + + await waitFor(() => { + expect( + getByText( + 'Add data sources that will be available in the workspace. If a selected data source has related Direct Query connection, they will also be available in the workspace.' + ) + ).toBeInTheDocument(); + expect(getByText(assignedDataSourcesConnections[1].name)).toBeInTheDocument(); + }); + + fireEvent.click(getByText(assignedDataSourcesConnections[1].name)); + fireEvent.click(getByText('Associate data sources')); expect(onChangeMock).toHaveBeenCalledWith([ - { - id: '', - title: '', - }, + expect.objectContaining({ + id: assignedDataSourcesConnections[1].id, + }), + ]); + + fireEvent.click(getByTestId('workspace-creator-dqc-assign-button')); + await waitFor(() => { + expect(getByText(assignedDataSourcesConnections[0].name)).toBeInTheDocument(); + }); + fireEvent.click(getByText(assignedDataSourcesConnections[0].name)); + fireEvent.click(getByText('Associate data sources')); + expect(onChangeMock).toHaveBeenCalledWith([ + expect.objectContaining({ + id: assignedDataSourcesConnections[0].id, + }), ]); }); - it('should call onChange when updating selected data sources in combo box', async () => { + it('should call onChange when deleting selected data source', async () => { const onChangeMock = jest.fn(); - const { getByTitle, getByText } = setup({ + const { getByText, getByTestId } = setup({ onChange: onChangeMock, - selectedDataSources: [{ id: '', title: '' }], + assignedDataSourceConnections: assignedDataSourcesConnections, + }); + fireEvent.click(getByTestId('workspace-creator-dataSources-assign-button')); + + await waitFor(() => { + expect(getByText(assignedDataSourcesConnections[0].name)).toBeInTheDocument(); + expect(getByText(assignedDataSourcesConnections[1].name)).toBeInTheDocument(); }); + + fireEvent.click(getByText(assignedDataSourcesConnections[0].name)); + fireEvent.click(getByText(assignedDataSourcesConnections[1].name)); + expect(onChangeMock).not.toHaveBeenCalled(); - await act(() => { - fireEvent.click(getByText('Select')); + + fireEvent.click(getByText('Associate data sources')); + + await waitFor(() => { + fireEvent.click(getByTestId('checkboxSelectRow-' + dataSources[1].id)); + fireEvent.click(getByText('Remove selected')); }); - fireEvent.click(getByTitle(dataSources[0].title)); - expect(onChangeMock).toHaveBeenCalledWith([{ id: 'id1', title: 'title1' }]); + expect(onChangeMock).toHaveBeenCalledWith([assignedDataSourcesConnections[0]]); }); - it('should call onChange when deleting selected data source', async () => { - const onChangeMock = jest.fn(); - const { getByLabelText } = setup({ - onChange: onChangeMock, - selectedDataSources: [{ id: '', title: '' }], + it('should close associate data sources modal', async () => { + const { getByText, queryByText, getByTestId } = setup({ + assignedDataSourceConnections: [], }); - expect(onChangeMock).not.toHaveBeenCalled(); - await act(() => { - fireEvent.click(getByLabelText('Delete data source')); + + fireEvent.click(getByTestId('workspace-creator-dataSources-assign-button')); + await waitFor(() => { + expect( + getByText( + 'Add data sources that will be available in the workspace. If a selected data source has related Direct Query connection, they will also be available in the workspace.' + ) + ).toBeInTheDocument(); }); - expect(onChangeMock).toHaveBeenCalledWith([]); + fireEvent.click(getByText('Close')); + expect( + queryByText( + 'Add data sources that will be available in the workspace. If a selected data source has related Direct Query connection, they will also be available in the workspace.' + ) + ).toBeNull(); }); }); 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..b1ec518d77c1 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,149 @@ * 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, EuiFlexItem, EuiSmallButton, EuiFlexGroup, EuiPanel } from '@elastic/eui'; import { i18n } from '@osd/i18n'; -import { SavedObjectsStart } from '../../../../../core/public'; -import { getDataSourcesList } from '../../utils'; -import { DataSource } from '../../../common/types'; +import { SavedObjectsStart, CoreStart } from '../../../../../core/public'; +import { DataSourceConnection } from '../../../common/types'; import { WorkspaceFormError } from './types'; +import { AssociationDataSourceModal } from '../workspace_detail/association_data_source_modal'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceClient } from '../../workspace_client'; +import { AssociationDataSourceModalMode } from '../../../common/constants'; +import { DataSourceConnectionTable } from './data_source_connection_table'; export interface SelectDataSourcePanelProps { errors?: { [key: number]: WorkspaceFormError }; savedObjects: SavedObjectsStart; - selectedDataSources: DataSource[]; - onChange: (value: DataSource[]) => void; + assignedDataSourceConnections: DataSourceConnection[]; + onChange: (value: DataSourceConnection[]) => void; + showDataSourceManagement: boolean; } export const SelectDataSourcePanel = ({ - errors, onChange, - selectedDataSources, + assignedDataSourceConnections, savedObjects, + showDataSourceManagement, }: 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 [selectedItems, setSelectedItems] = useState([]); + const [toggleIdSelected, setToggleIdSelected] = useState( + AssociationDataSourceModalMode.OpenSearchConnections + ); + const { + services: { notifications, http, chrome }, + } = useOpenSearchDashboards<{ CoreStart: CoreStart; workspaceClient: WorkspaceClient }>(); + + const handleAssignDataSourceConnections = (newDataSourceConnections: DataSourceConnection[]) => { + setModalVisible(false); + onChange([...assignedDataSourceConnections, ...newDataSourceConnections]); + }; + + const handleUnassignDataSources = (dataSourceConnections: DataSourceConnection[]) => { + onChange( + assignedDataSourceConnections.filter( + ({ id }: DataSourceConnection) => !dataSourceConnections.some((item) => item.id === id) + ) + ); + }; - 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 handleSingleDataSourceUnAssign = (connection: DataSourceConnection) => { + handleUnassignDataSources([connection]); + }; - onChange(newSelectedOptions); - }, - [onChange, selectedDataSources] + const renderTableContent = () => { + return ( + + + + ); + }; + + const addOpenSearchConnectionsButton = ( + { + setToggleIdSelected(AssociationDataSourceModalMode.OpenSearchConnections); + setModalVisible(true); + }} + data-test-subj="workspace-creator-dataSources-assign-button" + > + {i18n.translate('workspace.form.selectDataSourcePanel.addNew', { + defaultMessage: 'Add OpenSearch connections', + })} + ); - const handleDelete = useCallback( - (index) => { - const newSelectedOptions = [...selectedDataSources]; - newSelectedOptions.splice(index, 1); + const addDirectQueryConnectionsButton = ( + { + setToggleIdSelected(AssociationDataSourceModalMode.DirectQueryConnections); + setModalVisible(true); + }} + data-test-subj="workspace-creator-dqc-assign-button" + > + {i18n.translate('workspace.form.selectDataSourcePanel.addNewDQCs', { + defaultMessage: 'Add direct query connections', + })} + + ); - onChange(newSelectedOptions); - }, - [onChange, selectedDataSources] + const removeButton = ( + { + handleUnassignDataSources(selectedItems); + }} + data-test-subj="workspace-creator-dataSources-remove-button" + > + {i18n.translate('workspace.form.selectDataSourcePanel.remove', { + defaultMessage: 'Remove selected', + })} + ); return (
- - {i18n.translate('workspace.form.selectDataSource.subTitle', { - defaultMessage: 'Data source', - })} - - - {selectedDataSources.map(({ id, title }, index) => ( - - - - handleSelect(selectedOptions, index)} - placeholder="Select" - /> - - - handleDelete(index)} - isDisabled={false} - /> - - - - ))} - - - {i18n.translate('workspace.form.selectDataSourcePanel.addNew', { - defaultMessage: 'Add New', - })} - + + + {showDataSourceManagement && + selectedItems.length > 0 && + assignedDataSourceConnections.length > 0 && ( + {removeButton} + )} + {showDataSourceManagement && ( + {addOpenSearchConnectionsButton} + )} + {showDataSourceManagement && ( + {addDirectQueryConnectionsButton} + )} + + + + {assignedDataSourceConnections.length > 0 && renderTableContent()} + + {modalVisible && chrome && ( + setModalVisible(false)} + handleAssignDataSourceConnections={handleAssignDataSourceConnections} + http={http} + mode={toggleIdSelected} + notifications={notifications} + logos={chrome.logos} + /> + )}
); }; diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts index 0bf8881cd3bd..7ca175f794be 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 { 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[]; + selectedDataSourceConnections?: DataSourceConnection[]; } export enum WorkspaceFormErrorCode { @@ -61,14 +61,14 @@ export interface WorkspaceFormError { export type WorkspaceFormErrors = { [key in keyof Omit< WorkspaceFormSubmitData, - 'permissionSettings' | 'description' | 'selectedDataSources' + 'permissionSettings' | 'description' | 'selectedDataSourceConnections' >]?: WorkspaceFormError; } & { permissionSettings?: { overall?: WorkspaceFormError; fields?: { [key: number]: WorkspaceFormError }; }; - selectedDataSources?: { [key: number]: WorkspaceFormError }; + selectedDataSourceConnections?: { [key: number]: WorkspaceFormError }; }; export interface WorkspaceFormProps { @@ -91,7 +91,7 @@ export interface WorkspaceFormDataState extends Omit { name: string; useCase: string | undefined; - selectedDataSources: DataSource[]; + selectedDataSourceConnections: DataSourceConnection[]; permissionSettings: Array< Pick & Partial >; 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 703255455ec4..74cf11982f4d 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 @@ -8,7 +8,7 @@ import { htmlIdGenerator, EuiColorPickerProps } from '@elastic/eui'; import { useApplications } from '../../hooks'; import { getFirstUseCaseOfFeatureConfigs, isUseCaseFeatureConfig } from '../../utils'; -import { DataSource } from '../../../common/types'; +import { DataSourceConnection } from '../../../common/types'; import { getUseCaseFeatureConfig } from '../../../common/utils'; import { WorkspaceFormProps, @@ -51,9 +51,12 @@ export const useWorkspaceForm = ({ WorkspaceFormDataState['permissionSettings'] >(initialPermissionSettingsRef.current); - const [selectedDataSources, setSelectedDataSources] = useState( - defaultValues?.selectedDataSources && defaultValues.selectedDataSources.length > 0 - ? defaultValues.selectedDataSources + const [selectedDataSourceConnections, setSelectedDataSourceConnections] = useState< + DataSourceConnection[] + >( + defaultValues?.selectedDataSourceConnections && + defaultValues.selectedDataSourceConnections.length > 0 + ? defaultValues.selectedDataSourceConnections : [] ); @@ -67,7 +70,7 @@ export const useWorkspaceForm = ({ useCase: selectedUseCase, color, permissionSettings, - selectedDataSources, + selectedDataSourceConnections, }); const getFormDataRef = useRef(getFormData); getFormDataRef.current = getFormData; @@ -122,7 +125,7 @@ export const useWorkspaceForm = ({ color: currentFormData.color || '#FFFFFF', features: currentFormData.features, permissionSettings: currentFormData.permissionSettings as WorkspacePermissionSetting[], - selectedDataSources: currentFormData.selectedDataSources, + selectedDataSourceConnections: currentFormData.selectedDataSourceConnections, }); }, [onSubmit, permissionEnabled] @@ -159,6 +162,6 @@ export const useWorkspaceForm = ({ handleColorChange, handleUseCaseChange, setPermissionSettings, - setSelectedDataSources, + setSelectedDataSourceConnections, }; }; diff --git a/src/plugins/workspace/public/components/workspace_form/utils.test.ts b/src/plugins/workspace/public/components/workspace_form/utils.test.ts index 3a45165044d7..03cea502f573 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.test.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.test.ts @@ -9,9 +9,15 @@ import { convertPermissionsToPermissionSettings, getNumberOfChanges, getNumberOfErrors, + isWorkspacePermissionSetting, } from './utils'; import { WorkspacePermissionMode } from '../../../common/constants'; -import { WorkspacePermissionItemType } from './constants'; +import { + WorkspacePermissionItemType, + optionIdToWorkspacePermissionModesMap, + PermissionModeId, +} from './constants'; +import { DataSourceConnectionType } from '../../../common/types'; import { WorkspaceFormErrorCode } from './types'; describe('convertPermissionSettingsToPermissions', () => { @@ -337,15 +343,17 @@ describe('validateWorkspaceForm', () => { validateWorkspaceForm( { name: 'test', - selectedDataSources: [ + selectedDataSourceConnections: [ { id: '', - title: 'title', + name: 'title', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', }, ], }, false - ).selectedDataSources + ).selectedDataSourceConnections ).toEqual({ 0: { code: WorkspaceFormErrorCode.InvalidDataSource, message: 'Invalid data source' }, }); @@ -356,19 +364,23 @@ describe('validateWorkspaceForm', () => { validateWorkspaceForm( { name: 'test', - selectedDataSources: [ + selectedDataSourceConnections: [ { id: 'id', - title: 'title1', + name: 'title1', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', }, { id: 'id', - title: 'title2', + name: 'title2', + connectionType: DataSourceConnectionType.OpenSearchConnection, + type: 'OpenSearch', }, ], }, false - ).selectedDataSources + ).selectedDataSourceConnections ).toEqual({ '1': { code: WorkspaceFormErrorCode.DuplicateDataSource, message: 'Duplicate data sources' }, }); @@ -379,7 +391,7 @@ describe('getNumberOfErrors', () => { it('should calculate the error number of data sources form', () => { expect( getNumberOfErrors({ - selectedDataSources: { + selectedDataSourceConnections: { 0: { code: WorkspaceFormErrorCode.InvalidDataSource, message: 'Invalid data source' }, }, }) @@ -617,3 +629,72 @@ describe('getNumberOfChanges', () => { ).toEqual(3); }); }); + +describe('isWorkspacePermissionSetting', () => { + it('should return true for a valid user permission setting', () => { + const validUserPermissionSetting = { + modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Read], + type: WorkspacePermissionItemType.User, + userId: 'user123', + }; + const result = isWorkspacePermissionSetting(validUserPermissionSetting); + expect(result).toBe(true); + }); + + it('should return true for a valid group permission setting', () => { + const validGroupPermissionSetting = { + modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Owner], + type: WorkspacePermissionItemType.Group, + group: 'group456', + }; + const result = isWorkspacePermissionSetting(validGroupPermissionSetting); + expect(result).toBe(true); + }); + + it('should return false if modes is missing', () => { + const permissionSettingWithoutModes = { + type: WorkspacePermissionItemType.User, + userId: 'user123', + }; + const result = isWorkspacePermissionSetting(permissionSettingWithoutModes); + expect(result).toBe(false); + }); + + it('should return false if modes are invalid', () => { + const permissionSettingWithInvalidModes = { + modes: ['invalid' as WorkspacePermissionMode], + type: WorkspacePermissionItemType.User, + userId: 'user123', + }; + const result = isWorkspacePermissionSetting(permissionSettingWithInvalidModes); + expect(result).toBe(false); + }); + + it('should return false if type is invalid', () => { + const permissionSettingWithInvalidType = { + modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Owner], + type: 'invalid', + userId: 'user123', + }; + const result = isWorkspacePermissionSetting(permissionSettingWithInvalidType); + expect(result).toBe(false); + }); + + it('should return false if userId is missing for user type', () => { + const permissionSettingWithoutUserId = { + modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Owner], + type: WorkspacePermissionItemType.User, + }; + const result = isWorkspacePermissionSetting(permissionSettingWithoutUserId); + expect(result).toBe(false); + }); + + it('should return false if group is missing for group type', () => { + const permissionSettingWithoutGroup = { + modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Owner], + type: WorkspacePermissionItemType.Group, + }; + const result = isWorkspacePermissionSetting(permissionSettingWithoutGroup); + expect(result).toBe(false); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts index 161def1f3f6b..5e03c724889a 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -25,7 +25,7 @@ import { WorkspaceUserGroupPermissionSetting, WorkspaceUserPermissionSetting, } from './types'; -import { DataSource } from '../../../common/types'; +import { DataSourceConnection } from '../../../common/types'; import { validateWorkspaceColor } from '../../../common/utils'; export const isValidFormTextInput = (input?: string) => { @@ -48,8 +48,8 @@ export const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => { if (formErrors.permissionSettings?.overall) { numberOfErrors += 1; } - if (formErrors.selectedDataSources) { - numberOfErrors += Object.keys(formErrors.selectedDataSources).length; + if (formErrors.selectedDataSourceConnections) { + numberOfErrors += Object.keys(formErrors.selectedDataSourceConnections).length; } if (formErrors.features) { numberOfErrors += 1; @@ -226,7 +226,7 @@ const validateUserGroupPermissionSetting = ( } }; -const validatePermissionSetting = ( +const validatePermissionSettings = ( permissionSettings?: Array< Pick & Partial > @@ -289,17 +289,17 @@ const validatePermissionSetting = ( : {}), }; }; -export const isSelectedDataSourcesDuplicated = ( - selectedDataSources: DataSource[], - row: DataSource -) => selectedDataSources.some((ds) => ds.id === row.id); +export const isSelectedDataSourceConnectionsDuplicated = ( + selectedDataSourceConnections: DataSourceConnection[], + row: DataSourceConnection +) => selectedDataSourceConnections.some((connection) => connection.id === row.id); export const validateWorkspaceForm = ( formData: Partial, isPermissionEnabled: boolean ) => { const formErrors: WorkspaceFormErrors = {}; - const { name, permissionSettings, color, features, selectedDataSources } = formData; + const { name, permissionSettings, color, features, selectedDataSourceConnections } = formData; if (name && name.trim()) { if (!isValidFormTextInput(name)) { formErrors.name = { @@ -334,12 +334,12 @@ export const validateWorkspaceForm = ( }; } if (isPermissionEnabled) { - formErrors.permissionSettings = validatePermissionSetting(permissionSettings); + formErrors.permissionSettings = validatePermissionSettings(permissionSettings); } - if (selectedDataSources) { + if (selectedDataSourceConnections) { const dataSourcesErrors: { [key: number]: WorkspaceFormError } = {}; - for (let i = 0; i < selectedDataSources.length; i++) { - const row = selectedDataSources[i]; + for (let i = 0; i < selectedDataSourceConnections.length; i++) { + const row = selectedDataSourceConnections[i]; if (!row.id) { dataSourcesErrors[i] = { code: WorkspaceFormErrorCode.InvalidDataSource, @@ -347,7 +347,9 @@ export const validateWorkspaceForm = ( defaultMessage: 'Invalid data source', }), }; - } else if (isSelectedDataSourcesDuplicated(selectedDataSources.slice(0, i), row)) { + } else if ( + isSelectedDataSourceConnectionsDuplicated(selectedDataSourceConnections.slice(0, i), row) + ) { dataSourcesErrors[i] = { code: WorkspaceFormErrorCode.DuplicateDataSource, message: i18n.translate('workspace.form.permission.invalidate.group', { @@ -357,7 +359,7 @@ export const validateWorkspaceForm = ( } } if (Object.keys(dataSourcesErrors).length > 0) { - formErrors.selectedDataSources = dataSourcesErrors; + formErrors.selectedDataSourceConnections = dataSourcesErrors; } } return formErrors; @@ -447,6 +449,34 @@ const isSamePermissionSetting = (a: PermissionSettingLike, b: PermissionSettingL ); }; +export const isWorkspacePermissionSetting = ( + permissionSetting: PermissionSettingLike +): permissionSetting is WorkspacePermissionSetting => { + const { modes, type, userId, group } = permissionSetting; + if (!modes) { + return false; + } + const arrayStringify = (array: string[]) => array.sort().join(); + const stringifyModes = arrayStringify(modes); + if ( + Object.values(optionIdToWorkspacePermissionModesMap).every( + (validModes) => arrayStringify([...validModes]) !== stringifyModes + ) + ) { + return false; + } + if (type !== WorkspacePermissionItemType.User && type !== WorkspacePermissionItemType.Group) { + return false; + } + if (type === WorkspacePermissionItemType.User && !userId) { + return false; + } + if (type === WorkspacePermissionItemType.Group && !group) { + return false; + } + return true; +}; + export const getNumberOfChanges = ( newFormData: Partial, initialFormData: Partial 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..9ac317f1ec07 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,16 +5,17 @@ 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 { WorkspaceFormProps, WorkspaceFormErrors, WorkspacePermissionSetting } from './types'; +import { DataSourceConnection } from '../../../common/types'; +import { WorkspaceFormProps, WorkspaceFormErrors } from './types'; import { PublicAppInfo } from '../../../../../core/public'; import { useWorkspaceForm } from './use_workspace_form'; +import { WorkspaceFormDataState } from '../workspace_form'; interface WorkspaceFormContextProps { formId: string; setName: React.Dispatch>; setDescription: React.Dispatch>; - formData: any; + formData: WorkspaceFormDataState; isEditing: boolean; formErrors: WorkspaceFormErrors; setIsEditing: React.Dispatch>; @@ -26,11 +27,9 @@ interface WorkspaceFormContextProps { handleColorChange: (text: string, output: EuiColorPickerOutput) => void; handleUseCaseChange: (newUseCase: string) => void; setPermissionSettings: React.Dispatch< - React.SetStateAction< - Array & Partial> - > + React.SetStateAction >; - setSelectedDataSources: React.Dispatch>; + setSelectedDataSourceConnections: React.Dispatch>; } const initialContextValue: WorkspaceFormContextProps = {} as WorkspaceFormContextProps; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx index 00560f7c033d..86f0d0688714 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx @@ -18,6 +18,9 @@ import { WorkspacePermissionItemType, optionIdToWorkspacePermissionModesMap, PermissionModeId, + PERMISSION_TYPE_LABEL_ID, + PERMISSION_COLLABORATOR_LABEL_ID, + PERMISSION_ACCESS_LEVEL_LABEL_ID, } from './constants'; import { getPermissionModeId } from './utils'; @@ -148,6 +151,7 @@ export const WorkspacePermissionSettingInput = ({ onChange={(value) => onTypeChange(value, index)} disabled={userOrGroupDisabled || !isEditing} data-test-subj="workspace-typeOptions" + aria-labelledby={PERMISSION_TYPE_LABEL_ID} />
@@ -166,6 +170,7 @@ export const WorkspacePermissionSettingInput = ({ defaultMessage: 'Enter group name or group ID', }) } + aria-labelledby={PERMISSION_COLLABORATOR_LABEL_ID} /> @@ -176,6 +181,7 @@ export const WorkspacePermissionSettingInput = ({ onChange={handlePermissionModeOptionChange} disabled={userOrGroupDisabled || !isEditing} data-test-subj="workspace-permissionModeOptions" + aria-labelledby={PERMISSION_ACCESS_LEVEL_LABEL_ID} /> diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx index 8ea255b83b36..845708d7ecbf 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx @@ -10,6 +10,7 @@ import { EuiFlexItem, EuiCompressedFormRow, EuiSpacer, + EuiFormLabel, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { WorkspaceFormError, WorkspacePermissionSetting } from './types'; @@ -17,6 +18,9 @@ import { WorkspacePermissionItemType, optionIdToWorkspacePermissionModesMap, PermissionModeId, + PERMISSION_TYPE_LABEL_ID, + PERMISSION_COLLABORATOR_LABEL_ID, + PERMISSION_ACCESS_LEVEL_LABEL_ID, } from './constants'; import { WorkspacePermissionSettingInput, @@ -130,35 +134,30 @@ export const WorkspacePermissionSettingPanel = ({ ); return ( -
- + <> + - + {i18n.translate('workspaceForm.permissionSetting.typeLabel', { defaultMessage: 'Type', })} - > - <> - + - + {i18n.translate('workspaceForm.permissionSetting.collaboratorLabel', { defaultMessage: 'Collaborator', })} - > - <> - + - - + + {i18n.translate('workspaceForm.permissionSetting.accessLevelLabel', { defaultMessage: 'Access level', })} - > - <> - + + {permissionSettings.map((item, index) => ( @@ -195,6 +194,6 @@ export const WorkspacePermissionSettingPanel = ({ })} )} -
+ ); }; diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index 2620b4703670..8a137a5c173d 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -266,28 +266,46 @@ export const getDirectQueryConnections = async (dataSourceId: string, http: Http return directQueryConnections; }; -// Helper function to merge data sources with direct query connections -export const mergeDataSourcesWithConnections = ( - assignedDataSources: DataSource[], - directQueryConnections: DataSourceConnection[] -): DataSourceConnection[] => { - const dataSources: DataSourceConnection[] = []; - assignedDataSources.forEach((ds) => { - const relatedConnections = directQueryConnections.filter( - (directQueryConnection) => directQueryConnection.parentId === ds.id - ); - - dataSources.push({ +export const convertDataSourcesToOpenSearchConnections = ( + dataSources: DataSource[] +): DataSourceConnection[] => + dataSources.map((ds) => { + return { id: ds.id, type: ds.dataSourceEngineType, connectionType: DataSourceConnectionType.OpenSearchConnection, name: ds.title, description: ds.description, + relatedConnections: [], + }; + }); + +export const fulfillRelatedConnections = ( + connections: DataSourceConnection[], + directQueryConnections: DataSourceConnection[] +) => { + return connections.map((connection) => { + const relatedConnections = directQueryConnections.filter( + (directQueryConnection) => directQueryConnection.parentId === connection.id + ); + return { + ...connection, relatedConnections, - }); + }; }); +}; - return [...dataSources, ...directQueryConnections]; +// Helper function to merge data sources with direct query connections +export const mergeDataSourcesWithConnections = ( + dataSources: DataSource[], + directQueryConnections: DataSourceConnection[] +): DataSourceConnection[] => { + const openSearchConnections = convertDataSourcesToOpenSearchConnections(dataSources); + + return [ + ...fulfillRelatedConnections(openSearchConnections, directQueryConnections), + ...directQueryConnections, + ].sort((a, b) => a.name.localeCompare(b.name)); }; // If all connected data sources are serverless, will only allow to select essential use case. @@ -474,21 +492,28 @@ export const getUseCaseUrl = ( return useCaseURL; }; +export const fetchDataSourceConnectionsByDataSourceIds = async ( + dataSourceIds: string[], + http: HttpSetup | undefined +) => { + const directQueryConnectionsPromises = dataSourceIds.map((dataSourceId) => + getDirectQueryConnections(dataSourceId, http!).catch(() => []) + ); + const directQueryConnectionsResult = await Promise.all(directQueryConnectionsPromises); + return directQueryConnectionsResult.flat(); +}; + export const fetchDataSourceConnections = async ( assignedDataSources: DataSource[], http: HttpSetup | undefined, notifications: NotificationsStart | undefined ) => { try { - const directQueryConnectionsPromises = assignedDataSources.map((ds) => - getDirectQueryConnections(ds.id, http!).catch(() => []) + const directQueryConnections = await fetchDataSourceConnectionsByDataSourceIds( + assignedDataSources.map((ds) => ds.id), + http ); - const directQueryConnectionsResult = await Promise.all(directQueryConnectionsPromises); - const directQueryConnections = directQueryConnectionsResult.flat(); - return mergeDataSourcesWithConnections( - assignedDataSources, - directQueryConnections - ).sort((a, b) => a.name.localeCompare(b.name)); + return mergeDataSourcesWithConnections(assignedDataSources, directQueryConnections); } catch (error) { notifications?.toasts.addDanger( i18n.translate('workspace.detail.dataSources.error.message', {