From 034a2c22ceaf0f60c1b29842504a6c691d5a291c Mon Sep 17 00:00:00 2001 From: "opensearch-trigger-bot[bot]" <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Date: Mon, 19 Aug 2024 11:21:05 +0800 Subject: [PATCH] [Workspace] Refactor workspace detail page (#7598) (#7729) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor workspace detail page * Changeset file for PR #7598 created/updated * add datasource list page * add workspace detail hash router * reset useless update * optimize the code * add unit tests * add unit tests * Fix left navigation overlapped with bottom bar * update workspace detail use case * It should only support change to the “ALL” use case * fix error * fix errors * optimize the code * optimize the code * fix test error --------- (cherry picked from commit 2165e036a23764b055ac21acadb18894145c9e72) Signed-off-by: yubonluo Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7598.yml | 2 + src/plugins/workspace/common/types.ts | 5 +- src/plugins/workspace/public/application.tsx | 10 +- .../public/components/utils/workspace.test.ts | 27 +- .../public/components/utils/workspace.ts | 11 +- .../workspace_detail.test.tsx.snap | 606 +++++++++++++++++- .../association_data_source_modal.tsx | 121 ++++ .../opensearch_connections_table.tsx | 224 +++++++ .../select_data_source_panel.test.tsx | 304 +++++++++ .../select_data_source_panel.tsx | 183 ++++++ .../workspace_bottom_bar.test.tsx | 54 ++ .../workspace_detail/workspace_bottom_bar.tsx | 101 +++ .../workspace_detail.test.tsx | 200 +++++- .../workspace_detail/workspace_detail.tsx | 243 +++++-- .../workspace_detail_content.tsx | 48 -- .../workspace_detail_panel.tsx | 140 ++++ .../workspace_updater.test.tsx | 359 ----------- .../workspace_detail/workspace_updater.tsx | 174 ----- .../components/workspace_detail_app.tsx | 144 ++++- .../components/workspace_form/constants.ts | 70 +- .../public/components/workspace_form/index.ts | 1 + .../public/components/workspace_form/types.ts | 1 + .../workspace_form/use_workspace_form.test.ts | 19 + .../workspace_form/use_workspace_form.ts | 17 +- .../workspace_form/workspace_bottom_bar.tsx | 87 --- .../workspace_form/workspace_detail_form.tsx | 197 +++--- .../workspace_detail_form_details.tsx | 140 ++++ .../workspace_form/workspace_form.tsx | 15 +- .../workspace_form/workspace_form_context.tsx | 69 ++ .../workspace_permission_setting_input.tsx | 8 +- .../workspace_permission_setting_panel.tsx | 36 +- .../workspace_menu/workspace_menu.tsx | 16 +- src/plugins/workspace/public/utils.test.ts | 17 +- src/plugins/workspace/public/utils.ts | 27 +- .../workspace/public/workspace_client.ts | 3 + 35 files changed, 2748 insertions(+), 931 deletions(-) create mode 100644 changelogs/fragments/7598.yml create mode 100644 src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx create mode 100644 src/plugins/workspace/public/components/workspace_detail/opensearch_connections_table.tsx create mode 100644 src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx create mode 100644 src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.tsx delete mode 100644 src/plugins/workspace/public/components/workspace_detail/workspace_detail_content.tsx create mode 100644 src/plugins/workspace/public/components/workspace_detail/workspace_detail_panel.tsx delete mode 100644 src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx delete mode 100644 src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx delete mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_detail_form_details.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx diff --git a/changelogs/fragments/7598.yml b/changelogs/fragments/7598.yml new file mode 100644 index 00000000000..85bcda12a9d --- /dev/null +++ b/changelogs/fragments/7598.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace] Refactor workspace detail page ([#7598](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7598)) \ No newline at end of file diff --git a/src/plugins/workspace/common/types.ts b/src/plugins/workspace/common/types.ts index 42c8ee31b0d..cf621a09143 100644 --- a/src/plugins/workspace/common/types.ts +++ b/src/plugins/workspace/common/types.ts @@ -5,7 +5,10 @@ import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; -export type DataSource = Pick & { +export type DataSource = Pick< + DataSourceAttributes, + 'title' | 'description' | 'dataSourceEngineType' +> & { // Id defined in SavedObjectAttribute could be single or array, here only should be single string. id: string; }; diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index 3e9cb3a506e..31965012d16 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -5,6 +5,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; +import { HashRouter as Router, Route, Switch, Redirect } from 'react-router-dom'; import { AppMountParameters, ScopedHistory } from '../../../core/public'; import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; import { WorkspaceFatalError } from './components/workspace_fatal_error'; @@ -14,6 +15,7 @@ import { Services } from './types'; import { WorkspaceCreatorProps } from './components/workspace_creator/workspace_creator'; import { WorkspaceDetailApp } from './components/workspace_detail_app'; import { WorkspaceDetailProps } from './components/workspace_detail/workspace_detail'; +import { DetailTab } from './components/workspace_form/constants'; export const renderCreatorApp = ( { element }: AppMountParameters, @@ -70,7 +72,13 @@ export const renderDetailApp = ( ) => { ReactDOM.render( - + + + + + + + , element ); diff --git a/src/plugins/workspace/public/components/utils/workspace.test.ts b/src/plugins/workspace/public/components/utils/workspace.test.ts index 5676b0d1aa9..f1ab368fe8c 100644 --- a/src/plugins/workspace/public/components/utils/workspace.test.ts +++ b/src/plugins/workspace/public/components/utils/workspace.test.ts @@ -22,19 +22,28 @@ describe('workspace utils', () => { describe('navigateToWorkspaceDetail', () => { it('should redirect if newUrl is returned', () => { - Object.defineProperty(window, 'location', { - value: { - href: defaultUrl, - }, - writable: true, - }); // @ts-ignore - formatUrlWithWorkspaceId.mockImplementation(() => 'new_url'); + formatUrlWithWorkspaceId.mockImplementation(() => 'localhost:5601/w/id/app/workspace_detail'); navigateToWorkspaceDetail( { application: coreStartMock.application, http: coreStartMock.http }, - '' + 'id' + ); + expect(mockNavigateToUrl).toHaveBeenCalledWith( + 'localhost:5601/w/id/app/workspace_detail#/?tab=details' + ); + }); + + it('should redirect to collaborators if newUrl is returned and tab id is collaborators', () => { + // @ts-ignore + formatUrlWithWorkspaceId.mockImplementation(() => 'localhost:5601/w/id/app/workspace_detail'); + navigateToWorkspaceDetail( + { application: coreStartMock.application, http: coreStartMock.http }, + 'id', + 'collaborators' + ); + expect(mockNavigateToUrl).toHaveBeenCalledWith( + 'localhost:5601/w/id/app/workspace_detail#/?tab=collaborators' ); - expect(mockNavigateToUrl).toHaveBeenCalledWith('new_url'); }); it('should not redirect if newUrl is not returned', () => { diff --git a/src/plugins/workspace/public/components/utils/workspace.ts b/src/plugins/workspace/public/components/utils/workspace.ts index c7ba243bdf9..3acd26570f5 100644 --- a/src/plugins/workspace/public/components/utils/workspace.ts +++ b/src/plugins/workspace/public/components/utils/workspace.ts @@ -6,10 +6,15 @@ import { WORKSPACE_DETAIL_APP_ID } from '../../../common/constants'; import { CoreStart } from '../../../../../core/public'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { DetailTab } from '../workspace_form/constants'; type Core = Pick; -export const navigateToWorkspaceDetail = ({ application, http }: Core, id: string) => { +export const navigateToWorkspaceDetail = ( + { application, http }: Core, + id: string, + tabId: string = DetailTab.Details +) => { const newUrl = formatUrlWithWorkspaceId( application.getUrlForApp(WORKSPACE_DETAIL_APP_ID, { absolute: true, @@ -18,6 +23,8 @@ export const navigateToWorkspaceDetail = ({ application, http }: Core, id: strin http.basePath ); if (newUrl) { - application.navigateToUrl(newUrl); + const url = new URL(newUrl); + url.hash = `/?tab=${tabId}`; + application.navigateToUrl(url.toString()); } }; diff --git a/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap b/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap index e6da2eebcf4..b9e165a79c3 100644 --- a/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap +++ b/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap @@ -3,8 +3,207 @@ exports[`WorkspaceDetail render workspace detail page normally 1`] = `
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ this is my foo workspace description +
+
+
+
+
+
+
+
+

+ Use case +

+

+

+
+
+
+

+ Owner +

+

+ user1 +    + +

+
+
+
+
+

+ Last updated +

+

+ Invalid date +

+
+
+
+
+

+ ID +

+

+ foo_id + + + +

+
+
+
+
+

+ Workspace overview +

+

+ + Overview + + +

+
+
+
+
+
@@ -19,28 +218,28 @@ exports[`WorkspaceDetail render workspace detail page normally 1`] = ` aria-controls="generated-id" aria-selected="true" class="euiTab euiTab-isSelected" - id="overview" + id="details" role="tab" type="button" > - Overview + Details
-
+
+

+ Details +

+
+
+ +
+
+
+
+
+

+ Name +

+
+
+
+
+ +
+
+
+
+ +
+
+
+ + 37 characters left. + +
+ Use a unique name for the workspace. Valid characters are a-z, A-Z, 0-9, (), [], _ (underscore), - (hyphen) and (space). +
+
+
+
+
+
+
+

+ Description - + + optional + +

+
+
+ Describe the workspace. +
+
+
+
- - About - + Description - + + optional + + +
+
+
-

- this is my foo workspace description -

+ + 164 characters left. +
+
+
+
+
+ class="euiFlexItem" + > +

+ Use case +

+
+
+
+
+ +
+
+
+
+
+ +
+
+ + Select an option: Observability, is selected + + +
+ + +
+
+
+
+
+
+
+ You can only choose use cases with more features than the current use case. +
+
+
+
+
+
+
+
+
+

+ Workspace icon color +

+
+
+ The background color of the icon that represents the workspace. +
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+ +
+ + +
+
+
+
+
+
+
+
+
+
+
-
+
+
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 new file mode 100644 index 00000000000..999ac80f7ad --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/association_data_source_modal.tsx @@ -0,0 +1,121 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Fragment, useEffect, useMemo, useState } from 'react'; +import React from 'react'; +import { + EuiText, + EuiModal, + EuiButton, + EuiModalBody, + EuiSelectable, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSelectableOption, +} from '@elastic/eui'; +import { FormattedMessage } from 'react-intl'; +import { getDataSourcesList } from '../../utils'; +import { DataSource } from '../../../common/types'; +import { SavedObjectsStart } from '../../../../../core/public'; + +export interface AssociationDataSourceModalProps { + savedObjects: SavedObjectsStart; + assignedDataSources: DataSource[]; + closeModal: () => void; + handleAssignDataSources: (dataSources: DataSource[]) => Promise; +} + +export const AssociationDataSourceModal = ({ + closeModal, + savedObjects, + assignedDataSources, + handleAssignDataSources, +}: AssociationDataSourceModalProps) => { + const [options, setOptions] = useState([]); + const [allDataSources, setAllDataSources] = useState([]); + + useEffect(() => { + getDataSourcesList(savedObjects.client, ['*']).then((result) => { + const filteredDataSources = result.filter( + ({ id }: DataSource) => !assignedDataSources.some((ds) => ds.id === id) + ); + setAllDataSources(filteredDataSources); + setOptions( + filteredDataSources.map((dataSource) => ({ + label: dataSource.title, + key: dataSource.id, + })) + ); + }); + }, [assignedDataSources, savedObjects]); + + const selectedDataSources = useMemo(() => { + const selectedIds = options + .filter((option: EuiSelectableOption) => option.checked) + .map((option: EuiSelectableOption) => option.key); + + return allDataSources.filter((ds) => selectedIds.includes(ds.id)); + }, [options, allDataSources]); + + return ( + + + +

+ +

+
+
+ + + + + setOptions(newOptions)} + > + {(list, search) => ( + + {search} + {list} + + )} + + + + + + + + handleAssignDataSources(selectedDataSources)} + isDisabled={!selectedDataSources || selectedDataSources.length === 0} + fill + > + + + +
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_detail/opensearch_connections_table.tsx b/src/plugins/workspace/public/components/workspace_detail/opensearch_connections_table.tsx new file mode 100644 index 00000000000..ed6ce46965a --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/opensearch_connections_table.tsx @@ -0,0 +1,224 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo, useState } from 'react'; +import { + EuiSpacer, + EuiButton, + EuiFlexItem, + EuiFlexGroup, + EuiFieldSearch, + EuiInMemoryTable, + EuiBasicTableColumn, + EuiTableSelectionType, + EuiTableActionsColumnType, + EuiConfirmModal, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { CoreStart, WorkspaceObject } from '../../../../../core/public'; +import { DataSource } from '../../../common/types'; +import { WorkspaceClient } from '../../workspace_client'; +import { convertPermissionSettingsToPermissions, useWorkspaceFormContext } from '../workspace_form'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; + +interface OpenSearchConnectionTableProps { + assignedDataSources: DataSource[]; + isDashboardAdmin: boolean; + currentWorkspace: WorkspaceObject; + setIsLoading: React.Dispatch>; +} + +export const OpenSearchConnectionTable = ({ + assignedDataSources, + isDashboardAdmin, + currentWorkspace, + setIsLoading, +}: OpenSearchConnectionTableProps) => { + const { + services: { notifications, workspaceClient }, + } = useOpenSearchDashboards<{ CoreStart: CoreStart; workspaceClient: WorkspaceClient }>(); + const { formData, setSelectedDataSources } = useWorkspaceFormContext(); + const [searchTerm, setSearchTerm] = useState(''); + const [SelectedItems, setSelectedItems] = useState([]); + const [assignItems, setAssignItems] = useState([]); + const [modalVisible, setModalVisible] = useState(false); + + const filteredDataSources = useMemo( + () => + assignedDataSources.filter((dataSource) => + dataSource.title.toLowerCase().includes(searchTerm.toLowerCase()) + ), + [searchTerm, assignedDataSources] + ); + + const onSelectionChange = (selectedItems: DataSource[]) => { + setSelectedItems(selectedItems); + setAssignItems(selectedItems); + }; + + const handleUnassignDataSources = async (dataSources: DataSource[]) => { + try { + setIsLoading(true); + setModalVisible(false); + const { permissionSettings, selectedDataSources, useCase, ...attributes } = formData; + const savedDataSources = (selectedDataSources ?? [])?.filter( + ({ id }: DataSource) => !dataSources.some((item) => item.id === id) + ); + + const result = await workspaceClient.update(currentWorkspace.id, attributes, { + dataSources: savedDataSources.map(({ id }: DataSource) => id), + permissions: convertPermissionSettingsToPermissions(permissionSettings), + }); + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.detail.dataSources.unassign.success', { + defaultMessage: 'Remove associated OpenSearch connections successfully', + }), + }); + setSelectedDataSources(savedDataSources); + } else { + throw new Error( + result?.error ? result?.error : 'Remove associated OpenSearch connections failed' + ); + } + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.detail.dataSources.unassign.failed', { + defaultMessage: 'Failed to remove associated OpenSearch connections', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + } finally { + setIsLoading(false); + } + }; + + const columns: Array> = [ + { + field: 'title', + name: i18n.translate('workspace.detail.dataSources.table.title', { + defaultMessage: 'Title', + }), + truncateText: true, + }, + { + field: 'dataSourceEngineType', + name: i18n.translate('workspace.detail.dataSources.table.type', { + defaultMessage: 'Type', + }), + truncateText: true, + }, + { + field: 'description', + name: i18n.translate('workspace.detail.dataSources.table.description', { + defaultMessage: 'Description', + }), + truncateText: true, + }, + ...(isDashboardAdmin + ? [ + { + name: i18n.translate('workspace.detail.dataSources.table.actions', { + defaultMessage: 'Actions', + }), + actions: [ + { + name: i18n.translate('workspace.detail.dataSources.table.actions.remove.name', { + defaultMessage: 'Remove association', + }), + isPrimary: true, + description: i18n.translate( + 'workspace.detail.dataSources.table.actions.remove.description', + { + defaultMessage: 'Remove association', + } + ), + icon: 'unlink', + type: 'icon', + onClick: (item: DataSource) => { + setAssignItems([item]); + setModalVisible(true); + }, + 'data-test-subj': 'workspace-detail-dataSources-table-actions-remove', + }, + ], + } as EuiTableActionsColumnType, + ] + : []), + ]; + + const selection: EuiTableSelectionType = { + selectable: () => isDashboardAdmin, + onSelectionChange, + }; + + const handleSearch = (e: React.ChangeEvent) => { + setSearchTerm(e.target.value); + }; + 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 }, + })} + + + )} + + + + + + + + {modalVisible && ( + { + setModalVisible(false); + }} + onConfirm={() => { + handleUnassignDataSources(assignItems); + }} + cancelButtonText={i18n.translate('workspace.detail.dataSources.modal.cancelButton', { + defaultMessage: 'Cancel', + })} + confirmButtonText={i18n.translate('workspace.detail.dataSources.Modal.confirmButton', { + defaultMessage: 'Remove connections', + })} + buttonColor="danger" + defaultFocusedButton="confirm" + /> + )} + + ); +}; 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 new file mode 100644 index 00000000000..6a81482096e --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.test.tsx @@ -0,0 +1,304 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +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 * as utils from '../../utils'; +import { IntlProvider } from 'react-intl'; + +const mockCoreStart = coreMock.createStart(); + +const workspaceObject = { + id: 'foo_id', + name: 'foo', + description: 'this is my foo workspace description', + features: ['use-case-observability', 'workspace_detail'], +}; + +const dataSources = [ + { + id: 'ds-1', + title: 'ds-1-title', + description: 'ds-1-description', + }, + { + id: 'ds-2', + title: 'ds-2-title', + description: 'ds-2-description', + }, +]; +jest.spyOn(utils, 'getDataSourcesList').mockResolvedValue(dataSources); + +const defaultValues = { + id: workspaceObject.id, + name: workspaceObject.name, + features: workspaceObject.features, + selectedDataSources: [dataSources[0]], +}; + +const defaultProps = { + savedObjects: {}, + assignedDataSources: [], + detailTitle: 'Data Sources', + isDashboardAdmin: true, + currentWorkspace: workspaceObject, +}; + +const notificationToastsAddSuccess = jest.fn(); +const notificationToastsAddDanger = jest.fn(); + +const success = jest.fn().mockResolvedValue({ + success: true, +}); +const failed = jest.fn().mockResolvedValue({}); + +const WorkspaceDetailPage = (props: any) => { + const { Provider } = createOpenSearchDashboardsReactContext({ + ...mockCoreStart, + ...{ + notifications: { + ...mockCoreStart.notifications, + toasts: { + ...mockCoreStart.notifications.toasts, + addDanger: notificationToastsAddDanger, + addSuccess: notificationToastsAddSuccess, + }, + }, + workspaceClient: { + update: props.action, + }, + }, + }); + + return ( + + + + + + + + ); +}; + +describe('WorkspaceDetail', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders "No associated data sources" message when no data sources are assigned', () => { + const { getByText } = render(WorkspaceDetailPage(defaultProps)); + expect(getByText('No associated data sources')).toBeInTheDocument(); + expect( + getByText('No OpenSearch connections are available in this workspace.') + ).toBeInTheDocument(); + }); + + it('should not show Association OpenSearch Connections button when user is not OSD admin', () => { + const { getByText, queryByText } = render( + WorkspaceDetailPage({ + ...defaultProps, + isDashboardAdmin: false, + }) + ); + expect( + getByText('Contact your administrator to associate data sources with the workspace.') + ).toBeInTheDocument(); + expect(queryByText('Association OpenSearch Connections')).toBeNull(); + }); + + it('should click on Association OpenSearch Connections button', async () => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 600, + }); + const { getByText } = render( + WorkspaceDetailPage({ ...defaultProps, assignedDataSources: [dataSources[0]] }) + ); + expect(getByText('Association OpenSearch Connections')).toBeInTheDocument(); + + fireEvent.click(getByText('Association OpenSearch Connections')); + await waitFor(() => { + expect( + getByText('Add OpenSearch connections that will be available in the workspace.') + ).toBeInTheDocument(); + expect(getByText('Close')).toBeInTheDocument(); + expect(getByText('Save changes')).toBeInTheDocument(); + expect(getByText('ds-2-title')).toBeInTheDocument(); + }); + fireEvent.click(getByText('Close')); + }); + + it('Association OpenSearch connections successfully', async () => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 600, + }); + const { getByText } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: [dataSources[0]], + action: success, + }) + ); + expect(getByText('Association OpenSearch Connections')).toBeInTheDocument(); + + fireEvent.click(getByText('Association OpenSearch Connections')); + await waitFor(() => { + expect( + getByText('Add OpenSearch connections that will be available in the workspace.') + ).toBeInTheDocument(); + expect(getByText('Close')).toBeInTheDocument(); + expect(getByText('Save changes')).toBeInTheDocument(); + expect(getByText('ds-2-title')).toBeInTheDocument(); + }); + fireEvent.click(getByText('ds-2-title')); + fireEvent.click(getByText('Save changes')); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + }); + + it('Association OpenSearch connections failed', async () => { + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + configurable: true, + value: 600, + }); + Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { + configurable: true, + value: 600, + }); + const { getByText } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: [dataSources[0]], + action: failed, + }) + ); + expect(getByText('Association OpenSearch Connections')).toBeInTheDocument(); + + fireEvent.click(getByText('Association OpenSearch Connections')); + await waitFor(() => { + expect( + getByText('Add OpenSearch connections that will be available in the workspace.') + ).toBeInTheDocument(); + expect(getByText('Close')).toBeInTheDocument(); + expect(getByText('Save changes')).toBeInTheDocument(); + expect(getByText('ds-2-title')).toBeInTheDocument(); + }); + fireEvent.click(getByText('ds-2-title')); + fireEvent.click(getByText('Save changes')); + await waitFor(() => { + expect(notificationToastsAddDanger).toHaveBeenCalled(); + }); + }); + + it('Remove OpenSearch connections successfully', async () => { + const { getByText, getByTestId } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: [dataSources[0]], + action: success, + }) + ); + expect(getByText('ds-1-title')).toBeInTheDocument(); + const button = getByTestId('workspace-detail-dataSources-table-actions-remove'); + fireEvent.click(button); + expect(getByText('Remove OpenSearch connections')).toBeInTheDocument(); + fireEvent.click(getByText('Cancel')); + fireEvent.click(button); + fireEvent.click(getByText('Remove connections')); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + }); + + it('Remove OpenSearch connections failed', async () => { + const { getByText, getByTestId } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: [dataSources[0]], + action: failed, + }) + ); + expect(getByText('ds-1-title')).toBeInTheDocument(); + const button = getByTestId('workspace-detail-dataSources-table-actions-remove'); + fireEvent.click(button); + fireEvent.click(getByText('Remove connections')); + await waitFor(() => { + expect(notificationToastsAddDanger).toHaveBeenCalled(); + }); + }); + + it('Remove selected OpenSearch connections successfully', async () => { + const { getByText, queryByTestId } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: [dataSources[0]], + action: success, + }) + ); + expect(getByText('ds-1-title')).toBeInTheDocument(); + expect(queryByTestId('workspace-detail-dataSources-table-bulkRemove')).toBeNull(); + const checkbox = screen.getAllByRole('checkbox')[0]; + + // Simulate clicking the checkbox + fireEvent.click(checkbox); + expect(getByText('Remove 1 association(s)')).toBeInTheDocument(); + fireEvent.click(getByText('Remove 1 association(s)')); + fireEvent.click(getByText('Remove connections')); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + }); + + it('should handle input in the search box', async () => { + const { getByText, queryByText } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: dataSources, + }) + ); + expect(getByText('ds-1-title')).toBeInTheDocument(); + expect(getByText('ds-2-title')).toBeInTheDocument(); + + const searchInput = screen.getByPlaceholderText('Search'); + // Simulate typing in the search input + fireEvent.change(searchInput, { target: { value: 'ds-1-title' } }); + expect(getByText('ds-1-title')).toBeInTheDocument(); + expect(queryByText('ds-2-title')).toBeNull(); + }); + + it('should not allow user to remove associations when user is not OSD admin', () => { + const { queryByTestId } = render( + WorkspaceDetailPage({ + ...defaultProps, + assignedDataSources: dataSources, + isDashboardAdmin: false, + }) + ); + expect(queryByTestId('workspace-detail-dataSources-table-action-Remove')).toBeNull(); + }); +}); 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 new file mode 100644 index 00000000000..db4c06fa75f --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/select_data_source_panel.tsx @@ -0,0 +1,183 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiText, + EuiTitle, + EuiPanel, + EuiSpacer, + EuiFlexItem, + EuiTextAlign, + EuiFlexGroup, + EuiSmallButton, + EuiHorizontalRule, + EuiLoadingSpinner, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from 'react-intl'; +import { DataSource } 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'; + +export interface SelectDataSourcePanelProps { + savedObjects: SavedObjectsStart; + assignedDataSources: DataSource[]; + detailTitle: string; + isDashboardAdmin: boolean; + currentWorkspace: WorkspaceObject; +} + +export const SelectDataSourceDetailPanel = ({ + assignedDataSources, + savedObjects, + detailTitle, + isDashboardAdmin, + currentWorkspace, +}: SelectDataSourcePanelProps) => { + const { + services: { notifications, workspaceClient }, + } = useOpenSearchDashboards<{ CoreStart: CoreStart; workspaceClient: WorkspaceClient }>(); + const { formData, setSelectedDataSources } = useWorkspaceFormContext(); + const [isLoading, setIsLoading] = useState(false); + const [isVisible, setIsVisible] = useState(false); + + const handleAssignDataSources = async (dataSources: DataSource[]) => { + try { + setIsLoading(true); + setIsVisible(false); + const { permissionSettings, selectedDataSources, useCase, ...attributes } = formData; + const savedDataSources: DataSource[] = [...selectedDataSources, ...dataSources]; + + const result = await workspaceClient.update(currentWorkspace.id, attributes, { + dataSources: savedDataSources.map((ds) => { + return ds.id; + }), + permissions: convertPermissionSettingsToPermissions(permissionSettings), + }); + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.detail.dataSources.assign.success', { + defaultMessage: 'Associate OpenSearch connections successfully', + }), + }); + setSelectedDataSources(savedDataSources); + } else { + throw new Error(result?.error ? result?.error : 'Associate OpenSearch connections failed'); + } + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.detail.dataSources.assign.failed', { + defaultMessage: 'Failed to associate OpenSearch connections', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + } finally { + setIsLoading(false); + } + }; + + const associationButton = ( + setIsVisible(true)} + isLoading={isLoading} + data-test-subj="workspace-detail-dataSources-assign-button" + > + {i18n.translate('workspace.detail.dataSources.assign.button', { + defaultMessage: 'Association OpenSearch Connections', + })} + + ); + + const loadingMessage = ( +
+ + + + + +
+ ); + + const noAssociationMessage = ( + + +

+ +

+
+ + + + + {isDashboardAdmin ? ( + <> + + {associationButton} + + ) : ( + + + + )} +
+ ); + + const renderTableContent = () => { + if (isLoading) { + return loadingMessage; + } + if (assignedDataSources.length === 0) { + return noAssociationMessage; + } + return ( + + ); + }; + + return ( + + + + +

{detailTitle}

+
+
+ {isDashboardAdmin && {associationButton}} +
+ + {renderTableContent()} + {isVisible && ( + setIsVisible(false)} + handleAssignDataSources={handleAssignDataSources} + /> + )} +
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.test.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.test.tsx new file mode 100644 index 00000000000..2214587d121 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.test.tsx @@ -0,0 +1,54 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { WorkspaceBottomBar } from './workspace_bottom_bar'; + +const mockHandleResetForm = jest.fn(); + +const defaultProps = { + formId: 'testForm', + numberOfChanges: 2, + numberOfErrors: 1, + handleResetForm: mockHandleResetForm, +}; + +describe('WorkspaceBottomBar', () => { + test('renders correctly with errors and unsaved changes', () => { + render(); + + expect(screen.getByText('2 Unsaved change(s)')).toBeInTheDocument(); + expect(screen.getByText('2 error(s)')).toBeInTheDocument(); + expect(screen.getByText('Discard changes')).toBeInTheDocument(); + expect(screen.getByText('Save changes')).toBeInTheDocument(); + }); + + test('disables the save button when there are no changes', () => { + render(); + const saveButton = screen.getByRole('button', { name: 'Save changes' }); + expect(saveButton).toBeDisabled(); + }); + + test('calls handleResetForm when discard changes button is clicked', () => { + render(); + fireEvent.click(screen.getByText('Discard changes')); + expect(mockHandleResetForm).toHaveBeenCalled(); + }); + + test('calls handleSubmit when save changes button is clicked', () => { + const handleSubmit = jest.fn(); + render( +
+ + + ); + + fireEvent.click(screen.getByRole('button', { name: 'Save changes' })); + + // Assuming handleSubmit is called during form submission + expect(handleSubmit).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.tsx new file mode 100644 index 00000000000..fdc817dc6f8 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_bottom_bar.tsx @@ -0,0 +1,101 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiBottomBar, + EuiSmallButton, + EuiSmallButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React from 'react'; +import ReactDOM from 'react-dom'; + +interface WorkspaceBottomBarProps { + formId: string; + numberOfChanges: number; + numberOfErrors: number; + handleResetForm: () => void; +} + +export const WorkspaceBottomBar = ({ + formId, + numberOfChanges, + numberOfErrors, + handleResetForm, +}: WorkspaceBottomBarProps) => { + const applicationElement = document.querySelector('.app-wrapper'); + const bottomBar = ( + + + + + + {numberOfErrors > 0 && ( + + {i18n.translate('workspace.form.bottomBar.errors', { + defaultMessage: '{numberOfChanges} error(s)', + values: { + numberOfChanges, + }, + })} + + )} + + + {numberOfChanges > 0 && ( + + {i18n.translate('workspace.form.bottomBar.unsavedChanges', { + defaultMessage: '{numberOfChanges} Unsaved change(s)', + values: { + numberOfChanges, + }, + })} + + )} + + + + + + + + {i18n.translate('workspace.form.bottomBar.disCardChanges', { + defaultMessage: 'Discard changes', + })} + + + + + {i18n.translate('workspace.form.bottomBar.saveChanges', { + defaultMessage: 'Save changes', + })} + + + + + + + ); + if (!applicationElement) { + return bottomBar; + } + + return ReactDOM.createPortal(bottomBar, applicationElement); +}; 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 2562e7a7591..cc23f3af8ec 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,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import React from 'react'; import { BehaviorSubject } from 'rxjs'; import { PublicAppInfo, WorkspaceObject } from 'opensearch-dashboards/public'; @@ -11,6 +11,8 @@ import { coreMock } from '../../../../../core/public/mocks'; import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; import { WORKSPACE_USE_CASES } from '../../../common/constants'; import { WorkspaceDetail } from './workspace_detail'; +import { WorkspaceFormProvider, WorkspaceOperationType } from '../workspace_form'; +import { MemoryRouter } from 'react-router-dom'; // all applications const PublicAPPInfoMap = new Map([ @@ -24,10 +26,40 @@ const workspaceObject = { id: 'foo_id', name: 'foo', description: 'this is my foo workspace description', - features: ['use-case-observability'], + features: ['use-case-observability', 'workspace_detail'], color: '', - icon: '', reserved: false, + permissions: { write: { users: ['user1', 'user2'] } }, +}; + +const defaultValues = { + id: workspaceObject.id, + name: workspaceObject.name, + description: workspaceObject.description, + features: workspaceObject.features, + color: workspaceObject.color, + permissionSettings: [ + { + id: 0, + type: 'user', + userId: 'user1', + modes: ['library_write', 'write'], + }, + { + id: 1, + type: 'user2', + group: '', + modes: ['library_write', 'write'], + }, + ], + selectedDataSources: [ + { + id: 'ds-1', + title: 'ds-1-title', + description: 'ds-1-description', + dataSourceEngineType: 'OpenSearch', + }, + ], }; const createWorkspacesSetupContractMockWithValue = (workspace?: WorkspaceObject) => { @@ -44,8 +76,23 @@ const createWorkspacesSetupContractMockWithValue = (workspace?: WorkspaceObject) }; }; +const deleteFn = jest.fn().mockReturnValue({ + success: true, +}); + const WorkspaceDetailPage = (props: any) => { const workspacesService = props.workspacesService || createWorkspacesSetupContractMockWithValue(); + const values = props.defaultValues || defaultValues; + const permissionEnabled = props.permissionEnabled ?? true; + const dataSourceManagement = + props.dataSourceEnabled !== false + ? { + ui: { + getDataSourceMenu: jest.fn(), + }, + } + : undefined; + const { Provider } = createOpenSearchDashboardsReactContext({ ...mockCoreStart, ...{ @@ -55,8 +102,9 @@ const WorkspaceDetailPage = (props: any) => { capabilities: { ...mockCoreStart.application.capabilities, workspaces: { - permissionEnabled: true, + permissionEnabled, }, + dashboards: { isDashboardAdmin: true }, }, }, workspaces: workspacesService, @@ -67,8 +115,10 @@ const WorkspaceDetailPage = (props: any) => { find: jest.fn().mockResolvedValue({ savedObjects: [], }), + delete: deleteFn, }, }, + dataSourceManagement, }, }); @@ -78,47 +128,155 @@ const WorkspaceDetailPage = (props: any) => { WORKSPACE_USE_CASES.essentials, WORKSPACE_USE_CASES.search, ]); - return ( - - - + + + + + + + ); }; describe('WorkspaceDetail', () => { + let mockHistoryPush: jest.Mock; + let mockLocation: Partial; + beforeEach(() => { + mockHistoryPush = jest.fn(); + mockLocation = { + pathname: '/current-path', + search: '', + hash: '', + }; + + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: jest.fn().mockReturnValue({ + push: mockHistoryPush, + location: mockLocation, + }), + useLocation: jest.fn().mockReturnValue(mockLocation), + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + it('render workspace detail page normally', async () => { const { container } = render(WorkspaceDetailPage({})); expect(container).toMatchSnapshot(); }); - it('default selected tab is overview', async () => { + it('default selected tab is Details', async () => { const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); render(WorkspaceDetailPage({ workspacesService: workspaceService })); + expect(document.querySelector('#details')).toHaveClass('euiTab-isSelected'); expect(screen.queryByTestId('workspaceTabs')).not.toBeNull(); - expect(document.querySelector('#overview')).toHaveClass('euiTab-isSelected'); }); - it('click on collaborators tab will workspace update page with permission', async () => { + it('click on Collaborators tab when permission control enabled', async () => { + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { getByText } = render(WorkspaceDetailPage({ workspacesService: workspaceService })); + fireEvent.click(getByText('Collaborators')); + expect(document.querySelector('#collaborators')).toHaveClass('euiTab-isSelected'); + }); + + it('click on Data Sources tab when dataSource enabled', async () => { const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); const { getByText } = render(WorkspaceDetailPage({ workspacesService: workspaceService })); - await act(async () => { - fireEvent.click(getByText('Collaborators')); + fireEvent.click(getByText('Data Sources')); + expect(document.querySelector('#dataSources')).toHaveClass('euiTab-isSelected'); + }); + + it('click on delete button will show delete modal', async () => { + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { getByText, getByTestId, queryByText } = render( + WorkspaceDetailPage({ workspacesService: workspaceService }) + ); + fireEvent.click(getByText('delete')); + expect(getByText('Delete workspace')).toBeInTheDocument(); + fireEvent.click(getByText('Cancel')); + expect(queryByText('Delete workspace')).toBeNull(); + fireEvent.click(getByText('delete')); + const input = getByTestId('delete-workspace-modal-input'); + fireEvent.change(input, { + target: { value: 'delete' }, + }); + const confirmButton = getByTestId('delete-workspace-modal-confirm'); + fireEvent.click(confirmButton); + }); + + it('click on Collaborators tab when permission control and dataSource disabled', async () => { + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { queryByText } = render( + WorkspaceDetailPage({ + workspacesService: workspaceService, + permissionEnabled: false, + dataSourceEnabled: false, + }) + ); + expect(queryByText('Collaborators')).toBeNull(); + expect(queryByText('Data Sources')).toBeNull(); + }); + + it('click on tab button will show navigate modal when number of changes > 1', async () => { + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { getByText, getByTestId, queryByText } = render( + WorkspaceDetailPage({ workspacesService: workspaceService }) + ); + fireEvent.click(getByText('Edit')); + expect(getByTestId('workspaceForm-workspaceDetails-discardChanges')).toBeInTheDocument(); + const input = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.change(input, { + target: { value: 'newName' }, }); + fireEvent.click(getByText('Collaborators')); + expect(getByText('Any unsaved changes will be lost.')).toBeInTheDocument(); + fireEvent.click(getByText('Cancel')); + expect(queryByText('Any unsaved changes will be lost.')).toBeNull(); + fireEvent.click(getByText('Collaborators')); + const button = getByText('Navigate away'); + fireEvent.click(button); expect(document.querySelector('#collaborators')).toHaveClass('euiTab-isSelected'); - await waitFor(() => { - expect(screen.queryByText('Manage access and permissions')).not.toBeNull(); + }); + + it('click on badge button will navigate to Collaborators tab when number of changes > 0', async () => { + const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); + const { getByText, getByTestId } = render( + WorkspaceDetailPage({ workspacesService: workspaceService }) + ); + expect(getByText('+1 more')).toBeInTheDocument(); + + fireEvent.click(getByText('Edit')); + expect(getByTestId('workspaceForm-workspaceDetails-discardChanges')).toBeInTheDocument(); + const input = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.change(input, { + target: { value: 'newName' }, }); + + fireEvent.click(getByText('+1 more')); + expect(getByText('Any unsaved changes will be lost.')).toBeInTheDocument(); + + fireEvent.click(getByText('Navigate away')); + expect(document.querySelector('#collaborators')).toHaveClass('euiTab-isSelected'); }); - it('click on settings tab will show workspace update page', async () => { + it('click on badge button will navigate to Collaborators tab when number of changes = 0', async () => { const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); const { getByText } = render(WorkspaceDetailPage({ workspacesService: workspaceService })); - fireEvent.click(getByText('Settings')); - expect(document.querySelector('#settings')).toHaveClass('euiTab-isSelected'); - await waitFor(() => { - expect(screen.queryByText('Enter details')).not.toBeNull(); - }); + expect(getByText('+1 more')).toBeInTheDocument(); + fireEvent.click(getByText('+1 more')); + expect(document.querySelector('#collaborators')).toHaveClass('euiTab-isSelected'); }); it('will not render xss content', async () => { 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 59098a5e721..a9575694742 100644 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail.tsx @@ -3,18 +3,37 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { EuiPage, EuiPageBody, EuiTabbedContent } from '@elastic/eui'; - -import { useObservable } from 'react-use'; +import React, { useEffect, useState } from 'react'; +import { + EuiPage, + EuiText, + EuiSpacer, + EuiFlexItem, + EuiPageBody, + EuiFlexGroup, + EuiPageHeader, + EuiPageContent, + EuiSmallButton, + EuiConfirmModal, + EuiTabbedContent, +} from '@elastic/eui'; import { i18n } from '@osd/i18n'; -import { CoreStart } from 'opensearch-dashboards/public'; -import { BehaviorSubject } from 'rxjs'; -import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { useObservable } from 'react-use'; +import { BehaviorSubject, of } from 'rxjs'; +import { useHistory, useLocation } from 'react-router-dom'; import { WorkspaceUseCase } from '../../types'; -import { WorkspaceDetailContent } from './workspace_detail_content'; -import { WorkspaceUpdater } from './workspace_updater'; -import { DetailTab } from '../workspace_form/constants'; +import { WorkspaceDetailForm, useWorkspaceFormContext } from '../workspace_form'; +import { WorkspaceDetailPanel } from './workspace_detail_panel'; +import { DeleteWorkspaceModal } from '../delete_workspace_modal'; +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 { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; +import { SelectDataSourceDetailPanel } from './select_data_source_panel'; +import { WorkspaceBottomBar } from './workspace_bottom_bar'; export interface WorkspaceDetailProps { registeredUseCases$: BehaviorSubject; @@ -22,67 +41,203 @@ export interface WorkspaceDetailProps { export const WorkspaceDetail = (props: WorkspaceDetailProps) => { const { - services: { workspaces, application }, - } = useOpenSearchDashboards(); + services: { workspaces, application, http, savedObjects, dataSourceManagement, uiSettings }, + } = useOpenSearchDashboards<{ + CoreStart: CoreStart; + dataSourceManagement?: DataSourceManagementPluginSetup; + }>(); + + const { + formData, + isEditing, + formId, + numberOfErrors, + handleResetForm, + numberOfChanges, + setIsEditing, + } = useWorkspaceFormContext(); + const [deletedWorkspace, setDeletedWorkspace] = useState(null); + const [selectedTabId, setSelectedTabId] = useState(DetailTab.Details); + const [modalVisible, setModalVisible] = useState(false); + const [tabId, setTabId] = useState(DetailTab.Details); - const currentWorkspace = useObservable(workspaces.currentWorkspace$); + const availableUseCases = useObservable(props.registeredUseCases$, []); + const isDashboardAdmin = !!application?.capabilities?.dashboards?.isDashboardAdmin; + const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; + const currentUseCase = availableUseCases.find( + (useCase) => useCase.id === getFirstUseCaseOfFeatureConfigs(currentWorkspace?.features ?? []) + ); + const history = useHistory(); + const location = useLocation(); + + useEffect(() => { + const params = new URLSearchParams(location.search); + const tab = params.get('tab'); + if (tab) { + setSelectedTabId(tab); + } + }, [location.search]); - if (!currentWorkspace) { + if (!currentWorkspace || !application || !http || !savedObjects || !uiSettings) { return null; } + const useCaseUrl = getUseCaseUrl(currentUseCase, currentWorkspace, application, http); + + const handleTabClick = (tab: any) => { + if (numberOfChanges > 0) { + setTabId(tab.id); + setModalVisible(true); + return; + } + history.push(`?tab=${tab.id}`); + setIsEditing(false); + setSelectedTabId(tab.id); + }; + + const handleBadgeClick = () => { + if (selectedTabId !== DetailTab.Collaborators && numberOfChanges > 0) { + setTabId(DetailTab.Collaborators); + setModalVisible(true); + return; + } + history.push(`?tab=${DetailTab.Collaborators}`); + setSelectedTabId(DetailTab.Collaborators); + }; + + const createDetailTab = (id: DetailTab, detailTitle: string) => ({ + id, + name: detailTitle, + content: ( + + ), + }); + const detailTabs = [ - { - id: DetailTab.Overview, - name: i18n.translate('workspace.overview.tabTitle', { - defaultMessage: 'Overview', - }), - content: , - }, - { - id: DetailTab.Settings, - name: i18n.translate('workspace.overview.setting.tabTitle', { - defaultMessage: 'Settings', - }), - content: ( - - ), - }, - ...(isPermissionEnabled + createDetailTab(DetailTab.Details, DetailTabTitles.details), + ...(dataSourceManagement ? [ { - id: DetailTab.Collaborators, - name: i18n.translate('workspace.overview.collaborators.tabTitle', { - defaultMessage: 'Collaborators', - }), + id: DetailTab.DataSources, + name: DetailTabTitles.dataSources, content: ( - ), }, ] : []), + ...(isPermissionEnabled + ? [createDetailTab(DetailTab.Collaborators, DetailTabTitles.collaborators)] + : []), ]; + const deleteButton = ( + setDeletedWorkspace(currentWorkspace)} + > + {i18n.translate('workspace.detail.delete', { + defaultMessage: 'delete', + })} + + ); + return ( <> - + + + + {currentWorkspace.description} + + + + + + tab.id === selectedTabId)]} + onTabClick={handleTabClick} size="s" /> + {deletedWorkspace && ( + setDeletedWorkspace(null)} + onDeleteSuccess={() => { + window.setTimeout(() => { + window.location.assign( + cleanWorkspaceId( + application.getUrlForApp(WORKSPACE_LIST_APP_ID, { + absolute: false, + }) + ) + ); + }, 1000); + }} + /> + )} + {modalVisible && ( + setModalVisible(false)} + onConfirm={() => { + handleResetForm(); + setModalVisible(false); + history.push(`?tab=${tabId}`); + setSelectedTabId(tabId); + }} + cancelButtonText={i18n.translate('workspace.form.cancelButtonText', { + defaultMessage: 'Cancel', + })} + confirmButtonText={i18n.translate('workspace.form.confirmButtonText', { + defaultMessage: 'Navigate away', + })} + buttonColor="danger" + defaultFocusedButton="confirm" + > + {i18n.translate('workspace.form.cancelModal.body', { + defaultMessage: 'Any unsaved changes will be lost.', + })} + + )} + {isEditing && ( + + )} ); }; diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail_content.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail_content.tsx deleted file mode 100644 index 3e3010e4143..00000000000 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_detail_content.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - EuiFlexItem, - EuiCard, - EuiFlexGroup, - EuiPage, - EuiPageContent, - EuiPageBody, - EuiSpacer, -} from '@elastic/eui'; -import React from 'react'; -import { useObservable } from 'react-use'; -import { of } from 'rxjs'; -import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; - -export const WorkspaceDetailContent = () => { - const { - services: { workspaces }, - } = useOpenSearchDashboards(); - - const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); - - return ( - - - - - - - - - - - - - - ); -}; diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_detail_panel.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_detail_panel.tsx new file mode 100644 index 00000000000..1f426743ac1 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_detail/workspace_detail_panel.tsx @@ -0,0 +1,140 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiLink, + EuiText, + EuiCopy, + EuiBadge, + EuiFlexItem, + EuiFlexGroup, + EuiButtonIcon, + EuiColorPickerSwatch, +} from '@elastic/eui'; +import moment from 'moment'; +import { i18n } from '@osd/i18n'; +import { WorkspaceUseCase } from '../../types'; +import { WorkspaceObject } from '../../../../../core/public'; +import { WorkspaceAttributeWithPermission } from '../../../../../core/types'; + +const detailUseCase = i18n.translate('workspace.detail.useCase', { + defaultMessage: 'Use case', +}); + +const detailOwner = i18n.translate('workspace.detail.owner', { + defaultMessage: 'Owner', +}); + +const detailLastUpdated = i18n.translate('workspace.detail.lastUpdated', { + defaultMessage: 'Last updated', +}); + +const detailID = i18n.translate('workspace.detail.id', { + defaultMessage: 'ID', +}); + +const workspaceOverview = i18n.translate('workspace.detail.workspaceOverview', { + defaultMessage: 'Workspace overview', +}); + +const overview = i18n.translate('workspace.detail.overview', { + defaultMessage: 'Overview', +}); + +function getOwners(currentWorkspace: WorkspaceAttributeWithPermission) { + const { groups = [], users = [] } = currentWorkspace.permissions!.write; + return [...groups, ...users]; +} + +interface WorkspaceDetailPanelProps { + useCaseUrl: string; + handleBadgeClick: () => void; + currentUseCase: WorkspaceUseCase | undefined; + currentWorkspace: WorkspaceObject; + dateFormat: string; +} +export const WorkspaceDetailPanel = ({ + useCaseUrl, + currentUseCase, + handleBadgeClick, + currentWorkspace, + dateFormat, +}: WorkspaceDetailPanelProps) => { + const owners = getOwners(currentWorkspace); + const formatDate = (lastUpdatedTime: string) => { + return moment(lastUpdatedTime).format(dateFormat); + }; + + return ( + + + +

{detailUseCase}

+

+ + {currentUseCase?.title} +

+
+
+ + +

{detailOwner}

+

+ {owners?.at(0)}   + {owners && owners.length > 1 && ( + + +{owners?.length - 1} more + + )} +

+
+
+ + +

{detailLastUpdated}

+

{formatDate(currentWorkspace.lastUpdatedTime || '')}

+
+
+ + +

{detailID}

+

+ {currentWorkspace.id} + + {(copy) => ( + + )} + +

+
+
+ + +

{workspaceOverview}

+

+ + {overview} + +

+
+
+
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx deleted file mode 100644 index e2ecbd0a32c..00000000000 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_updater.test.tsx +++ /dev/null @@ -1,359 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { PublicAppInfo, WorkspaceObject } from 'opensearch-dashboards/public'; -import { fireEvent, render, waitFor, screen, act } from '@testing-library/react'; -import { BehaviorSubject } from 'rxjs'; - -import { coreMock, workspacesServiceMock } from '../../../../../core/public/mocks'; -import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; -import { DetailTab } from '../workspace_form/constants'; -import { WORKSPACE_USE_CASES } from '../../../common/constants'; -import { - WorkspaceUpdater as WorkspaceUpdaterComponent, - WorkspaceUpdaterProps, -} from './workspace_updater'; - -const workspaceClientUpdate = jest.fn().mockReturnValue({ result: true, success: true }); - -const navigateToApp = jest.fn(); -const notificationToastsAddSuccess = jest.fn(); -const notificationToastsAddDanger = jest.fn(); -const PublicAPPInfoMap = new Map([ - ['data-explorer', { id: 'data-explorer', title: 'Data Explorer' }], - ['dashboards', { id: 'dashboards', title: 'Dashboards' }], -]); -const createWorkspacesSetupContractMockWithValue = () => { - const currentWorkspaceId$ = new BehaviorSubject('workspaceId'); - const currentWorkspace = { - id: 'workspaceId', - name: 'test1', - description: 'test1', - features: ['use-case-observability'], - reserved: false, - permissions: { - library_write: { - users: ['foo'], - }, - write: { - users: ['foo'], - }, - }, - }; - const workspaceList$ = new BehaviorSubject([currentWorkspace]); - const currentWorkspace$ = new BehaviorSubject(currentWorkspace); - const initialized$ = new BehaviorSubject(false); - return { - currentWorkspaceId$, - workspaceList$, - currentWorkspace$, - initialized$, - }; -}; - -const dataSourcesList = [ - { - id: 'id1', - title: 'ds1', // This is used for mocking saved object function - get: () => { - return 'ds1'; - }, - }, - { - id: 'id2', - title: 'ds2', - get: () => { - return 'ds2'; - }, - }, -]; - -const mockCoreStart = coreMock.createStart(); - -const renderCompleted = () => expect(screen.queryByText('Enter details')).not.toBeNull(); - -const WorkspaceUpdater = ( - props: Partial & { - workspacesService?: ReturnType; - } -) => { - const workspacesService = props.workspacesService || createWorkspacesSetupContractMockWithValue(); - const { Provider } = createOpenSearchDashboardsReactContext({ - ...mockCoreStart, - ...{ - application: { - ...mockCoreStart.application, - capabilities: { - ...mockCoreStart.application.capabilities, - workspaces: { - permissionEnabled: true, - }, - dashboards: { - isDashboardAdmin: true, - }, - }, - navigateToApp, - getUrlForApp: jest.fn(() => '/app/workspace_detail'), - applications$: new BehaviorSubject>(PublicAPPInfoMap as any), - }, - workspaces: workspacesService, - notifications: { - ...mockCoreStart.notifications, - toasts: { - ...mockCoreStart.notifications.toasts, - addDanger: notificationToastsAddDanger, - addSuccess: notificationToastsAddSuccess, - }, - }, - workspaceClient: { - ...mockCoreStart.workspaces, - update: workspaceClientUpdate, - }, - savedObjects: { - ...mockCoreStart.savedObjects, - client: { - ...mockCoreStart.savedObjects.client, - find: jest.fn().mockResolvedValue({ - savedObjects: dataSourcesList, - }), - }, - }, - dataSourceManagement: {}, - }, - }); - const registeredUseCases$ = new BehaviorSubject([ - WORKSPACE_USE_CASES.observability, - WORKSPACE_USE_CASES['security-analytics'], - WORKSPACE_USE_CASES.essentials, - WORKSPACE_USE_CASES.search, - ]); - - return ( - - - - ); -}; - -function clearMockedFunctions() { - workspaceClientUpdate.mockClear(); - notificationToastsAddDanger.mockClear(); - notificationToastsAddSuccess.mockClear(); -} - -describe('WorkspaceUpdater', () => { - beforeEach(() => clearMockedFunctions()); - const { location } = window; - const setHrefSpy = jest.fn((href) => href); - - beforeAll(() => { - if (window.location) { - // @ts-ignore - delete window.location; - } - window.location = {} as Location; - Object.defineProperty(window.location, 'href', { - get: () => 'http://localhost/', - set: setHrefSpy, - }); - }); - - afterAll(() => { - window.location = location; - }); - - it('cannot render when the name of the current workspace is empty', async () => { - const mockedWorkspacesService = workspacesServiceMock.createSetupContract(); - const { container } = render( - - ); - expect(container).toMatchInlineSnapshot(`
`); - }); - - it('cannot update workspace with invalid name', async () => { - const { getByTestId } = render(); - - await waitFor(renderCompleted); - - const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); - fireEvent.input(nameInput, { - target: { value: '~' }, - }); - expect(workspaceClientUpdate).not.toHaveBeenCalled(); - }); - - it('cancel update workspace', async () => { - const { findByText, getByTestId } = render(); - await waitFor(renderCompleted); - - fireEvent.click(getByTestId('workspaceForm-bottomBar-cancelButton')); - await findByText('Discard changes?'); - fireEvent.click(getByTestId('confirmModalConfirmButton')); - expect(navigateToApp).toHaveBeenCalled(); - }); - - it('update workspace successfully', async () => { - const { getByTestId, getAllByLabelText } = render( - - ); - await waitFor(renderCompleted); - - const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); - fireEvent.input(nameInput, { - target: { value: 'test workspace name' }, - }); - - const descriptionInput = getByTestId('workspaceForm-workspaceDetails-descriptionInputText'); - fireEvent.input(descriptionInput, { - target: { value: 'test workspace description' }, - }); - const colorSelector = getByTestId( - 'euiColorPickerAnchor workspaceForm-workspaceDetails-colorPicker' - ); - fireEvent.input(colorSelector, { - target: { value: '#000000' }, - }); - - fireEvent.click(getByTestId('workspaceUseCase-observability')); - fireEvent.click(getByTestId('workspaceUseCase-analytics')); - - act(() => { - fireEvent.click(getAllByLabelText('Delete data source')[0]); - }); - - fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); - expect(workspaceClientUpdate).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - name: 'test workspace name', - color: '#000000', - description: 'test workspace description', - features: expect.arrayContaining(['use-case-analytics']), - }), - { - permissions: { - library_write: { - users: ['foo'], - }, - write: { - users: ['foo'], - }, - }, - dataSources: ['id2'], - } - ); - await waitFor(() => { - expect(notificationToastsAddSuccess).toHaveBeenCalled(); - }); - expect(notificationToastsAddDanger).not.toHaveBeenCalled(); - await waitFor(() => { - expect(setHrefSpy).toHaveBeenCalledWith(expect.stringMatching(/workspace_detail$/)); - }); - }); - - it('update workspace permission successfully', async () => { - const { getByTestId, getAllByTestId } = render( - - ); - await waitFor(() => expect(screen.queryByText('Manage access and permissions')).not.toBeNull()); - - const userIdInput = getAllByTestId('comboBoxSearchInput')[0]; - fireEvent.click(userIdInput); - - fireEvent.input(userIdInput, { - target: { value: 'test user id' }, - }); - fireEvent.blur(userIdInput); - - fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); - expect(workspaceClientUpdate).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - name: 'test1', - description: 'test1', - features: expect.arrayContaining(['use-case-observability']), - }), - { - permissions: { - library_write: { - users: ['test user id'], - }, - write: { - users: ['test user id'], - }, - }, - dataSources: ['id1', 'id2'], - } - ); - await waitFor(() => { - expect(notificationToastsAddSuccess).toHaveBeenCalled(); - }); - expect(notificationToastsAddDanger).not.toHaveBeenCalled(); - await waitFor(() => { - expect(setHrefSpy).toHaveBeenCalledWith(expect.stringMatching(/workspace_detail$/)); - }); - }); - - it('should show danger toasts after update workspace failed', async () => { - workspaceClientUpdate.mockReturnValue({ result: false, success: false }); - const { getByTestId } = render(); - await waitFor(renderCompleted); - - const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); - fireEvent.input(nameInput, { - target: { value: 'test workspace name' }, - }); - fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); - expect(workspaceClientUpdate).toHaveBeenCalled(); - await waitFor(() => { - expect(notificationToastsAddDanger).toHaveBeenCalled(); - }); - expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); - }); - - it('should show danger toasts after update workspace threw error', async () => { - workspaceClientUpdate.mockImplementation(() => { - throw new Error('update workspace failed'); - }); - const { getByTestId } = render(); - await waitFor(renderCompleted); - - const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); - fireEvent.input(nameInput, { - target: { value: 'test workspace name' }, - }); - fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); - expect(workspaceClientUpdate).toHaveBeenCalled(); - await waitFor(() => { - expect(notificationToastsAddDanger).toHaveBeenCalled(); - }); - expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); - }); - - it('should show danger toasts when currentWorkspace is missing after click update button', async () => { - const mockedWorkspacesService = workspacesServiceMock.createSetupContract(); - const { getByTestId } = render( - - ); - - await waitFor(renderCompleted); - - const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); - fireEvent.input(nameInput, { - target: { value: 'test workspace name' }, - }); - fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); - mockedWorkspacesService.currentWorkspace$ = new BehaviorSubject(null); - expect(workspaceClientUpdate).toHaveBeenCalled(); - await waitFor(() => { - expect(notificationToastsAddDanger).toHaveBeenCalled(); - }); - expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); - }); -}); diff --git a/src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx deleted file mode 100644 index 9005646fd80..00000000000 --- a/src/plugins/workspace/public/components/workspace_detail/workspace_updater.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useCallback, useEffect, useState } from 'react'; -import { EuiPage, EuiPageBody, EuiPageContent, EuiSpacer } from '@elastic/eui'; -import { i18n } from '@osd/i18n'; -import { useObservable } from 'react-use'; -import { BehaviorSubject, of } from 'rxjs'; -import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; -import { WORKSPACE_DETAIL_APP_ID } from '../../../common/constants'; -import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; -import { WorkspaceAttributeWithPermission } from '../../../../../core/types'; -import { WorkspaceClient } from '../../workspace_client'; -import { - WorkspaceFormSubmitData, - WorkspaceOperationType, - convertPermissionsToPermissionSettings, - convertPermissionSettingsToPermissions, - WorkspaceDetailForm, -} from '../workspace_form'; -import { getDataSourcesList } from '../../utils'; -import { DataSource } from '../../../common/types'; -import { DetailTab } from '../workspace_form/constants'; -import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; -import { WorkspaceUseCase } from '../../types'; - -export interface WorkspaceUpdaterProps { - registeredUseCases$: BehaviorSubject; - detailTab?: DetailTab; -} - -function getFormDataFromWorkspace( - currentWorkspace: WorkspaceAttributeWithPermission | null | undefined -) { - if (!currentWorkspace) { - return null; - } - return { - ...currentWorkspace, - permissionSettings: currentWorkspace.permissions - ? convertPermissionsToPermissionSettings(currentWorkspace.permissions) - : currentWorkspace.permissions, - }; -} - -type FormDataFromWorkspace = ReturnType & { - selectedDataSources: DataSource[]; -}; - -export const WorkspaceUpdater = (props: WorkspaceUpdaterProps) => { - const { - services: { - application, - workspaces, - notifications, - http, - workspaceClient, - savedObjects, - dataSourceManagement, - }, - } = useOpenSearchDashboards<{ - workspaceClient: WorkspaceClient; - dataSourceManagement?: DataSourceManagementPluginSetup; - }>(); - - const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; - const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); - const availableUseCases = useObservable(props.registeredUseCases$, []); - const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState(); - - const handleWorkspaceFormSubmit = useCallback( - async (data: WorkspaceFormSubmitData) => { - let result; - if (!currentWorkspace) { - notifications?.toasts.addDanger({ - title: i18n.translate('Cannot find current workspace', { - defaultMessage: 'Cannot update workspace', - }), - }); - return; - } - - try { - const { permissionSettings, selectedDataSources, ...attributes } = data; - const selectedDataSourceIds = (selectedDataSources ?? []).map((ds: DataSource) => { - return ds.id; - }); - result = await workspaceClient.update(currentWorkspace.id, attributes, { - dataSources: selectedDataSourceIds, - permissions: convertPermissionSettingsToPermissions(permissionSettings), - }); - if (result?.success) { - notifications?.toasts.addSuccess({ - title: i18n.translate('workspace.update.success', { - defaultMessage: 'Update workspace successfully', - }), - }); - if (application && http) { - // Redirect page after one second, leave one second time to show update successful toast. - window.setTimeout(() => { - window.location.href = formatUrlWithWorkspaceId( - application.getUrlForApp(WORKSPACE_DETAIL_APP_ID, { - absolute: true, - }), - currentWorkspace.id, - http.basePath - ); - }, 1000); - } - return; - } else { - throw new Error(result?.error ? result?.error : 'update workspace failed'); - } - } catch (error) { - notifications?.toasts.addDanger({ - title: i18n.translate('workspace.update.failed', { - defaultMessage: 'Failed to update workspace', - }), - text: error instanceof Error ? error.message : JSON.stringify(error), - }); - return; - } - }, - [notifications?.toasts, currentWorkspace, http, application, workspaceClient] - ); - - useEffect(() => { - const rawFormData = getFormDataFromWorkspace(currentWorkspace); - - if (rawFormData && savedObjects && currentWorkspace) { - getDataSourcesList(savedObjects.client, [currentWorkspace.id]).then((selectedDataSources) => { - setCurrentWorkspaceFormData({ - ...rawFormData, - selectedDataSources, - }); - }); - } - }, [currentWorkspace, savedObjects]); - - if (!currentWorkspaceFormData) { - return null; - } - - return ( - - - - - {application && savedObjects && ( - - )} - - - - ); -}; diff --git a/src/plugins/workspace/public/components/workspace_detail_app.tsx b/src/plugins/workspace/public/components/workspace_detail_app.tsx index 1185f0c66d8..1347b130575 100644 --- a/src/plugins/workspace/public/components/workspace_detail_app.tsx +++ b/src/plugins/workspace/public/components/workspace_detail_app.tsx @@ -3,21 +3,63 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { I18nProvider } from '@osd/i18n/react'; import { i18n } from '@osd/i18n'; import { CoreStart } from 'opensearch-dashboards/public'; import { useObservable } from 'react-use'; import { EuiBreadcrumb } from '@elastic/eui'; +import { of } from 'rxjs'; import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; import { WorkspaceDetail, WorkspaceDetailProps } from './workspace_detail/workspace_detail'; +import { WorkspaceFormProvider } from './workspace_form'; +import { + WorkspaceFormSubmitData, + WorkspaceOperationType, + convertPermissionSettingsToPermissions, + convertPermissionsToPermissionSettings, +} from './workspace_form'; +import { DataSource } 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 { WorkspaceAttributeWithPermission } from '../../../../core/types'; + +function getFormDataFromWorkspace( + currentWorkspace: WorkspaceAttributeWithPermission | null | undefined +) { + if (!currentWorkspace) { + return null; + } + return { + ...currentWorkspace, + permissionSettings: currentWorkspace.permissions + ? convertPermissionsToPermissionSettings(currentWorkspace.permissions) + : currentWorkspace.permissions, + }; +} + +type FormDataFromWorkspace = ReturnType & { + selectedDataSources: DataSource[]; +}; export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { const { - services: { workspaces, chrome, application }, - } = useOpenSearchDashboards(); - - const currentWorkspace = useObservable(workspaces.currentWorkspace$); + services: { + workspaces, + chrome, + application, + savedObjects, + notifications, + workspaceClient, + http, + }, + } = useOpenSearchDashboards<{ CoreStart: CoreStart; workspaceClient: WorkspaceClient }>(); + const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState(); + const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); + const availableUseCases = useObservable(props.registeredUseCases$, []); + const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; /** * set breadcrumbs to chrome @@ -27,7 +69,7 @@ export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { { text: 'Home', onClick: () => { - application.navigateToApp('home'); + application?.navigateToApp('home'); }, }, ]; @@ -47,9 +89,93 @@ export const WorkspaceDetailApp = (props: WorkspaceDetailProps) => { chrome?.setBreadcrumbs(breadcrumbs); }, [chrome, currentWorkspace, application]); + useEffect(() => { + const rawFormData = getFormDataFromWorkspace(currentWorkspace); + + if (rawFormData && savedObjects && currentWorkspace) { + getDataSourcesList(savedObjects.client, [currentWorkspace.id]).then((selectedDataSources) => { + setCurrentWorkspaceFormData({ + ...rawFormData, + selectedDataSources, + }); + }); + } + }, [currentWorkspace, savedObjects]); + + const handleWorkspaceFormSubmit = useCallback( + async (data: WorkspaceFormSubmitData) => { + let result; + if (!currentWorkspace) { + notifications?.toasts.addDanger({ + title: i18n.translate('Cannot find current workspace', { + defaultMessage: 'Cannot update workspace', + }), + }); + return; + } + + try { + const { permissionSettings, selectedDataSources, ...attributes } = data; + const selectedDataSourceIds = (selectedDataSources ?? []).map((ds: DataSource) => { + return ds.id; + }); + + result = await workspaceClient.update(currentWorkspace.id, attributes, { + dataSources: selectedDataSourceIds, + permissions: convertPermissionSettingsToPermissions(permissionSettings), + }); + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.update.success', { + defaultMessage: 'Update workspace successfully', + }), + }); + if (application && http) { + // Redirect page after one second, leave one second time to show update successful toast. + window.setTimeout(() => { + window.location.href = formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_DETAIL_APP_ID, { + absolute: true, + }), + currentWorkspace.id, + http.basePath + ); + }, 1000); + } + return; + } else { + throw new Error(result?.error ? result?.error : 'update workspace failed'); + } + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.update.failed', { + defaultMessage: 'Failed to update workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + }, + [notifications?.toasts, currentWorkspace, http, application, workspaceClient] + ); + + if (!workspaces || !application || !http || !savedObjects || !currentWorkspaceFormData) { + return null; + } + return ( - - - + + + + + ); }; diff --git a/src/plugins/workspace/public/components/workspace_form/constants.ts b/src/plugins/workspace/public/components/workspace_form/constants.ts index 2a2c7142d6f..a69afeacbbb 100644 --- a/src/plugins/workspace/public/components/workspace_form/constants.ts +++ b/src/plugins/workspace/public/components/workspace_form/constants.ts @@ -46,11 +46,75 @@ export const selectDataSourceTitle = i18n.translate('workspace.form.selectDataSo }); export const usersAndPermissionsTitle = i18n.translate('workspace.form.usersAndPermissions.title', { - defaultMessage: 'Manage access and permissions', + defaultMessage: 'Workspaces access', }); +export const detailsName = i18n.translate('workspace.form.workspaceDetails.name.label', { + defaultMessage: 'Name', +}); + +export const detailsNameHelpText = i18n.translate('workspace.form.workspaceDetails.name.helpText', { + defaultMessage: + 'Valid characters are a-z, A-Z, 0-9, (), [], _ (underscore), - (hyphen) and (space).', +}); + +export const detailsNamePlaceholder = i18n.translate( + 'workspace.form.workspaceDetails.name.placeholder', + { + defaultMessage: 'Enter a name', + } +); + +export const detailsDescriptionIntroduction = i18n.translate( + 'workspace.form.workspaceDetails.description.introduction', + { + defaultMessage: 'Describe the workspace.', + } +); + +export const detailsDescriptionPlaceholder = i18n.translate( + 'workspace.form.workspaceDetails.description.placeholder', + { + defaultMessage: 'Describe the workspace', + } +); + +export const detailsUseCaseLabel = i18n.translate('workspace.form.workspaceDetails.useCase.label', { + defaultMessage: 'Use case', +}); + +export const detailsUseCaseHelpText = i18n.translate( + 'workspace.form.workspaceDetails.useCase.helpText', + { + defaultMessage: 'You can only choose use cases with more features than the current use case.', + } +); + +export const detailsColorLabel = i18n.translate('workspace.form.workspaceDetails.color.label', { + defaultMessage: 'Workspace icon color', +}); + +export const detailsColorHelpText = i18n.translate( + 'workspace.form.workspaceDetails.color.helpText', + { + defaultMessage: 'The background color of the icon that represents the workspace.', + } +); + export enum DetailTab { - Settings = 'settings', + Details = 'details', + DataSources = 'dataSources', Collaborators = 'collaborators', - Overview = 'overview', } + +export const DetailTabTitles: { [key in DetailTab]: string } = { + [DetailTab.Details]: i18n.translate('workspace.detail.tabTitle.details', { + defaultMessage: 'Details', + }), + [DetailTab.DataSources]: i18n.translate('workspace.detail.tabTitle.dataSources', { + defaultMessage: 'Data Sources', + }), + [DetailTab.Collaborators]: i18n.translate('workspace.detail.tabTitle.collaborators', { + defaultMessage: 'Collaborators', + }), +}; diff --git a/src/plugins/workspace/public/components/workspace_form/index.ts b/src/plugins/workspace/public/components/workspace_form/index.ts index 31addf5a641..42164ca530e 100644 --- a/src/plugins/workspace/public/components/workspace_form/index.ts +++ b/src/plugins/workspace/public/components/workspace_form/index.ts @@ -11,3 +11,4 @@ export { convertPermissionsToPermissionSettings, convertPermissionSettingsToPermissions, } from './utils'; +export { WorkspaceFormProvider, useWorkspaceFormContext } from './workspace_form_context'; diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts index cbcf7e8ded2..6f0c6a2d2f7 100644 --- a/src/plugins/workspace/public/components/workspace_form/types.ts +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -86,6 +86,7 @@ export interface WorkspaceFormProps { detailTab?: DetailTab; dataSourceManagement?: DataSourceManagementPluginSetup; availableUseCases: WorkspaceUseCase[]; + detailTitle?: string; } export interface WorkspaceDetailedFormProps extends WorkspaceFormProps { diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts index ae9ba5d6a4f..c946cb35a68 100644 --- a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts @@ -140,4 +140,23 @@ describe('useWorkspaceForm', () => { }); expect(renderResult.result.current.formData.useCase).toBe('search'); }); + + it('should reset workspace form', () => { + const { renderResult } = setup({ + id: 'test', + name: 'current-workspace-name', + features: ['use-case-observability'], + }); + expect(renderResult.result.current.formData.name).toBe('current-workspace-name'); + + act(() => { + renderResult.result.current.setName('update-workspace-name'); + }); + expect(renderResult.result.current.formData.name).toBe('update-workspace-name'); + + act(() => { + renderResult.result.current.handleResetForm(); + }); + expect(renderResult.result.current.formData.name).toBe('current-workspace-name'); + }); }); 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 8b03d246d56..422161bea94 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 @@ -37,6 +37,7 @@ export const useWorkspaceForm = ({ const [description, setDescription] = useState(defaultValues?.description); const [color, setColor] = useState(defaultValues?.color); const defaultValuesRef = useRef(defaultValues); + const [isEditing, setIsEditing] = useState(false); const initialPermissionSettingsRef = useRef( generatePermissionSettingsState(operationType, defaultValues?.permissionSettings) ); @@ -119,7 +120,7 @@ export const useWorkspaceForm = ({ onSubmit?.({ name: currentFormData.name!, description: currentFormData.description, - color: currentFormData.color, + color: currentFormData.color || '#FFFFFF', features: currentFormData.features, permissionSettings: currentFormData.permissionSettings as WorkspacePermissionSetting[], selectedDataSources: currentFormData.selectedDataSources, @@ -132,13 +133,27 @@ export const useWorkspaceForm = ({ setColor(text); }, []); + const handleResetForm = useCallback(() => { + const resetValues = defaultValuesRef.current; + setName(resetValues?.name ?? ''); + setDescription(resetValues?.description); + setColor(resetValues?.color); + setFeatureConfigs(appendDefaultFeatureIds(resetValues?.features ?? [])); + setPermissionSettings(initialPermissionSettingsRef.current); + setFormErrors({}); + setIsEditing(false); + }, []); + return { formId: formIdRef.current, formData, + isEditing, formErrors, + setIsEditing, applications, numberOfErrors, numberOfChanges, + handleResetForm, setName, setDescription, handleFormSubmit, diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx deleted file mode 100644 index 79819a217de..00000000000 --- a/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - EuiBottomBar, - EuiSmallButton, - EuiSmallButtonEmpty, - EuiFlexGroup, - EuiFlexItem, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { i18n } from '@osd/i18n'; -import React, { useState, useCallback } from 'react'; -import { ApplicationStart } from 'opensearch-dashboards/public'; -import { WorkspaceCancelModal } from './workspace_cancel_modal'; - -interface WorkspaceBottomBarProps { - formId: string; - application: ApplicationStart; - numberOfChanges: number; -} - -export const WorkspaceBottomBar = ({ - formId, - numberOfChanges, - application, -}: WorkspaceBottomBarProps) => { - const [isCancelModalVisible, setIsCancelModalVisible] = useState(false); - const closeCancelModal = useCallback(() => setIsCancelModalVisible(false), []); - const showCancelModal = useCallback(() => setIsCancelModalVisible(true), []); - - return ( -
- - - - - - - {numberOfChanges > 0 && ( - - {i18n.translate('workspace.form.bottomBar.unsavedChanges', { - defaultMessage: '{numberOfChanges} Unsaved change(s)', - values: { - numberOfChanges, - }, - })} - - )} - - - - - - {i18n.translate('workspace.form.bottomBar.cancel', { - defaultMessage: 'Cancel', - })} - - - - {i18n.translate('workspace.form.bottomBar.saveChanges', { - defaultMessage: 'Save changes', - })} - - - - - - {isCancelModalVisible && ( - - )} -
- ); -}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx index 271f90ca69f..a8015120d40 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx @@ -4,76 +4,97 @@ */ import './workspace_detail_form.scss'; -import React, { useRef } from 'react'; -import { EuiPanel, EuiSpacer, EuiForm, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; +import React, { useEffect, useRef, useState } from 'react'; +import { + EuiSpacer, + EuiForm, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, + EuiText, + EuiPanel, + EuiSmallButton, + EuiHorizontalRule, +} from '@elastic/eui'; -import { WorkspaceBottomBar } from './workspace_bottom_bar'; -import { WorkspaceDetailedFormProps } from './types'; -import { useWorkspaceForm } from './use_workspace_form'; -import { WorkspaceUseCase } from './workspace_use_case'; +import { i18n } from '@osd/i18n'; +import { WorkspaceFormProps } from './types'; import { WorkspacePermissionSettingPanel } from './workspace_permission_setting_panel'; -import { SelectDataSourcePanel } from './select_data_source_panel'; -import { EnterDetailsPanel } from './workspace_enter_details_panel'; -import { - DetailTab, - WorkspaceOperationType, - selectDataSourceTitle, - usersAndPermissionsTitle, - workspaceDetailsTitle, - workspaceUseCaseTitle, -} from './constants'; -import { WorkspaceCreateActionPanel } from './workspace_create_action_panel'; +import { DetailTab, usersAndPermissionsTitle } from './constants'; import { WorkspaceFormErrorCallout } from './workspace_form_error_callout'; +import { useWorkspaceFormContext } from './workspace_form_context'; +import { WorkspaceDetailFormDetailsProps } from './workspace_detail_form_details'; interface FormGroupProps { - title: string; + title: React.ReactNode; children: React.ReactNode; + describe?: string; } -const FormGroup = ({ title, children }: FormGroupProps) => ( - - - -

{title}

-
-
- {children} -
+const FormGroup = ({ title, children, describe }: FormGroupProps) => ( + <> + + + +

{title}

+
+ + {describe} + +
+ + + {children} + +
+ + ); -export const WorkspaceDetailForm = (props: WorkspaceDetailedFormProps) => { - const { - detailTab, - application, - savedObjects, - defaultValues, - operationType, - availableUseCases, - dataSourceManagement: isDataSourceEnabled, - } = props; +export const WorkspaceDetailForm = (props: WorkspaceFormProps) => { + const { detailTab, detailTitle, defaultValues, availableUseCases } = props; const { formId, formData, + isEditing, formErrors, + setIsEditing, numberOfErrors, numberOfChanges, - setName, - setDescription, + handleResetForm, handleFormSubmit, - handleColorChange, - handleUseCaseChange, setPermissionSettings, - setSelectedDataSources, - } = useWorkspaceForm(props); - - const isDashboardAdmin = application?.capabilities?.dashboards?.isDashboardAdmin ?? false; - + } = useWorkspaceFormContext(); const disabledUserOrGroupInputIdsRef = useRef( defaultValues?.permissionSettings?.map((item) => item.id) ?? [] ); + const [isSaving, setIsSaving] = useState(false); + + // Handle beforeunload event + useEffect(() => { + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (!isSaving && isEditing && numberOfChanges > 0) { + event.preventDefault(); + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [isEditing, isSaving, numberOfChanges]); + return ( - + { + setIsSaving(true); + handleFormSubmit(event); + }} + component="form" + > {numberOfErrors > 0 && ( <> @@ -81,6 +102,38 @@ export const WorkspaceDetailForm = (props: WorkspaceDetailedFormProps) => { )} + + + +

{detailTitle}

+
+
+ + {isEditing ? ( + + {i18n.translate('workspace.detail.button.discardChanges', { + defaultMessage: 'Discard changes', + })} + + ) : ( + setIsEditing((prevIsEditing) => !prevIsEditing)} + data-test-subj="workspaceForm-workspaceDetails-edit" + > + {i18n.translate('workspace.detail.button.edit', { + defaultMessage: 'Edit', + })} + + )} + +
+ + {detailTab === DetailTab.Details && ( + + )} {detailTab === DetailTab.Collaborators && ( { permissionSettings={formData.permissionSettings} disabledUserOrGroupInputIds={disabledUserOrGroupInputIdsRef.current} data-test-subj={`workspaceForm-permissionSettingPanel`} + isEditing={isEditing} /> )} - {detailTab === DetailTab.Settings && ( - <> - - - - - - - - - {isDashboardAdmin && isDataSourceEnabled && ( - - - - )} - - )}
- {operationType === WorkspaceOperationType.Create && ( - - )} - {operationType === WorkspaceOperationType.Update && ( - - )}
); }; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form_details.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form_details.tsx new file mode 100644 index 00000000000..27de826141b --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form_details.tsx @@ -0,0 +1,140 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiSuperSelect, + EuiColorPicker, + EuiCompressedFormRow, + EuiDescribedFormGroup, + EuiCompressedTextArea, +} from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import { useObservable } from 'react-use'; +import { + detailsName, + detailsColorLabel, + detailsUseCaseLabel, + detailsColorHelpText, + detailsDescriptionPlaceholder, + detailsDescriptionIntroduction, + detailsUseCaseHelpText, +} from './constants'; +import { CoreStart } from '../../../../../core/public'; +import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; +import { DEFAULT_NAV_GROUPS } from '../../../../../core/public'; +import { useWorkspaceFormContext } from './workspace_form_context'; +import { WorkspaceUseCase as WorkspaceUseCaseObject } from '../../types'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceNameField } from './fields/workspace_name_field'; +import { WorkspaceDescriptionField } from './fields/workspace_description_field'; + +interface WorkspaceDetailFormDetailsProps { + availableUseCases: Array< + Pick + >; +} + +export const WorkspaceDetailFormDetailsProps = ({ + availableUseCases, +}: WorkspaceDetailFormDetailsProps) => { + const { + setName, + formData, + isEditing, + formErrors, + setDescription, + handleColorChange, + handleUseCaseChange, + } = useWorkspaceFormContext(); + const { + services: { workspaces }, + } = useOpenSearchDashboards(); + const [value, setValue] = useState(formData.useCase); + const currentWorkspace = useObservable(workspaces.currentWorkspace$); + const currentUseCase = getFirstUseCaseOfFeatureConfigs(currentWorkspace?.features ?? []); + + useEffect(() => { + setValue(formData.useCase); + }, [formData.useCase]); + + const options = availableUseCases + .filter((item) => !item.systematic) + .concat(DEFAULT_NAV_GROUPS.all) + .filter(({ id }) => { + // Essential can be changed to other use cases; + // Analytics (all) cannot be changed back to a single use case; + // Other use cases can only be changed to Analytics (all) use case. + return currentUseCase === 'analytics' || id === 'all' || id === currentUseCase; + }) + .map((useCase) => ({ + value: useCase.id, + inputDisplay: useCase.title, + 'data-test-subj': useCase.id, + })); + + return ( + <> + {detailsName}}> + + + + Description - optional + + } + description={detailsDescriptionIntroduction} + > + + + {detailsUseCaseLabel}}> + + { + setValue(id); + handleUseCaseChange(id); + }} + disabled={!isEditing} + readOnly={!isEditing} + /> + + + {detailsColorLabel}} + description={detailsColorHelpText} + > + + + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx index e8cfd534d92..a968f560fb0 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -6,12 +6,10 @@ import React, { useRef } from 'react'; import { EuiPanel, EuiSpacer, EuiTitle, EuiForm } from '@elastic/eui'; -import { WorkspaceBottomBar } from './workspace_bottom_bar'; import { WorkspaceFormProps } from './types'; import { useWorkspaceForm } from './use_workspace_form'; import { WorkspacePermissionSettingPanel } from './workspace_permission_setting_panel'; import { WorkspaceUseCase } from './workspace_use_case'; -import { WorkspaceOperationType } from './constants'; import { WorkspaceFormErrorCallout } from './workspace_form_error_callout'; import { WorkspaceCreateActionPanel } from './workspace_create_action_panel'; import { SelectDataSourcePanel } from './select_data_source_panel'; @@ -28,10 +26,10 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { application, savedObjects, defaultValues, - operationType, permissionEnabled, dataSourceManagement: isDataSourceEnabled, availableUseCases, + operationType, } = props; const { formId, @@ -127,16 +125,7 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { )} - {operationType === WorkspaceOperationType.Create && ( - - )} - {operationType === WorkspaceOperationType.Update && ( - - )} +
); }; 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 new file mode 100644 index 00000000000..417921f170d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form_context.tsx @@ -0,0 +1,69 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +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 { PublicAppInfo } from '../../../../../core/public'; +import { useWorkspaceForm } from './use_workspace_form'; + +interface WorkspaceFormContextProps { + formId: string; + setName: React.Dispatch>; + setDescription: React.Dispatch>; + formData: any; + isEditing: boolean; + formErrors: WorkspaceFormErrors; + setIsEditing: React.Dispatch>; + applications: PublicAppInfo[]; + numberOfErrors: number; + numberOfChanges: number; + handleResetForm: () => void; + handleFormSubmit: FormEventHandler; + handleColorChange: (text: string, output: EuiColorPickerOutput) => void; + handleUseCaseChange: (newUseCase: string) => void; + setPermissionSettings: React.Dispatch< + React.SetStateAction< + Array & Partial> + > + >; + setSelectedDataSources: React.Dispatch>; +} + +const initialContextValue: WorkspaceFormContextProps = {} as WorkspaceFormContextProps; +export const WorkspaceFormContext = createContext(initialContextValue); +interface ContextProps extends WorkspaceFormProps { + children: ReactNode; +} + +export const WorkspaceFormProvider = ({ + children, + application, + defaultValues, + operationType, + onSubmit, + permissionEnabled, + savedObjects, + availableUseCases, +}: ContextProps) => { + const workspaceFormContextValue = useWorkspaceForm({ + application, + defaultValues, + operationType, + onSubmit, + permissionEnabled, + savedObjects, + availableUseCases, + }); + + return ( + + {children} + + ); +}; + +export const useWorkspaceFormContext = () => useContext(WorkspaceFormContext); 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 8827f9bf9b7..40165530c3b 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 @@ -63,6 +63,7 @@ export interface WorkspacePermissionSettingInputProps { userId?: string; group?: string; modes?: WorkspacePermissionMode[]; + isEditing?: boolean; deletable?: boolean; userOrGroupDisabled: boolean; onGroupOrUserIdChange: ( @@ -84,6 +85,7 @@ export const WorkspacePermissionSettingInput = ({ userId, group, modes, + isEditing = true, deletable = true, userOrGroupDisabled, onDelete, @@ -147,13 +149,13 @@ export const WorkspacePermissionSettingInput = ({ defaultMessage: 'Select a user group', }) } - isDisabled={userOrGroupDisabled} + isDisabled={userOrGroupDisabled || !isEditing} /> - {deletable && ( + {deletable && isEditing && ( & Partial> ) => void; + isEditing?: boolean; } interface UserOrGroupSectionProps extends WorkspacePermissionSettingPanelProps { @@ -44,6 +45,7 @@ const UserOrGroupSection = ({ type, errors, onChange, + isEditing, nextIdGenerator, permissionSettings, disabledUserOrGroupInputIds, @@ -143,24 +145,27 @@ const UserOrGroupSection = ({ onDelete={handleDelete} onGroupOrUserIdChange={handleGroupOrUserIdChange} onPermissionModesChange={handlePermissionModesChange} + isEditing={isEditing} /> ))} - - {type === WorkspacePermissionItemType.User - ? i18n.translate('workspace.form.permissionSettingPanel.addUser', { - defaultMessage: 'Add user', - }) - : i18n.translate('workspace.form.permissionSettingPanel.addUserGroup', { - defaultMessage: 'Add user group', - })} - + {isEditing && ( + + {type === WorkspacePermissionItemType.User + ? i18n.translate('workspace.form.permissionSettingPanel.addUser', { + defaultMessage: 'Add user', + }) + : i18n.translate('workspace.form.permissionSettingPanel.addUserGroup', { + defaultMessage: 'Add user group', + })} + + )}
); }; @@ -170,6 +175,7 @@ export const WorkspacePermissionSettingPanel = ({ onChange, permissionSettings, disabledUserOrGroupInputIds, + isEditing = true, }: WorkspacePermissionSettingPanelProps) => { const userPermissionSettings = useMemo( () => @@ -224,6 +230,7 @@ export const WorkspacePermissionSettingPanel = ({ type={WorkspacePermissionItemType.User} nextIdGenerator={nextIdGenerator} disabledUserOrGroupInputIds={disabledUserOrGroupInputIds} + isEditing={isEditing} />
); diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx index be627abeff3..f0a5f6f7fc8 100644 --- a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -29,11 +29,9 @@ import { WORKSPACE_CREATE_APP_ID, WORKSPACE_LIST_APP_ID, MAX_WORKSPACE_PICKER_NUM, - WORKSPACE_DETAIL_APP_ID, } from '../../../common/constants'; -import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; -import { ALL_USE_CASE_ID, CoreStart, WorkspaceObject } from '../../../../../core/public'; -import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; +import { CoreStart, WorkspaceObject } from '../../../../../core/public'; +import { getFirstUseCaseOfFeatureConfigs, getUseCaseUrl } from '../../utils'; import { recentWorkspaceManager } from '../../recent_workspace_manager'; import { WorkspaceUseCase } from '../../types'; import { navigateToWorkspaceDetail } from '../utils/workspace'; @@ -134,15 +132,7 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { const getWorkspaceListGroup = (filterWorkspaceList: WorkspaceObject[], itemType: string) => { const listItems = filterWorkspaceList.map((workspace: WorkspaceObject) => { const useCase = getUseCase(workspace); - const appId = - (useCase?.id !== ALL_USE_CASE_ID && useCase?.features?.[0]) || WORKSPACE_DETAIL_APP_ID; - const useCaseURL = formatUrlWithWorkspaceId( - coreStart.application.getUrlForApp(appId, { - absolute: false, - }), - workspace.id, - coreStart.http.basePath - ); + const useCaseURL = getUseCaseUrl(useCase, workspace, coreStart.application, coreStart.http); return ( { savedObjects: [ { id: 'id1', - get: () => { - return 'mock_value'; + get: (param: string) => { + switch (param) { + case 'title': + return 'title1'; + case 'description': + return 'description1'; + case 'dataSourceEngineType': + return 'dataSourceEngineType1'; + case 'auth': + return 'mock_value'; + } }, }, ], @@ -389,8 +398,10 @@ describe('workspace utils: getDataSourcesList', () => { expect(await getDataSourcesList(mockedSavedObjectClient, [])).toStrictEqual([ { id: 'id1', - title: 'mock_value', + title: 'title1', auth: 'mock_value', + description: 'description1', + dataSourceEngineType: 'dataSourceEngineType1', }, ]); }); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index dc01e64182e..8d6049feac2 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -11,6 +11,8 @@ import { ALL_USE_CASE_ID, CoreStart, ChromeBreadcrumb, + ApplicationStart, + HttpSetup, } from '../../../core/public'; import { App, @@ -23,6 +25,7 @@ import { } from '../../../core/public'; import { DEFAULT_SELECTED_FEATURES_IDS, WORKSPACE_DETAIL_APP_ID } from '../common/constants'; import { WorkspaceUseCase } from './types'; +import { formatUrlWithWorkspaceId } from '../../../core/public/utils'; import { SigV4ServiceName } from '../../../plugins/data_source/common/data_sources'; export const USE_CASE_PREFIX = 'use-case-'; @@ -203,7 +206,7 @@ export const getDataSourcesList = (client: SavedObjectsStart['client'], workspac return client .find({ type: 'data-source', - fields: ['id', 'title', 'auth'], + fields: ['id', 'title', 'auth', 'description', 'dataSourceEngineType'], perPage: 10000, workspaces, }) @@ -214,10 +217,14 @@ export const getDataSourcesList = (client: SavedObjectsStart['client'], workspac const id = source.id; const title = source.get('title'); const auth = source.get('auth'); + const description = source.get('description'); + const dataSourceEngineType = source.get('dataSourceEngineType'); return { id, title, auth, + description, + dataSourceEngineType, }; }); } else { @@ -362,3 +369,21 @@ export function prependWorkspaceToBreadcrumbs( }); } } + +export const getUseCaseUrl = ( + useCase: WorkspaceUseCase | undefined, + workspace: WorkspaceObject, + application: ApplicationStart, + http: HttpSetup +): string => { + const appId = + (useCase?.id !== ALL_USE_CASE_ID && useCase?.features?.[0]) || WORKSPACE_DETAIL_APP_ID; + const useCaseURL = formatUrlWithWorkspaceId( + application.getUrlForApp(appId, { + absolute: false, + }), + workspace.id, + http.basePath + ); + return useCaseURL; +}; diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts index d9931f95c13..6482478c924 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -232,6 +232,9 @@ export class WorkspaceClient { const result = await this.safeFetch(this.getPath(id), { method: 'DELETE' }); if (result.success) { + // After deleting workspace, need to reset current workspace ID. + this.workspaces.currentWorkspaceId$.next(''); + await this.updateWorkspaceList(); }