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..037eaeae8d2c 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,79 +15,225 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiSelectableOption, + EuiSpacer, + EuiButtonGroup, + EuiButtonGroupOptionProps, + EuiBadge, } from '@elastic/eui'; import { FormattedMessage } from 'react-intl'; -import { getDataSourcesList } from '../../utils'; -import { DataSource } from '../../../common/types'; -import { SavedObjectsStart } from '../../../../../core/public'; +import { i18n } from '@osd/i18n'; + +import { getDataSourcesList, fetchDataSourceConnections } from '../../utils'; +import { DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; +import { HttpStart, NotificationsStart, SavedObjectsStart } from '../../../../../core/public'; + +type DataSourceModalOption = EuiSelectableOption<{ connection: DataSourceConnection }>; + +const convertConnectionsToOptions = ( + connections: DataSourceConnection[], + assignedConnections: DataSourceConnection[] +) => { + const assignedConnectionIds = assignedConnections.map(({ id }) => id); + return connections + .filter((connection) => !assignedConnectionIds.includes(connection.id)) + .map((connection) => ({ + label: connection.name, + key: connection.id, + append: + connection.relatedConnections && connection.relatedConnections.length > 0 ? ( + + {i18n.translate('workspace.form.selectDataSource.optionBadge', { + defaultMessage: '+ {relatedConnections} related', + values: { + relatedConnections: connection.relatedConnections.length, + }, + })} + + ) : undefined, + connection, + checked: undefined, + })); +}; + +enum AssociationDataSourceModalTab { + All = 'all', + OpenSearchConnections = 'opensearch-connections', + DirectQueryConnections = 'direction-query-connections', +} + +const tabOptions: EuiButtonGroupOptionProps[] = [ + { + id: AssociationDataSourceModalTab.All, + label: i18n.translate('workspace.form.selectDataSource.subTitle', { + defaultMessage: 'All', + }), + }, + { + id: AssociationDataSourceModalTab.OpenSearchConnections, + label: i18n.translate('workspace.form.selectDataSource.subTitle', { + defaultMessage: 'OpenSearch connections', + }), + }, + { + id: AssociationDataSourceModalTab.DirectQueryConnections, + label: i18n.translate('workspace.form.selectDataSource.subTitle', { + defaultMessage: 'Direct query connections', + }), + }, +]; export interface AssociationDataSourceModalProps { + http: HttpStart | undefined; + notifications: NotificationsStart | undefined; savedObjects: SavedObjectsStart; - assignedDataSources: DataSource[]; + assignedConnections: DataSourceConnection[]; closeModal: () => void; - handleAssignDataSources: (dataSources: DataSource[]) => Promise; + handleAssignDataSourceConnections: (connections: DataSourceConnection[]) => Promise; } export const AssociationDataSourceModal = ({ + http, + notifications, closeModal, savedObjects, - assignedDataSources, - handleAssignDataSources, + assignedConnections, + handleAssignDataSourceConnections, }: AssociationDataSourceModalProps) => { - const [options, setOptions] = useState([]); - const [allDataSources, setAllDataSources] = useState([]); + const [allConnections, setAllConnections] = useState([]); + const [currentTab, setCurrentTab] = useState('all'); + const [allOptions, setAllOptions] = useState([]); + const [isLoading, setIsLoading] = useState(false); - useEffect(() => { - getDataSourcesList(savedObjects.client, ['*']).then((result) => { - const filteredDataSources = result.filter( - ({ id }: DataSource) => !assignedDataSources.some((ds) => ds.id === id) + const options = useMemo(() => { + if (currentTab === AssociationDataSourceModalTab.OpenSearchConnections) { + return allOptions.filter( + ({ connection }) => + connection.connectionType === DataSourceConnectionType.OpenSearchConnection ); - setAllDataSources(filteredDataSources); - setOptions( - filteredDataSources.map((dataSource) => ({ - label: dataSource.title, - key: dataSource.id, - })) + } + if (currentTab === AssociationDataSourceModalTab.DirectQueryConnections) { + return allOptions.filter( + ({ connection }) => + connection.connectionType === DataSourceConnectionType.DirectQueryConnection ); - }); - }, [assignedDataSources, savedObjects]); + } + return allOptions; + }, [allOptions, currentTab]); + + const selectedConnections = useMemo( + () => allOptions.filter(({ checked }) => checked === 'on').map(({ connection }) => connection), + [allOptions] + ); + + const handleSelectionChange = useCallback( + (newOptions: DataSourceModalOption[]) => { + const newCheckedConnectionIds = newOptions + .filter(({ checked }) => checked === 'on') + .map(({ connection }) => connection.id); - const selectedDataSources = useMemo(() => { - const selectedIds = options - .filter((option: EuiSelectableOption) => option.checked) - .map((option: EuiSelectableOption) => option.key); + setAllOptions((prevOptions) => { + return prevOptions.map((option) => { + option = { ...option }; + const checkedInNewOptions = newCheckedConnectionIds.includes(option.connection.id); + const connection = option.connection; + option.checked = checkedInNewOptions ? 'on' : undefined; - return allDataSources.filter((ds) => selectedIds.includes(ds.id)); - }, [options, allDataSources]); + if (connection.connectionType === DataSourceConnectionType.OpenSearchConnection) { + const childDQCIds = allConnections + .filter(({ parentId }) => parentId === connection.id) + .map(({ id }) => id); + // Check if there any DQC change to checked status this time, set to "on" if exists. + if ( + newCheckedConnectionIds.some( + (id) => + childDQCIds.includes(id) && + // This child DQC not checked before + !prevOptions.find((item) => item.connection.id === id && item.checked === 'on') + ) + ) { + option.checked = 'on'; + } + } + + if (connection.connectionType === DataSourceConnectionType.DirectQueryConnection) { + const parentConnection = allConnections.find(({ id }) => id === connection.parentId); + if (parentConnection) { + const isParentCheckedLastTime = !!prevOptions.find( + (item) => item.connection.id === parentConnection.id && item.checked === 'on' + ); + const isParentCheckedThisTime = newCheckedConnectionIds.includes(parentConnection.id); + + // Parent change to checked this time + if (!isParentCheckedLastTime && isParentCheckedThisTime) { + option.checked = 'on'; + } + + // This won't be executed since checked options already been filter out + if (isParentCheckedLastTime && isParentCheckedThisTime) { + option.checked = undefined; + } + } + } + + return option; + }); + }); + }, + [allConnections] + ); + + useEffect(() => { + setIsLoading(true); + getDataSourcesList(savedObjects.client, ['*']) + .then((dataSourcesList) => fetchDataSourceConnections(dataSourcesList, http, notifications)) + .then((connections) => { + setAllConnections(connections); + }) + .finally(() => { + setIsLoading(false); + }); + }, [savedObjects.client, http, notifications]); + + useEffect(() => { + setAllOptions(convertConnectionsToOptions(allConnections, assignedConnections)); + }, [allConnections, assignedConnections]); return ( - + -

- -

+
- + + + setCurrentTab(id)} + buttonSize="compressed" + /> + setOptions(newOptions)} + onChange={handleSelectionChange} + isLoading={isLoading} > {(list, search) => ( @@ -99,20 +245,20 @@ export const AssociationDataSourceModal = ({ - + handleAssignDataSources(selectedDataSources)} - isDisabled={!selectedDataSources || selectedDataSources.length === 0} + onClick={() => handleAssignDataSourceConnections(selectedConnections)} + isDisabled={!selectedConnections || selectedConnections.length === 0} fill > 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 894bd93ebc86..0eb67ba52dea 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, useEffect, useMemo, useState } from 'react'; import { EuiText, EuiTitle, @@ -20,14 +20,14 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from 'react-intl'; -import { DataSource, DataSourceConnection } from '../../../common/types'; +import { DataSource, DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; import { WorkspaceClient } from '../../workspace_client'; import { OpenSearchConnectionTable } from './opensearch_connections_table'; import { AssociationDataSourceModal } from './association_data_source_modal'; import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; import { CoreStart, SavedObjectsStart, WorkspaceObject } from '../../../../../core/public'; import { convertPermissionSettingsToPermissions, useWorkspaceFormContext } from '../workspace_form'; -import { useFetchDQC } from '../../hooks'; +import { fetchDataSourceConnections } from '../../utils'; export interface SelectDataSourcePanelProps { savedObjects: SavedObjectsStart; @@ -50,17 +50,18 @@ export const SelectDataSourceDetailPanel = ({ const { formData, setSelectedDataSources } = useWorkspaceFormContext(); const [isLoading, setIsLoading] = useState(false); const [isVisible, setIsVisible] = useState(false); - const [dataSourceConnections, setDataSourceConnections] = useState([]); + const [assignedDataSourceConnections, setAssignedDataSourceConnections] = useState< + DataSourceConnection[] + >([]); const [toggleIdSelected, setToggleIdSelected] = useState('all'); - const fetchDQC = useFetchDQC(assignedDataSources, http, notifications); useEffect(() => { setIsLoading(true); - fetchDQC().then((res) => { - setDataSourceConnections(res); + fetchDataSourceConnections(assignedDataSources, http, notifications).then((connections) => { + setAssignedDataSourceConnections(connections); setIsLoading(false); }); - }, [fetchDQC]); + }, [assignedDataSources, http, notifications]); const toggleButtons = [ { @@ -83,7 +84,19 @@ export const SelectDataSourceDetailPanel = ({ }, ]; - const handleAssignDataSources = async (dataSources: DataSource[]) => { + const handleAssignDataSourceConnections = async ( + dataSourceConnections: 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); @@ -228,7 +241,7 @@ export const SelectDataSourceDetailPanel = ({ ); @@ -262,10 +275,12 @@ export const SelectDataSourceDetailPanel = ({ {renderTableContent()} {isVisible && ( setIsVisible(false)} - handleAssignDataSources={handleAssignDataSources} + assignedConnections={assignedDataSourceConnections} + handleAssignDataSourceConnections={handleAssignDataSourceConnections} /> )} diff --git a/src/plugins/workspace/public/hooks.ts b/src/plugins/workspace/public/hooks.ts index 93a2e4a38e79..0e036fcd04ce 100644 --- a/src/plugins/workspace/public/hooks.ts +++ b/src/plugins/workspace/public/hooks.ts @@ -25,29 +25,3 @@ export function useApplications(applicationInstance: ApplicationStart) { return apps; }, [applications]); } - -export const useFetchDQC = ( - assignedDataSources: DataSource[], - http: HttpSetup | undefined, - notifications: NotificationsStart | undefined -) => { - const fetchDQC = useCallback(async () => { - try { - const directQueryConnectionsPromises = assignedDataSources.map((ds) => - getDirectQueryConnections(ds.id, http!) - ); - const directQueryConnectionsResult = await Promise.all(directQueryConnectionsPromises); - const directQueryConnections = directQueryConnectionsResult.flat(); - return mergeDataSourcesWithConnections(assignedDataSources, directQueryConnections); - } catch (error) { - notifications?.toasts.addDanger( - i18n.translate('workspace.detail.dataSources.error.message', { - defaultMessage: 'Cannot fetch direct query connections', - }) - ); - return []; - } - }, [assignedDataSources, http, notifications?.toasts]); - - return fetchDQC; -}; diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index e48a0682ad74..9061c3425e39 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -2,7 +2,7 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ - +import { i18n } from '@osd/i18n'; import { combineLatest } from 'rxjs'; import { NavGroupType, @@ -13,6 +13,7 @@ import { ChromeBreadcrumb, ApplicationStart, HttpSetup, + NotificationsStart, } from '../../../core/public'; import { App, @@ -436,3 +437,25 @@ export const getUseCaseUrl = ( ); return useCaseURL; }; + +export const fetchDataSourceConnections = async ( + assignedDataSources: DataSource[], + http: HttpSetup | undefined, + notifications: NotificationsStart | undefined +) => { + try { + const directQueryConnectionsPromises = assignedDataSources.map((ds) => + getDirectQueryConnections(ds.id, http!).catch(() => []) + ); + const directQueryConnectionsResult = await Promise.all(directQueryConnectionsPromises); + const directQueryConnections = directQueryConnectionsResult.flat(); + return mergeDataSourcesWithConnections(assignedDataSources, directQueryConnections); + } catch (error) { + notifications?.toasts.addDanger( + i18n.translate('workspace.detail.dataSources.error.message', { + defaultMessage: 'Cannot fetch direct query connections', + }) + ); + return []; + } +};