From 810651ccb24c53b593d1daac9df63c62d3189ad2 Mon Sep 17 00:00:00 2001 From: Kapian1234 Date: Tue, 27 Aug 2024 12:02:37 +0800 Subject: [PATCH 1/2] Refactor association modal Signed-off-by: Kapian1234 --- .../association_data_source_modal.tsx | 200 +++++++++++++++--- 1 file changed, 175 insertions(+), 25 deletions(-) diff --git a/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx b/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx index 999ac80f7ad0..2b2558f5ac73 100644 --- a/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Fragment, useEffect, useMemo, useState } from 'react'; +import { Fragment, useEffect, useMemo, useState, useCallback } from 'react'; import React from 'react'; import { EuiText, @@ -15,17 +15,43 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiSelectableOption, + EuiSpacer, + EuiButtonGroup, + EuiButtonGroupOptionProps, + EuiBadge, } from '@elastic/eui'; import { FormattedMessage } from 'react-intl'; +import { i18n } from '@osd/i18n'; import { getDataSourcesList } from '../../utils'; -import { DataSource } from '../../../common/types'; +import { DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; import { SavedObjectsStart } from '../../../../../core/public'; +const tabOptions: EuiButtonGroupOptionProps[] = [ + { + id: 'all', + label: i18n.translate('workspace.form.selectDataSource.subTitle', { + defaultMessage: 'All', + }), + }, + { + id: 'opensearch-connections', + label: i18n.translate('workspace.form.selectDataSource.subTitle', { + defaultMessage: 'OpenSearch connections', + }), + }, + { + id: 'direct-query-connections', + label: i18n.translate('workspace.form.selectDataSource.subTitle', { + defaultMessage: 'Direct query connections', + }), + }, +]; + export interface AssociationDataSourceModalProps { savedObjects: SavedObjectsStart; - assignedDataSources: DataSource[]; + assignedDataSources: DataSourceConnection[]; closeModal: () => void; - handleAssignDataSources: (dataSources: DataSource[]) => Promise; + handleAssignDataSources: (dataSources: DataSourceConnection[]) => Promise; } export const AssociationDataSourceModal = ({ @@ -34,60 +60,184 @@ export const AssociationDataSourceModal = ({ assignedDataSources, handleAssignDataSources, }: AssociationDataSourceModalProps) => { - const [options, setOptions] = useState([]); - const [allDataSources, setAllDataSources] = useState([]); + // const [options, setOptions] = useState>>([]); + // const [allOptions, setAlloptions] = useState([]); + const [allDataSources, setAllDataSources] = useState([]); + const [currentTab, setCurrentTab] = useState('all'); + const [allOptions, setAllOptions] = useState>>([]); useEffect(() => { getDataSourcesList(savedObjects.client, ['*']).then((result) => { - const filteredDataSources = result.filter( - ({ id }: DataSource) => !assignedDataSources.some((ds) => ds.id === id) - ); + const filteredDataSources: DataSourceConnection[] = result + .filter(({ id }) => !assignedDataSources.some((ds) => ds.id === id)) + .map((datasource) => { + return { + ...datasource, + name: datasource.title, + type: datasource.dataSourceEngineType, + connectionType: DataSourceConnectionType.OpenSearchConnection, + }; + }); + + // dqc + filteredDataSources.push({ + name: 's3 connection1', + description: 'this is a s3', + id: filteredDataSources[0].id + '-' + 's3 connection1', + parentId: filteredDataSources[0].id, + type: 's3', + connectionType: DataSourceConnectionType.DirectQueryConnection, + }); + filteredDataSources.push({ + name: 's3 connection2', + description: 'this is a s3', + id: filteredDataSources[0].id + '-' + 's3 connection2', + parentId: filteredDataSources[0].id, + type: 's3', + connectionType: DataSourceConnectionType.DirectQueryConnection, + }); + filteredDataSources[0].relatedConnections = [ + filteredDataSources[filteredDataSources.length - 1], + filteredDataSources[filteredDataSources.length - 2], + ]; + setAllDataSources(filteredDataSources); - setOptions( + setAllOptions( filteredDataSources.map((dataSource) => ({ - label: dataSource.title, + ...dataSource, + label: dataSource.name, key: dataSource.id, + append: dataSource.relatedConnections ? ( + + {i18n.translate('workspace.form.selectDataSource.optionBadge', { + defaultMessage: '+' + dataSource.relatedConnections.length + ' related', + })} + + ) : undefined, })) ); }); }, [assignedDataSources, savedObjects]); const selectedDataSources = useMemo(() => { - const selectedIds = options + const selectedIds = allOptions .filter((option: EuiSelectableOption) => option.checked) .map((option: EuiSelectableOption) => option.key); return allDataSources.filter((ds) => selectedIds.includes(ds.id)); - }, [options, allDataSources]); + }, [allOptions, allDataSources]); + + const options = useMemo(() => { + if (currentTab === 'all') { + return allOptions; + } + if (currentTab === 'opensearch-connections') { + return allOptions.filter( + (dataSource) => dataSource.connectionType === DataSourceConnectionType.OpenSearchConnection + ); + } + if (currentTab === 'direct-query-connections') { + return allOptions.filter( + (dataSource) => dataSource.connectionType === DataSourceConnectionType.DirectQueryConnection + ); + } + }, [allOptions, currentTab]); + + const handleSelectionChange = useCallback( + (newOptions: Array>) => { + const newCheckedOptionIds = newOptions + .filter(({ checked }) => checked === 'on') + .map(({ value }) => value); + + setAllOptions((prevOptions) => { + return prevOptions.map((option) => { + const checkedInNewOptions = newCheckedOptionIds.includes(option.value); + const connection = allDataSources.find(({ id }) => id === option.value); + option.checked = checkedInNewOptions ? 'on' : undefined; + + if (!connection) { + return option; + } + + if (connection.type === 'DS') { + const childDQCIds = allDataSources + .find(({ parentId }) => parentId === connection.id) + ?.relatedConnections?.map(({ id }) => id); + // Check if there any DQC change to checked status this time, set to "on" if exists. + if ( + newCheckedOptionIds.some( + (id) => + childDQCIds.includes(id) && + // This child DQC not checked before + !prevOptions.find(({ value, checked }) => value === id && checked === 'on') + ) + ) { + option.checked = 'on'; + } + } + + if (connection.type === 'DQC') { + const parentConnection = allDataSources.find(({ id }) => id === connection.id); + if (parentConnection) { + const isParentCheckedLastTime = prevOptions.find( + ({ value, checked }) => value === parentConnection.id && checked === 'on' + ); + const isParentCheckedThisTime = newCheckedOptionIds.includes(parentConnection.id); + + // Parent change to checked this time + if (!isParentCheckedLastTime && isParentCheckedThisTime) { + option.checked = 'on'; + } + + if (isParentCheckedLastTime && isParentCheckedThisTime) { + option.checked = undefined; + } + } + } + + return option; + }); + }); + }, + [allDataSources] + ); return ( - + -

- -

+
- + + + setCurrentTab(id)} + buttonSize="compressed" + // isFullWidth={true} + /> + setOptions(newOptions)} + onChange={handleSelectionChange} > {(list, search) => ( @@ -99,7 +249,7 @@ export const AssociationDataSourceModal = ({ - + From 2409005b7094eabd62137ed3321baacfef3f10e9 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Tue, 27 Aug 2024 16:15:24 +0800 Subject: [PATCH 2/2] Integrate modal with workspace detail page Signed-off-by: Lin Wang --- .../association_data_source_modal.tsx | 206 +++++++++--------- .../select_data_source_panel.tsx | 39 +++- src/plugins/workspace/public/hooks.ts | 26 --- src/plugins/workspace/public/utils.ts | 25 ++- 4 files changed, 152 insertions(+), 144 deletions(-) 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 2b2558f5ac73..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 @@ -22,25 +22,60 @@ import { } from '@elastic/eui'; import { FormattedMessage } from 'react-intl'; import { i18n } from '@osd/i18n'; -import { getDataSourcesList } from '../../utils'; + +import { getDataSourcesList, fetchDataSourceConnections } from '../../utils'; import { DataSourceConnection, DataSourceConnectionType } from '../../../common/types'; -import { SavedObjectsStart } from '../../../../../core/public'; +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: 'all', + id: AssociationDataSourceModalTab.All, label: i18n.translate('workspace.form.selectDataSource.subTitle', { defaultMessage: 'All', }), }, { - id: 'opensearch-connections', + id: AssociationDataSourceModalTab.OpenSearchConnections, label: i18n.translate('workspace.form.selectDataSource.subTitle', { defaultMessage: 'OpenSearch connections', }), }, { - id: 'direct-query-connections', + id: AssociationDataSourceModalTab.DirectQueryConnections, label: i18n.translate('workspace.form.selectDataSource.subTitle', { defaultMessage: 'Direct query connections', }), @@ -48,147 +83,92 @@ const tabOptions: EuiButtonGroupOptionProps[] = [ ]; export interface AssociationDataSourceModalProps { + http: HttpStart | undefined; + notifications: NotificationsStart | undefined; savedObjects: SavedObjectsStart; - assignedDataSources: DataSourceConnection[]; + assignedConnections: DataSourceConnection[]; closeModal: () => void; - handleAssignDataSources: (dataSources: DataSourceConnection[]) => Promise; + handleAssignDataSourceConnections: (connections: DataSourceConnection[]) => Promise; } export const AssociationDataSourceModal = ({ + http, + notifications, closeModal, savedObjects, - assignedDataSources, - handleAssignDataSources, + assignedConnections, + handleAssignDataSourceConnections, }: AssociationDataSourceModalProps) => { - // const [options, setOptions] = useState>>([]); - // const [allOptions, setAlloptions] = useState([]); - const [allDataSources, setAllDataSources] = useState([]); + const [allConnections, setAllConnections] = useState([]); const [currentTab, setCurrentTab] = useState('all'); - const [allOptions, setAllOptions] = useState>>([]); - - useEffect(() => { - getDataSourcesList(savedObjects.client, ['*']).then((result) => { - const filteredDataSources: DataSourceConnection[] = result - .filter(({ id }) => !assignedDataSources.some((ds) => ds.id === id)) - .map((datasource) => { - return { - ...datasource, - name: datasource.title, - type: datasource.dataSourceEngineType, - connectionType: DataSourceConnectionType.OpenSearchConnection, - }; - }); - - // dqc - filteredDataSources.push({ - name: 's3 connection1', - description: 'this is a s3', - id: filteredDataSources[0].id + '-' + 's3 connection1', - parentId: filteredDataSources[0].id, - type: 's3', - connectionType: DataSourceConnectionType.DirectQueryConnection, - }); - filteredDataSources.push({ - name: 's3 connection2', - description: 'this is a s3', - id: filteredDataSources[0].id + '-' + 's3 connection2', - parentId: filteredDataSources[0].id, - type: 's3', - connectionType: DataSourceConnectionType.DirectQueryConnection, - }); - filteredDataSources[0].relatedConnections = [ - filteredDataSources[filteredDataSources.length - 1], - filteredDataSources[filteredDataSources.length - 2], - ]; - - setAllDataSources(filteredDataSources); - setAllOptions( - filteredDataSources.map((dataSource) => ({ - ...dataSource, - label: dataSource.name, - key: dataSource.id, - append: dataSource.relatedConnections ? ( - - {i18n.translate('workspace.form.selectDataSource.optionBadge', { - defaultMessage: '+' + dataSource.relatedConnections.length + ' related', - })} - - ) : undefined, - })) - ); - }); - }, [assignedDataSources, savedObjects]); - - const selectedDataSources = useMemo(() => { - const selectedIds = allOptions - .filter((option: EuiSelectableOption) => option.checked) - .map((option: EuiSelectableOption) => option.key); - - return allDataSources.filter((ds) => selectedIds.includes(ds.id)); - }, [allOptions, allDataSources]); + const [allOptions, setAllOptions] = useState([]); + const [isLoading, setIsLoading] = useState(false); const options = useMemo(() => { - if (currentTab === 'all') { - return allOptions; - } - if (currentTab === 'opensearch-connections') { + if (currentTab === AssociationDataSourceModalTab.OpenSearchConnections) { return allOptions.filter( - (dataSource) => dataSource.connectionType === DataSourceConnectionType.OpenSearchConnection + ({ connection }) => + connection.connectionType === DataSourceConnectionType.OpenSearchConnection ); } - if (currentTab === 'direct-query-connections') { + if (currentTab === AssociationDataSourceModalTab.DirectQueryConnections) { return allOptions.filter( - (dataSource) => dataSource.connectionType === DataSourceConnectionType.DirectQueryConnection + ({ connection }) => + connection.connectionType === DataSourceConnectionType.DirectQueryConnection ); } + return allOptions; }, [allOptions, currentTab]); + const selectedConnections = useMemo( + () => allOptions.filter(({ checked }) => checked === 'on').map(({ connection }) => connection), + [allOptions] + ); + const handleSelectionChange = useCallback( - (newOptions: Array>) => { - const newCheckedOptionIds = newOptions + (newOptions: DataSourceModalOption[]) => { + const newCheckedConnectionIds = newOptions .filter(({ checked }) => checked === 'on') - .map(({ value }) => value); + .map(({ connection }) => connection.id); setAllOptions((prevOptions) => { return prevOptions.map((option) => { - const checkedInNewOptions = newCheckedOptionIds.includes(option.value); - const connection = allDataSources.find(({ id }) => id === option.value); + option = { ...option }; + const checkedInNewOptions = newCheckedConnectionIds.includes(option.connection.id); + const connection = option.connection; option.checked = checkedInNewOptions ? 'on' : undefined; - if (!connection) { - return option; - } - - if (connection.type === 'DS') { - const childDQCIds = allDataSources - .find(({ parentId }) => parentId === connection.id) - ?.relatedConnections?.map(({ id }) => id); + 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 ( - newCheckedOptionIds.some( + newCheckedConnectionIds.some( (id) => childDQCIds.includes(id) && // This child DQC not checked before - !prevOptions.find(({ value, checked }) => value === id && checked === 'on') + !prevOptions.find((item) => item.connection.id === id && item.checked === 'on') ) ) { option.checked = 'on'; } } - if (connection.type === 'DQC') { - const parentConnection = allDataSources.find(({ id }) => id === connection.id); + if (connection.connectionType === DataSourceConnectionType.DirectQueryConnection) { + const parentConnection = allConnections.find(({ id }) => id === connection.parentId); if (parentConnection) { - const isParentCheckedLastTime = prevOptions.find( - ({ value, checked }) => value === parentConnection.id && checked === 'on' + const isParentCheckedLastTime = !!prevOptions.find( + (item) => item.connection.id === parentConnection.id && item.checked === 'on' ); - const isParentCheckedThisTime = newCheckedOptionIds.includes(parentConnection.id); + 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; } @@ -199,9 +179,25 @@ export const AssociationDataSourceModal = ({ }); }); }, - [allDataSources] + [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 ( @@ -226,7 +222,6 @@ export const AssociationDataSourceModal = ({ idSelected={currentTab} onChange={(id) => setCurrentTab(id)} buttonSize="compressed" - // isFullWidth={true} /> {(list, search) => ( @@ -256,8 +252,8 @@ export const AssociationDataSourceModal = ({ /> handleAssignDataSources(selectedDataSources)} - isDisabled={!selectedDataSources || selectedDataSources.length === 0} + onClick={() => handleAssignDataSourceConnections(selectedConnections)} + isDisabled={!selectedConnections || selectedConnections.length === 0} fill > ([]); + 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 []; + } +};