diff --git a/changelogs/fragments/7101.yml b/changelogs/fragments/7101.yml new file mode 100644 index 000000000000..85d0afd1bf46 --- /dev/null +++ b/changelogs/fragments/7101.yml @@ -0,0 +1,2 @@ +feat: +- Support data source assignment in workspace. ([#7101](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7101)) \ No newline at end of file diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index 997771270ee6..0a85cd9be3ec 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -1308,6 +1308,8 @@ describe('SavedObjectsRepository', () => { }, }; + const workspaces = ['workspace1', 'workspace2']; + const getMockBulkUpdateResponse = (objects, options, includeOriginId) => ({ items: objects.map(({ type, id }) => ({ update: { @@ -1583,6 +1585,20 @@ describe('SavedObjectsRepository', () => { }); }); + it(`accepts workspaces property when providing workspaces info`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, workspaces })); + await bulkUpdateSuccess(objects); + const doc = { + doc: expect.objectContaining({ workspaces }), + }; + const body = [expect.any(Object), doc, expect.any(Object), doc]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }); + describe('errors', () => { const obj = { type: 'dashboard', @@ -3950,6 +3966,8 @@ describe('SavedObjectsRepository', () => { }, }; + const workspaces = ['workspace1', 'workspace2']; + const updateSuccess = async (type, id, attributes, options, includeOriginId) => { if (registry.isMultiNamespace(type)) { const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace); @@ -4137,6 +4155,18 @@ describe('SavedObjectsRepository', () => { expect.anything() ); }); + + it(`accepts workspaces when providing permissions info`, async () => { + await updateSuccess(type, id, attributes, { workspaces }); + const expected = expect.objectContaining({ workspaces }); + const body = { + doc: expected, + }; + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); }); describe('errors', () => { diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index b7282ecdaf80..ba414caacf58 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -1089,7 +1089,13 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { version, references, refresh = DEFAULT_REFRESH_SETTING, permissions } = options; + const { + version, + references, + refresh = DEFAULT_REFRESH_SETTING, + permissions, + workspaces, + } = options; const namespace = normalizeNamespace(options.namespace); let preflightResult: SavedObjectsRawDoc | undefined; @@ -1104,6 +1110,7 @@ export class SavedObjectsRepository { updated_at: time, ...(Array.isArray(references) && { references }), ...(permissions && { permissions }), + ...(workspaces && { workspaces }), }; const { body, statusCode } = await this.client.update( @@ -1142,6 +1149,7 @@ export class SavedObjectsRepository { namespaces, ...(originId && { originId }), ...(permissions && { permissions }), + ...(workspaces && { workspaces }), references, attributes, }; @@ -1342,7 +1350,14 @@ export class SavedObjectsRepository { }; } - const { attributes, references, version, namespace: objectNamespace, permissions } = object; + const { + attributes, + references, + version, + namespace: objectNamespace, + permissions, + workspaces, + } = object; if (objectNamespace === ALL_NAMESPACES_STRING) { return { @@ -1364,6 +1379,7 @@ export class SavedObjectsRepository { updated_at: time, ...(Array.isArray(references) && { references }), ...(permissions && { permissions }), + ...(workspaces && { workspaces }), }; const requiresNamespacesCheck = this._registry.isMultiNamespace(object.type); @@ -1515,8 +1531,13 @@ export class SavedObjectsRepository { response )[0] as any; - // eslint-disable-next-line @typescript-eslint/naming-convention - const { [type]: attributes, references, updated_at, permissions } = documentToSave; + const { + [type]: attributes, + references, + updated_at: updatedAt, + permissions, + workspaces, + } = documentToSave; if (error) { return { id, @@ -1531,11 +1552,12 @@ export class SavedObjectsRepository { type, ...(namespaces && { namespaces }), ...(originId && { originId }), - updated_at, + updated_at: updatedAt, version: encodeVersion(seqNo, primaryTerm), attributes, references, ...(permissions && { permissions }), + ...(workspaces && { workspaces }), }; }), }; diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index d39f101b18e2..db7c4771c4f7 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -45,6 +45,8 @@ const create = () => update: jest.fn(), addToNamespaces: jest.fn(), deleteFromNamespaces: jest.fn(), + addToWorkspaces: jest.fn(), + deleteFromWorkspaces: jest.fn(), } as unknown) as jest.Mocked); export const savedObjectsClientMock = { create }; diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index 676b1a37e051..b3511df29580 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -222,3 +222,87 @@ test(`#deleteByWorkspace`, async () => { expect(mockRepository.deleteByWorkspace).toHaveBeenCalledWith(workspace, options); expect(result).toBe(returnValue); }); + +test(`#deleteFromWorkspaces Should use update if there is existing workspaces`, async () => { + const returnValue = Symbol(); + const create = jest.fn(); + const mockRepository = { + get: jest.fn().mockResolvedValue({ + workspaces: ['id1', 'id2'], + }), + update: jest.fn().mockResolvedValue(returnValue), + create, + }; + const client = new SavedObjectsClient(mockRepository); + + const type = Symbol(); + const id = Symbol(); + await client.deleteFromWorkspaces(type, id, ['id2']); + expect(mockRepository.get).toHaveBeenCalledWith(type, id, {}); + expect(mockRepository.update).toHaveBeenCalledWith(type, id, undefined, { + version: undefined, + workspaces: ['id1'], + }); +}); + +test(`#deleteFromWorkspaces Should use overwrite create if there is no existing workspaces`, async () => { + const returnValue = Symbol(); + const create = jest.fn(); + const mockRepository = { + get: jest.fn().mockResolvedValue({ + workspaces: [], + }), + update: jest.fn().mockResolvedValue(returnValue), + create, + }; + const client = new SavedObjectsClient(mockRepository); + + const type = Symbol(); + const id = Symbol(); + await client.deleteFromWorkspaces(type, id, ['id1']); + expect(mockRepository.get).toHaveBeenCalledWith(type, id, {}); + expect(mockRepository.create).toHaveBeenCalledWith( + type, + {}, + { id, overwrite: true, permissions: undefined, version: undefined } + ); +}); + +test(`#deleteFromWorkspaces should throw error if no workspaces passed`, () => { + const mockRepository = {}; + const client = new SavedObjectsClient(mockRepository); + const type = Symbol(); + const id = Symbol(); + const workspaces = []; + expect(() => client.deleteFromWorkspaces(type, id, workspaces)).rejects.toThrowError(); +}); + +test(`#addToWorkspaces`, async () => { + const returnValue = Symbol(); + const mockRepository = { + get: jest.fn().mockResolvedValue(returnValue), + update: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const type = Symbol(); + const id = Symbol(); + const workspaces = Symbol(); + const result = await client.addToWorkspaces(type, id, workspaces); + + expect(mockRepository.get).toHaveBeenCalledWith(type, id, {}); + expect(mockRepository.update).toHaveBeenCalledWith(type, id, undefined, { + workspaces: [workspaces], + }); + + expect(result).toBe(returnValue); +}); + +test(`#addToWorkspaces should throw error if no workspaces passed`, () => { + const mockRepository = {}; + const client = new SavedObjectsClient(mockRepository); + const type = Symbol(); + const id = Symbol(); + const workspaces = []; + expect(() => client.addToWorkspaces(type, id, workspaces)).rejects.toThrowError(); +}); diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index a51a832bdb0a..7c0b16ac4585 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -105,7 +105,7 @@ export interface SavedObjectsBulkCreateObject { * @public */ export interface SavedObjectsBulkUpdateObject - extends Pick { + extends Pick { /** The ID of this Saved Object, guaranteed to be unique for all objects of the same `type` */ id: string; /** The type of this Saved Object. Each plugin can define it's own custom Saved Object types. */ @@ -189,6 +189,7 @@ export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { refresh?: MutatingOperationRefreshSetting; /** permission control describe by ACL object */ permissions?: Permissions; + workspaces?: string[]; } /** @@ -463,6 +464,68 @@ export class SavedObjectsClient { return await this._repository.deleteByWorkspace(workspace, options); }; + /** + * Remove a saved object from workspaces + * @param type + * @param id + * @param workspaces + */ + deleteFromWorkspaces = async (type: string, id: string, workspaces: string[]) => { + if (!workspaces || workspaces.length === 0) { + throw new TypeError(`Workspaces is required.`); + } + const object = await this.get(type, id); + const existingWorkspaces = object.workspaces ?? []; + const newWorkspaces = existingWorkspaces.filter((item) => { + return workspaces.indexOf(item) === -1; + }); + if (newWorkspaces.length > 0) { + return await this.update(type, id, object.attributes, { + workspaces: newWorkspaces, + version: object.version, + }); + } else { + // If there is no workspaces assigned, will create object with overwrite to delete workspace property. + return await this.create( + type, + { + ...object.attributes, + }, + { + id, + permissions: object.permissions, + overwrite: true, + version: object.version, + } + ); + } + }; + + /** + * Add a saved object to workspaces + * @param type + * @param id + * @param workspaces + */ + addToWorkspaces = async ( + type: string, + id: string, + workspaces: string[] + ): Promise => { + if (!workspaces || workspaces.length === 0) { + throw new TypeError(`Workspaces is required.`); + } + const object = await this.get(type, id); + const existingWorkspaces = object.workspaces ?? []; + const mergedWorkspaces = existingWorkspaces.concat(workspaces); + const nonDuplicatedWorkspaces = Array.from(new Set(mergedWorkspaces)); + + return await this.update(type, id, object.attributes, { + workspaces: nonDuplicatedWorkspaces, + version: object.version, + }); + }; + /** * Bulk Updates multiple SavedObject at once * diff --git a/src/plugins/workspace/common/types.ts b/src/plugins/workspace/common/types.ts new file mode 100644 index 000000000000..42c8ee31b0d1 --- /dev/null +++ b/src/plugins/workspace/common/types.ts @@ -0,0 +1,11 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; + +export type DataSource = Pick & { + // Id defined in SavedObjectAttribute could be single or array, here only should be single string. + id: string; +}; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx index 1c1632f7cb4d..7ceca3b302f3 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { PublicAppInfo } from 'opensearch-dashboards/public'; -import { fireEvent, render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor, act } from '@testing-library/react'; import { BehaviorSubject } from 'rxjs'; import { WorkspaceCreator as WorkspaceCreatorComponent } from './workspace_creator'; import { coreMock } from '../../../../../core/public/mocks'; @@ -23,6 +23,24 @@ const PublicAPPInfoMap = new Map([ ['dashboards', { id: 'dashboards', title: 'Dashboards' }], ]); +const dataSourcesList = [ + { + id: 'id1', + title: 'ds1', + // This is used for mocking saved object function + get: () => { + return 'ds1'; + }, + }, + { + id: '2', + title: 'ds2', + get: () => { + return 'ds2'; + }, + }, +]; + const mockCoreStart = coreMock.createStart(); const WorkspaceCreator = (props: any) => { @@ -53,6 +71,15 @@ const WorkspaceCreator = (props: any) => { ...mockCoreStart.workspaces, create: workspaceClientCreate, }, + savedObjects: { + ...mockCoreStart.savedObjects, + client: { + ...mockCoreStart.savedObjects.client, + find: jest.fn().mockResolvedValue({ + savedObjects: dataSourcesList, + }), + }, + }, }, }); @@ -170,7 +197,7 @@ describe('WorkspaceCreator', () => { description: 'test workspace description', features: expect.arrayContaining(['use-case-observability']), }), - undefined + { dataSources: [], permissions: undefined } ); await waitFor(() => { expect(notificationToastsAddSuccess).toHaveBeenCalled(); @@ -244,11 +271,14 @@ describe('WorkspaceCreator', () => { name: 'test workspace name', }), { - read: { - users: ['test user id'], - }, - library_read: { - users: ['test user id'], + dataSources: [], + permissions: { + read: { + users: ['test user id'], + }, + library_read: { + users: ['test user id'], + }, }, } ); @@ -257,4 +287,38 @@ describe('WorkspaceCreator', () => { }); expect(notificationToastsAddDanger).not.toHaveBeenCalled(); }); + + it('create workspace with customized selected dataSources', async () => { + const { getByTestId, getByTitle, getByText } = render( + + ); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceUseCase-observability')); + fireEvent.click(getByTestId('workspaceForm-select-dataSource-addNew')); + fireEvent.click(getByTestId('workspaceForm-select-dataSource-comboBox')); + await act(() => { + fireEvent.click(getByText('Select')); + }); + fireEvent.click(getByTitle(dataSourcesList[0].title)); + + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + }), + { + dataSources: ['id1'], + permissions: undefined, + } + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); }); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index 61905572f628..ffb5287f7cd0 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -16,6 +16,7 @@ import { WORKSPACE_OVERVIEW_APP_ID } from '../../../common/constants'; import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; import { WorkspaceClient } from '../../workspace_client'; import { convertPermissionSettingsToPermissions } from '../workspace_form'; +import { DataSource } from '../../../common/types'; export interface WorkspaceCreatorProps { workspaceConfigurableApps$?: BehaviorSubject; @@ -23,7 +24,7 @@ export interface WorkspaceCreatorProps { export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { const { - services: { application, notifications, http, workspaceClient }, + services: { application, notifications, http, workspaceClient, savedObjects }, } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>(); const workspaceConfigurableApps = useObservable( props.workspaceConfigurableApps$ ?? of(undefined) @@ -34,11 +35,14 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { async (data: WorkspaceFormSubmitData) => { let result; try { - const { permissionSettings, ...attributes } = data; - result = await workspaceClient.create( - attributes, - convertPermissionSettingsToPermissions(permissionSettings) - ); + const { permissionSettings, selectedDataSources, ...attributes } = data; + const selectedDataSourceIds = (selectedDataSources ?? []).map((ds: DataSource) => { + return ds.id; + }); + result = await workspaceClient.create(attributes, { + dataSources: selectedDataSourceIds, + permissions: convertPermissionSettingsToPermissions(permissionSettings), + }); if (result?.success) { notifications?.toasts.addSuccess({ title: i18n.translate('workspace.create.success', { @@ -92,9 +96,10 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { **/ style={{ width: '100%', maxWidth: 1000 }} > - {application && ( + {application && savedObjects && ( ({ + getDataSourcesList: jest.fn().mockResolvedValue(dataSources), +})); + +const mockCoreStart = coreMock.createStart(); + +const setup = ({ + savedObjects = mockCoreStart.savedObjects, + selectedDataSources = [], + onChange = jest.fn(), + errors = undefined, +}: Partial) => { + return render( + + ); +}; + +describe('SelectDataSourcePanel', () => { + it('should render consistent data sources when selected data sources passed', () => { + const { getByText } = setup({ selectedDataSources: dataSources }); + + expect(getByText(dataSources[0].title)).toBeInTheDocument(); + expect(getByText(dataSources[1].title)).toBeInTheDocument(); + }); + + it('should call onChange when clicking add new data source button', () => { + const onChangeMock = jest.fn(); + const { getByTestId } = setup({ onChange: onChangeMock }); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click(getByTestId('workspaceForm-select-dataSource-addNew')); + expect(onChangeMock).toHaveBeenCalledWith([ + { + id: '', + title: '', + }, + ]); + }); + + it('should call onChange when updating selected data sources in combo box', async () => { + const onChangeMock = jest.fn(); + const { getByTitle, getByText } = setup({ + onChange: onChangeMock, + selectedDataSources: [{ id: '', title: '' }], + }); + expect(onChangeMock).not.toHaveBeenCalled(); + await act(() => { + fireEvent.click(getByText('Select')); + }); + fireEvent.click(getByTitle(dataSources[0].title)); + expect(onChangeMock).toHaveBeenCalledWith([{ id: 'id1', title: 'title1' }]); + }); + + it('should call onChange when deleting selected data source', async () => { + const onChangeMock = jest.fn(); + const { getByLabelText } = setup({ + onChange: onChangeMock, + selectedDataSources: [{ id: '', title: '' }], + }); + expect(onChangeMock).not.toHaveBeenCalled(); + await act(() => { + fireEvent.click(getByLabelText('Delete data source')); + }); + expect(onChangeMock).toHaveBeenCalledWith([]); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx new file mode 100644 index 000000000000..0ccd325938a8 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/select_data_source_panel.tsx @@ -0,0 +1,148 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { + EuiButton, + EuiFormRow, + EuiText, + EuiSpacer, + EuiFlexGroup, + EuiFlexItem, + EuiButtonIcon, + EuiComboBox, + EuiComboBoxOptionOption, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { SavedObjectsStart } from '../../../../../core/public'; +import { getDataSourcesList } from '../../utils'; +import { DataSource } from '../../../common/types'; + +export interface SelectDataSourcePanelProps { + errors?: { [key: number]: string }; + savedObjects: SavedObjectsStart; + selectedDataSources: DataSource[]; + onChange: (value: DataSource[]) => void; +} + +export const SelectDataSourcePanel = ({ + errors, + onChange, + selectedDataSources, + savedObjects, +}: SelectDataSourcePanelProps) => { + const [dataSourcesOptions, setDataSourcesOptions] = useState([]); + useEffect(() => { + if (!savedObjects) return; + getDataSourcesList(savedObjects.client, ['*']).then((result) => { + const options = result.map(({ title, id }) => ({ + label: title, + value: id, + })); + setDataSourcesOptions(options); + }); + }, [savedObjects, setDataSourcesOptions]); + const handleAddNewOne = useCallback(() => { + onChange?.([ + ...selectedDataSources, + { + title: '', + id: '', + }, + ]); + }, [onChange, selectedDataSources]); + + const handleSelect = useCallback( + (selectedOptions, index) => { + const newOption = selectedOptions[0] + ? // Select new data source + { + title: selectedOptions[0].label, + id: selectedOptions[0].value, + } + : // Click reset button + { + title: '', + id: '', + }; + const newSelectedOptions = [...selectedDataSources]; + newSelectedOptions.splice(index, 1, newOption); + + onChange(newSelectedOptions); + }, + [onChange, selectedDataSources] + ); + + const handleDelete = useCallback( + (index) => { + const newSelectedOptions = [...selectedDataSources]; + newSelectedOptions.splice(index, 1); + + onChange(newSelectedOptions); + }, + [onChange, selectedDataSources] + ); + + return ( +
+ + + {i18n.translate('workspace.form.selectDataSource.subTitle', { + defaultMessage: 'Data source', + })} + + + + {selectedDataSources.map(({ id, title }, index) => ( + + + + handleSelect(selectedOptions, index)} + placeholder="Select" + style={{ width: 200 }} + /> + + + handleDelete(index)} + isDisabled={false} + /> + + + + ))} + + + {i18n.translate('workspace.form.selectDataSourcePanel.addNew', { + defaultMessage: 'Add New', + })} + +
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts index d8679629c48b..320217bb40b3 100644 --- a/src/plugins/workspace/public/components/workspace_form/types.ts +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -3,9 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { ApplicationStart, PublicAppInfo } from '../../../../../core/public'; +import type { + ApplicationStart, + PublicAppInfo, + SavedObjectsStart, +} from '../../../../../core/public'; import type { WorkspacePermissionMode } from '../../../common/constants'; import type { WorkspaceOperationType, WorkspacePermissionItemType } from './constants'; +import { DataSource } from '../../../common/types'; export type WorkspacePermissionSetting = | { @@ -27,6 +32,7 @@ export interface WorkspaceFormSubmitData { features?: string[]; color?: string; permissionSettings?: WorkspacePermissionSetting[]; + selectedDataSources?: DataSource[]; } export interface WorkspaceFormData extends WorkspaceFormSubmitData { @@ -35,13 +41,15 @@ export interface WorkspaceFormData extends WorkspaceFormSubmitData { } export type WorkspaceFormErrors = { - [key in keyof Omit]?: string; + [key in keyof Omit]?: string; } & { permissionSettings?: { [key: number]: string }; + selectedDataSources?: { [key: number]: string }; }; export interface WorkspaceFormProps { application: ApplicationStart; + savedObjects: SavedObjectsStart; onSubmit?: (formData: WorkspaceFormSubmitData) => void; defaultValues?: WorkspaceFormData; operationType?: WorkspaceOperationType; 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 4536e56d21bd..86e87b6fa964 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 @@ -17,7 +17,7 @@ import { getUseCaseFromFeatureConfig, isUseCaseFeatureConfig, } from '../../utils'; - +import { DataSource } from '../../../common/types'; import { WorkspaceFormProps, WorkspaceFormErrors, WorkspacePermissionSetting } from './types'; import { appendDefaultFeatureIds, getNumberOfErrors, validateWorkspaceForm } from './utils'; @@ -46,6 +46,12 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works : [] ); + const [selectedDataSources, setSelectedDataSources] = useState( + defaultValues?.selectedDataSources && defaultValues.selectedDataSources.length > 0 + ? defaultValues.selectedDataSources + : [] + ); + const [formErrors, setFormErrors] = useState({}); const numberOfErrors = useMemo(() => getNumberOfErrors(formErrors), [formErrors]); const formIdRef = useRef(); @@ -56,6 +62,7 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works useCases: selectedUseCases, color, permissionSettings, + selectedDataSources, }); const getFormDataRef = useRef(getFormData); getFormDataRef.current = getFormData; @@ -94,6 +101,7 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works features: formData.features, color: formData.color, permissionSettings: formData.permissionSettings as WorkspacePermissionSetting[], + selectedDataSources: formData.selectedDataSources, }); }, [onSubmit] @@ -122,6 +130,7 @@ export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: Works handleUseCasesChange, handleNameInputChange, setPermissionSettings, + setSelectedDataSources, handleDescriptionChange, }; }; diff --git a/src/plugins/workspace/public/components/workspace_form/utils.test.ts b/src/plugins/workspace/public/components/workspace_form/utils.test.ts index 6935f84eda35..55658e5ca005 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.test.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.test.ts @@ -7,6 +7,7 @@ import { validateWorkspaceForm, convertPermissionSettingsToPermissions, convertPermissionsToPermissionSettings, + getNumberOfErrors, } from './utils'; import { WorkspacePermissionMode } from '../../../common/constants'; import { WorkspacePermissionItemType } from './constants'; @@ -265,4 +266,49 @@ describe('validateWorkspaceForm', () => { }) ).toEqual({}); }); + + it('should return error if selected data source id is null', () => { + expect( + validateWorkspaceForm({ + name: 'test', + selectedDataSources: [ + { + id: '', + title: 'title', + }, + ], + }).selectedDataSources + ).toEqual({ 0: 'Invalid data source' }); + }); + + it('should return error if selected data source id is duplicated', () => { + expect( + validateWorkspaceForm({ + name: 'test', + selectedDataSources: [ + { + id: 'id', + title: 'title1', + }, + { + id: 'id', + title: 'title2', + }, + ], + }).selectedDataSources + ).toEqual({ '1': 'Duplicate data sources' }); + }); +}); + +describe('getNumberOfErrors', () => { + it('should calculate the error number of data sources form', () => { + expect( + getNumberOfErrors({ + selectedDataSources: { + '0': 'Invalid data source', + }, + }) + ).toEqual(1); + expect(getNumberOfErrors({})).toEqual(0); + }); }); diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts index 352cbe1aca1a..9bee34b3e800 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -15,6 +15,7 @@ import { } from './constants'; import { WorkspaceFormErrors, WorkspaceFormSubmitData, WorkspacePermissionSetting } from './types'; +import { DataSource } from '../../../common/types'; export const appendDefaultFeatureIds = (ids: string[]) => { // concat default checked ids and unique the result @@ -41,6 +42,9 @@ export const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => { if (formErrors.permissionSettings) { numberOfErrors += Object.keys(formErrors.permissionSettings).length; } + if (formErrors.selectedDataSources) { + numberOfErrors += Object.keys(formErrors.selectedDataSources).length; + } if (formErrors.features) { numberOfErrors += 1; } @@ -179,6 +183,11 @@ export const convertPermissionsToPermissionSettings = (permissions: SavedObjectP return finalPermissionSettings; }; +export const isSelectedDataSourcesDuplicated = ( + selectedDataSources: DataSource[], + row: DataSource +) => selectedDataSources.some((ds) => ds.id === row.id); + export const validateWorkspaceForm = ( formData: Omit, 'permissionSettings'> & { permissionSettings?: Array< @@ -187,7 +196,7 @@ export const validateWorkspaceForm = ( } ) => { const formErrors: WorkspaceFormErrors = {}; - const { name, permissionSettings, features } = formData; + const { name, permissionSettings, features, selectedDataSources } = formData; if (name) { if (!isValidFormTextInput(name)) { formErrors.name = i18n.translate('workspace.form.detail.name.invalid', { @@ -254,6 +263,24 @@ export const validateWorkspaceForm = ( formErrors.permissionSettings = permissionSettingsErrors; } } + if (selectedDataSources) { + const dataSourcesErrors: { [key: number]: string } = {}; + for (let i = 0; i < selectedDataSources.length; i++) { + const row = selectedDataSources[i]; + if (!row.id) { + dataSourcesErrors[i] = i18n.translate('workspace.form.dataSource.invalid', { + defaultMessage: 'Invalid data source', + }); + } else if (isSelectedDataSourcesDuplicated(selectedDataSources.slice(0, i), row)) { + dataSourcesErrors[i] = i18n.translate('workspace.form.permission.invalidate.group', { + defaultMessage: 'Duplicate data sources', + }); + } + } + if (Object.keys(dataSourcesErrors).length > 0) { + formErrors.selectedDataSources = dataSourcesErrors; + } + } return formErrors; }; 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 645e1843c9ab..ac5a65ea78a1 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -22,10 +22,12 @@ import { WorkspaceFormProps } from './types'; import { useWorkspaceForm } from './use_workspace_form'; import { WorkspacePermissionSettingPanel } from './workspace_permission_setting_panel'; import { WorkspaceUseCase } from './workspace_use_case'; +import { SelectDataSourcePanel } from './select_data_source_panel'; export const WorkspaceForm = (props: WorkspaceFormProps) => { const { application, + savedObjects, defaultValues, operationType, permissionEnabled, @@ -42,6 +44,7 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { handleUseCasesChange, handleNameInputChange, setPermissionSettings, + setSelectedDataSources, handleDescriptionChange, } = useWorkspaceForm(props); const workspaceDetailsTitle = i18n.translate('workspace.form.workspaceDetails.title', { @@ -173,6 +176,23 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { )} + + +

+ {i18n.translate('workspace.form.selectDataSource.title', { + defaultMessage: 'Select Data Sources', + })} +

+
+ +
+ { applications$: new BehaviorSubject>(PublicAPPInfoMap as any), }, workspaces: workspacesService, + savedObjects: { + ...mockCoreStart.savedObjects, + client: { + ...mockCoreStart.savedObjects.client, + find: jest.fn().mockResolvedValue({ + savedObjects: [], + }), + }, + }, }, }); @@ -207,9 +216,11 @@ describe('WorkspaceOverview', () => { const workspaceService = createWorkspacesSetupContractMockWithValue(workspaceObject); const { getByText } = render(WorkspaceOverviewPage({ workspacesService: workspaceService })); fireEvent.click(getByText('Settings')); - expect(screen.queryByText('Enter Details')).not.toBeNull(); - // title is hidden - expect(screen.queryByText('Update Workspace')).toBeNull(); + await waitFor(() => { + expect(screen.queryByText('Enter Details')).not.toBeNull(); + // title is hidden + expect(screen.queryByText('Update Workspace')).toBeNull(); + }); }); it('default selected tab is overview', async () => { diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx index 463db3591f9c..cc64102eac48 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.test.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { PublicAppInfo, WorkspaceObject } from 'opensearch-dashboards/public'; -import { fireEvent, render, waitFor } from '@testing-library/react'; +import { fireEvent, render, waitFor, screen, act } from '@testing-library/react'; import { BehaviorSubject } from 'rxjs'; import { WorkspaceUpdater as WorkspaceCreatorComponent } from './workspace_updater'; import { coreMock, workspacesServiceMock } from '../../../../../core/public/mocks'; @@ -42,8 +42,27 @@ const createWorkspacesSetupContractMockWithValue = () => { }; }; +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: any) => { const workspacesService = props.workspacesService || createWorkspacesSetupContractMockWithValue(); const { Provider } = createOpenSearchDashboardsReactContext({ @@ -74,6 +93,15 @@ const WorkspaceUpdater = (props: any) => { ...mockCoreStart.workspaces, update: workspaceClientUpdate, }, + savedObjects: { + ...mockCoreStart.savedObjects, + client: { + ...mockCoreStart.savedObjects.client, + find: jest.fn().mockResolvedValue({ + savedObjects: dataSourcesList, + }), + }, + }, }, }); @@ -128,6 +156,9 @@ describe('WorkspaceUpdater', () => { workspaceConfigurableApps$={new BehaviorSubject([...PublicAPPInfoMap.values()])} /> ); + + await waitFor(renderCompleted); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: '~' }, @@ -141,6 +172,8 @@ describe('WorkspaceUpdater', () => { workspaceConfigurableApps$={new BehaviorSubject([...PublicAPPInfoMap.values()])} /> ); + await waitFor(renderCompleted); + fireEvent.click(getByTestId('workspaceForm-bottomBar-cancelButton')); await findByText('Discard changes?'); fireEvent.click(getByTestId('confirmModalConfirmButton')); @@ -148,11 +181,13 @@ describe('WorkspaceUpdater', () => { }); it('update workspace successfully', async () => { - const { getByTestId, getByText, getAllByText } = render( + const { getByTestId, getAllByText, getAllByTestId, getAllByLabelText } = render( ); + await waitFor(renderCompleted); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -176,10 +211,14 @@ describe('WorkspaceUpdater', () => { fireEvent.click(getByTestId('workspaceForm-permissionSettingPanel-user-addNew')); const userIdInput = getAllByText('Select')[0]; fireEvent.click(userIdInput); - fireEvent.input(getByTestId('comboBoxSearchInput'), { + fireEvent.input(getAllByTestId('comboBoxSearchInput')[0], { target: { value: 'test user id' }, }); - fireEvent.blur(getByTestId('comboBoxSearchInput')); + fireEvent.blur(getAllByTestId('comboBoxSearchInput')[0]); + + await act(() => { + fireEvent.click(getAllByLabelText('Delete data source')[0]); + }); fireEvent.click(getByTestId('workspaceForm-bottomBar-updateButton')); expect(workspaceClientUpdate).toHaveBeenCalledWith( @@ -191,12 +230,15 @@ describe('WorkspaceUpdater', () => { features: expect.arrayContaining(['use-case-analytics']), }), { - read: { - users: ['test user id'], - }, - library_read: { - users: ['test user id'], + permissions: { + read: { + users: ['test user id'], + }, + library_read: { + users: ['test user id'], + }, }, + dataSources: ['id2'], } ); await waitFor(() => { @@ -215,6 +257,8 @@ describe('WorkspaceUpdater', () => { workspaceConfigurableApps$={new BehaviorSubject([...PublicAPPInfoMap.values()])} /> ); + await waitFor(renderCompleted); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -236,6 +280,8 @@ describe('WorkspaceUpdater', () => { workspaceConfigurableApps$={new BehaviorSubject([...PublicAPPInfoMap.values()])} /> ); + await waitFor(renderCompleted); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, @@ -257,6 +303,8 @@ describe('WorkspaceUpdater', () => { /> ); + await waitFor(renderCompleted); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); fireEvent.input(nameInput, { target: { value: 'test workspace name' }, diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx index d786abc19179..57f246866a42 100644 --- a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -21,6 +21,8 @@ import { convertPermissionsToPermissionSettings, convertPermissionSettingsToPermissions, } from '../workspace_form'; +import { getDataSourcesList } from '../../utils'; +import { DataSource } from '../../../common/types'; export interface WorkspaceUpdaterProps { workspaceConfigurableApps$?: BehaviorSubject; @@ -42,9 +44,13 @@ function getFormDataFromWorkspace( }; } +type FormDataFromWorkspace = ReturnType & { + selectedDataSources: DataSource[]; +}; + export const WorkspaceUpdater = (props: WorkspaceUpdaterProps) => { const { - services: { application, workspaces, notifications, http, workspaceClient }, + services: { application, workspaces, notifications, http, workspaceClient, savedObjects }, } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>(); const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; @@ -52,9 +58,7 @@ export const WorkspaceUpdater = (props: WorkspaceUpdaterProps) => { const workspaceConfigurableApps = useObservable( props.workspaceConfigurableApps$ ?? of(undefined) ); - const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState( - getFormDataFromWorkspace(currentWorkspace) - ); + const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState(); const handleWorkspaceFormSubmit = useCallback( async (data: WorkspaceFormSubmitData) => { @@ -69,12 +73,14 @@ export const WorkspaceUpdater = (props: WorkspaceUpdaterProps) => { } try { - const { permissionSettings, ...attributes } = data; - result = await workspaceClient.update( - currentWorkspace.id, - attributes, - convertPermissionSettingsToPermissions(permissionSettings) - ); + 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', { @@ -111,8 +117,17 @@ export const WorkspaceUpdater = (props: WorkspaceUpdaterProps) => { ); useEffect(() => { - setCurrentWorkspaceFormData(getFormDataFromWorkspace(currentWorkspace)); - }, [currentWorkspace]); + const rawFormData = getFormDataFromWorkspace(currentWorkspace); + + if (rawFormData && savedObjects && currentWorkspace) { + getDataSourcesList(savedObjects.client, [currentWorkspace.id]).then((selectedDataSources) => { + setCurrentWorkspaceFormData({ + ...rawFormData, + selectedDataSources, + }); + }); + } + }, [currentWorkspace, savedObjects]); if (!currentWorkspaceFormData) { return null; @@ -131,7 +146,7 @@ export const WorkspaceUpdater = (props: WorkspaceUpdaterProps) => { hasShadow={false} style={{ width: '100%', maxWidth: props.maxWidth ? props.maxWidth : 1000 }} > - {application && ( + {application && savedObjects && ( { workspaceConfigurableApps={workspaceConfigurableApps} permissionEnabled={isPermissionEnabled} permissionLastAdminItemDeletable={false} + savedObjects={savedObjects} /> )} diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index 6ca878ec51dd..e3211990acf2 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -10,8 +10,12 @@ import { isAppAccessibleInWorkspace, isFeatureIdInsideUseCase, isNavGroupInFeatureConfigs, + getDataSourcesList, } from './utils'; import { WorkspaceAvailability } from '../../../core/public'; +import { coreMock } from '../../../core/public/mocks'; + +const startMock = coreMock.createStart(); describe('workspace utils: featureMatchesConfig', () => { it('feature configured with `*` should match any features', () => { @@ -293,3 +297,31 @@ describe('workspace utils: isNavGroupInFeatureConfigs', () => { ).toBe(true); }); }); + +describe('workspace utils: getDataSourcesList', () => { + const mockedSavedObjectClient = startMock.savedObjects.client; + + it('should return result when passed saved object client', async () => { + mockedSavedObjectClient.find = jest.fn().mockResolvedValue({ + savedObjects: [ + { + id: 'id1', + get: () => { + return 'title1'; + }, + }, + ], + }); + expect(await getDataSourcesList(mockedSavedObjectClient, [])).toStrictEqual([ + { + id: 'id1', + title: 'title1', + }, + ]); + }); + + it('should return empty array if no saved objects responded', async () => { + mockedSavedObjectClient.find = jest.fn().mockResolvedValue({}); + expect(await getDataSourcesList(mockedSavedObjectClient, [])).toStrictEqual([]); + }); +}); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index ce77d8370d5f..da9987b2aa1a 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { SavedObjectsStart } from '../../../core/public'; import { App, AppCategory, @@ -176,3 +177,28 @@ export const filterWorkspaceConfigurableApps = (applications: PublicAppInfo[]) = return visibleApplications; }; + +export const getDataSourcesList = (client: SavedObjectsStart['client'], workspaces: string[]) => { + return client + .find({ + type: 'data-source', + fields: ['id', 'title'], + perPage: 10000, + workspaces, + }) + .then((response) => { + const objects = response?.savedObjects; + if (objects) { + return objects.map((source) => { + const id = source.id; + const title = source.get('title'); + return { + id, + title, + }; + }); + } else { + return []; + } + }); +}; diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts index 892fcc52cef1..e62f9518916f 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -185,7 +185,10 @@ export class WorkspaceClient { */ public async create( attributes: Omit, - permissions?: SavedObjectPermissions + settings: { + dataSources?: string[]; + permissions?: SavedObjectPermissions; + } ): Promise>> { const path = this.getPath(); @@ -193,7 +196,7 @@ export class WorkspaceClient { method: 'POST', body: JSON.stringify({ attributes, - permissions, + settings, }), }); @@ -272,12 +275,15 @@ export class WorkspaceClient { public async update( id: string, attributes: Partial, - permissions?: SavedObjectPermissions + settings: { + dataSources?: string[]; + permissions?: SavedObjectPermissions; + } ): Promise> { const path = this.getPath(id); const body = { attributes, - permissions, + settings, }; const result = await this.safeFetch(path, { diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 1e0530217f21..fdcbde636429 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -577,7 +577,10 @@ describe('workspace service api integration test when savedObjects.permission.en .post(root, `/api/workspaces`) .send({ attributes: omitId(testWorkspace), - permissions: { invalid_type: { users: ['foo'] } }, + settings: { + permissions: { invalid_type: { users: ['foo'] } }, + dataSources: [], + }, }) .expect(400); @@ -585,7 +588,10 @@ describe('workspace service api integration test when savedObjects.permission.en .post(root, `/api/workspaces`) .send({ attributes: omitId(testWorkspace), - permissions: { read: { users: ['foo'] } }, + settings: { + permissions: { read: { users: ['foo'] } }, + dataSources: [], + }, }) .expect(200); @@ -613,7 +619,10 @@ describe('workspace service api integration test when savedObjects.permission.en attributes: { ...omitId(testWorkspace), }, - permissions: { write: { users: ['foo'] } }, + settings: { + permissions: { write: { users: ['foo'] } }, + dataSources: [], + }, }) .expect(200); expect(updateResult.body.result).toBe(true); diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 2f7c6d8969fd..ce7c35f45235 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -29,6 +29,13 @@ const workspacePermissions = schema.recordOf( schema.recordOf(principalType, schema.arrayOf(schema.string()), {}) ); +const dataSourceIds = schema.arrayOf(schema.string()); + +const settingsSchema = schema.object({ + permissions: schema.maybe(workspacePermissions), + dataSources: schema.maybe(dataSourceIds), +}); + const workspaceOptionalAttributesSchema = { description: schema.maybe(schema.string()), features: schema.maybe(schema.arrayOf(schema.string())), @@ -127,20 +134,22 @@ export function registerRoutes({ validate: { body: schema.object({ attributes: createWorkspaceAttributesSchema, - permissions: schema.maybe(workspacePermissions), + settings: settingsSchema, }), }, }, router.handleLegacyErrors(async (context, req, res) => { - const { attributes, permissions } = req.body; + const { attributes, settings } = req.body; const principals = permissionControlClient?.getPrincipalsFromRequest(req); - const createPayload: Omit = attributes; + const createPayload: Omit & { + dataSources?: string[]; + } = attributes; if (isPermissionControlEnabled) { - createPayload.permissions = permissions; + createPayload.permissions = settings.permissions; // Assign workspace owner to current user if (!!principals?.users?.length) { - const acl = new ACL(permissions); + const acl = new ACL(settings.permissions); const currentUserId = principals.users[0]; [WorkspacePermissionMode.Write, WorkspacePermissionMode.LibraryWrite].forEach( (permissionMode) => { @@ -153,6 +162,8 @@ export function registerRoutes({ } } + createPayload.dataSources = settings.dataSources; + const result = await client.create( { context, @@ -173,13 +184,13 @@ export function registerRoutes({ }), body: schema.object({ attributes: updateWorkspaceAttributesSchema, - permissions: schema.maybe(workspacePermissions), + settings: settingsSchema, }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { id } = req.params; - const { attributes, permissions } = req.body; + const { attributes, settings } = req.body; const result = await client.update( { @@ -190,7 +201,8 @@ export function registerRoutes({ id, { ...attributes, - ...(isPermissionControlEnabled ? { permissions } : {}), + ...(isPermissionControlEnabled ? { permissions: settings.permissions } : {}), + ...{ dataSources: settings.dataSources }, } ); return res.ok({ body: result }); diff --git a/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.test.ts b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.test.ts index f5613550a541..118b7840ce3a 100644 --- a/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.test.ts +++ b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.test.ts @@ -499,20 +499,4 @@ describe('WorkspaceConflictSavedObjectsClientWrapper', () => { ); }); }); - - describe('find', () => { - beforeEach(() => { - mockedClient.find.mockClear(); - }); - - it(`workspaces parameters should be removed when finding data sources`, async () => { - await wrapperClient.find({ - type: DATA_SOURCE_SAVED_OBJECT_TYPE, - workspaces: ['foo'], - }); - expect(mockedClient.find).toBeCalledWith({ - type: DATA_SOURCE_SAVED_OBJECT_TYPE, - }); - }); - }); }); diff --git a/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts index 0c4447e69e21..c71e59d3ab72 100644 --- a/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts +++ b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts @@ -50,11 +50,6 @@ export class WorkspaceConflictSavedObjectsClientWrapper { private isConfigType(type: SavedObject['type']): boolean { return type === UI_SETTINGS_SAVED_OBJECTS_TYPE; } - private formatFindParams(options: SavedObjectsFindOptions): SavedObjectsFindOptions { - const isListingDataSource = this.isDataSourceType(options.type); - const { workspaces, ...otherOptions } = options; - return isListingDataSource ? otherOptions : options; - } /** * Workspace is a concept to manage saved objects and the `workspaces` field of each object indicates workspaces the object belongs to. @@ -412,10 +407,7 @@ export class WorkspaceConflictSavedObjectsClientWrapper { bulkCreate: bulkCreateWithWorkspaceConflictCheck, checkConflicts: checkConflictWithWorkspaceConflictCheck, delete: wrapperOptions.client.delete, - find: (options: SavedObjectsFindOptions) => - // TODO: The `formatFindParams` is a workaround for 2.14 to always list global data sources, - // should remove this workaround in the upcoming release once readonly share is available. - wrapperOptions.client.find(this.formatFindParams(options)), + find: wrapperOptions.client.find, bulkGet: wrapperOptions.client.bulkGet, get: wrapperOptions.client.get, update: wrapperOptions.client.update, diff --git a/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts index 63f19b5dd0f7..9a41028908f9 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts @@ -29,7 +29,8 @@ export class WorkspaceIdConsumerWrapper { const workspaceIdsInUserOptions = options?.workspaces; let finalWorkspaces: string[] = []; if (options?.hasOwnProperty('workspaces')) { - finalWorkspaces = workspaceIdsInUserOptions || []; + // In order to get all data sources in workspace, use * to skip appending workspace id automatically + finalWorkspaces = (workspaceIdsInUserOptions || []).filter((id) => id !== '*'); } else if (workspaceIdParsedFromRequest) { finalWorkspaces = [workspaceIdParsedFromRequest]; } diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index 10a7649c87da..703df213bc84 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -51,13 +51,14 @@ export interface IWorkspaceClientImpl { /** * Create a workspace * @param requestDetail {@link IRequestDetail} - * @param payload {@link WorkspaceAttribute} - * @returns a Promise with a new-created id for the workspace + * @param payload - An object of type {@link WorkspaceAttributeWithPermission} excluding the 'id' property, and also containing an optional array of string. * @public */ create( requestDetail: IRequestDetail, - payload: Omit + payload: Omit & { + dataSources?: string[]; + } ): Promise>; /** * List workspaces @@ -88,14 +89,16 @@ export interface IWorkspaceClientImpl { * Update the detail of a given workspace * @param requestDetail {@link IRequestDetail} * @param id workspace id - * @param payload {@link WorkspaceAttribute} + * @param payload - An object of type {@link WorkspaceAttributeWithPermission} excluding the 'id' property, and also containing an optional array of string. * @returns a Promise with a boolean result indicating if the update operation successed. * @public */ update( requestDetail: IRequestDetail, id: string, - payload: Partial> + payload: Partial> & { + dataSources?: string[]; + } ): Promise>; /** * Delete a given workspace diff --git a/src/plugins/workspace/server/utils.test.ts b/src/plugins/workspace/server/utils.test.ts index 095208dca8b9..d25234f0972c 100644 --- a/src/plugins/workspace/server/utils.test.ts +++ b/src/plugins/workspace/server/utils.test.ts @@ -4,12 +4,17 @@ */ import { AuthStatus } from '../../../core/server'; -import { httpServerMock, httpServiceMock } from '../../../core/server/mocks'; +import { + httpServerMock, + httpServiceMock, + savedObjectsClientMock, +} from '../../../core/server/mocks'; import { generateRandomId, getOSDAdminConfigFromYMLConfig, getPrincipalsFromRequest, updateDashboardAdminStateForRequest, + getDataSourcesList, } from './utils'; import { getWorkspaceState } from '../../../core/server/utils'; import { Observable, of } from 'rxjs'; @@ -151,4 +156,25 @@ describe('workspace utils', () => { expect(groups).toEqual([]); expect(users).toEqual([]); }); + + it('should return dataSources list when passed savedObject client', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + const dataSources = [ + { + id: 'ds-1', + }, + ]; + savedObjectsClient.find = jest.fn().mockResolvedValue({ + saved_objects: dataSources, + }); + const result = await getDataSourcesList(savedObjectsClient, []); + expect(result).toEqual(dataSources); + }); + + it('should return empty array when finding no saved objects', async () => { + const savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient.find = jest.fn().mockResolvedValue({}); + const result = await getDataSourcesList(savedObjectsClient, []); + expect(result).toEqual([]); + }); }); diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts index d98352050a31..54626bb4705e 100644 --- a/src/plugins/workspace/server/utils.ts +++ b/src/plugins/workspace/server/utils.ts @@ -13,6 +13,7 @@ import { Principals, PrincipalType, SharedGlobalConfig, + SavedObjectsClientContract, } from '../../../core/server'; import { AuthInfo } from './types'; import { updateWorkspaceState } from '../../../core/server/utils'; @@ -89,3 +90,26 @@ export const getOSDAdminConfigFromYMLConfig = async ( return [groupsResult, usersResult]; }; + +export const getDataSourcesList = (client: SavedObjectsClientContract, workspaces: string[]) => { + return client + .find({ + type: 'data-source', + fields: ['id', 'title'], + perPage: 10000, + workspaces, + }) + .then((response) => { + const objects = response?.saved_objects; + if (objects) { + return objects.map((source) => { + const id = source.id; + return { + id, + }; + }); + } else { + return []; + } + }); +}; diff --git a/src/plugins/workspace/server/workspace_client.test.ts b/src/plugins/workspace/server/workspace_client.test.ts new file mode 100644 index 000000000000..7d1d692e2e4d --- /dev/null +++ b/src/plugins/workspace/server/workspace_client.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspaceClient } from './workspace_client'; + +import { coreMock, httpServerMock } from '../../../core/server/mocks'; +import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../data_source/common'; +import { SavedObjectsServiceStart } from '../../../core/server'; +import { IRequestDetail } from './types'; + +const coreSetup = coreMock.createSetup(); + +const mockWorkspaceId = 'workspace_id'; +const mockWorkspaceName = 'workspace_name'; + +jest.mock('./utils', () => ({ + generateRandomId: () => mockWorkspaceId, + getDataSourcesList: jest.fn().mockResolvedValue([ + { + id: 'id1', + }, + { + id: 'id2', + }, + ]), +})); + +describe('#WorkspaceClient', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const find = jest.fn(); + const addToWorkspaces = jest.fn(); + const deleteFromWorkspaces = jest.fn(); + const savedObjects = ({ + ...coreSetup.savedObjects, + getScopedClient: () => ({ + find, + addToWorkspaces, + deleteFromWorkspaces, + get: jest.fn().mockResolvedValue({ + attributes: { + name: mockWorkspaceName, + }, + }), + }), + } as unknown) as SavedObjectsServiceStart; + + const mockRequestDetail = ({ + request: httpServerMock.createOpenSearchDashboardsRequest(), + context: coreMock.createRequestHandlerContext(), + logger: {}, + } as unknown) as IRequestDetail; + + it('create# should not call addToWorkspaces if no data sources passed', async () => { + const client = new WorkspaceClient(coreSetup); + await client.setup(coreSetup); + client?.setSavedObjects(savedObjects); + + await client.create(mockRequestDetail, { + permissions: {}, + dataSources: [], + name: mockWorkspaceName, + }); + expect(addToWorkspaces).not.toHaveBeenCalled(); + }); + + it('create# should call addToWorkspaces with passed data sources normally', async () => { + const client = new WorkspaceClient(coreSetup); + await client.setup(coreSetup); + client?.setSavedObjects(savedObjects); + + await client.create(mockRequestDetail, { + name: mockWorkspaceName, + permissions: {}, + dataSources: ['id1'], + }); + + expect(addToWorkspaces).toHaveBeenCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, 'id1', [ + mockWorkspaceId, + ]); + }); + + it('update# should not call addToWorkspaces if no new data sources added', async () => { + const client = new WorkspaceClient(coreSetup); + await client.setup(coreSetup); + client?.setSavedObjects(savedObjects); + + await client.update(mockRequestDetail, mockWorkspaceId, { + permissions: {}, + name: mockWorkspaceName, + dataSources: ['id1', 'id2'], + }); + + expect(addToWorkspaces).not.toHaveBeenCalled(); + }); + + it('update# should call deleteFromWorkspaces if there is data source to be removed', async () => { + const client = new WorkspaceClient(coreSetup); + await client.setup(coreSetup); + client?.setSavedObjects(savedObjects); + + await client.update(mockRequestDetail, mockWorkspaceId, { + permissions: {}, + name: 'workspace_name', + dataSources: ['id3', 'id4'], + }); + expect(deleteFromWorkspaces).toHaveBeenCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, 'id1', [ + mockWorkspaceId, + ]); + + expect(deleteFromWorkspaces).toHaveBeenCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, 'id2', [ + mockWorkspaceId, + ]); + }); + it('update# should calculate data sources to be added and to be removed', async () => { + const client = new WorkspaceClient(coreSetup); + await client.setup(coreSetup); + client?.setSavedObjects(savedObjects); + + await client.update(mockRequestDetail, mockWorkspaceId, { + permissions: {}, + name: mockWorkspaceName, + dataSources: ['id1', 'id3'], + }); + expect(addToWorkspaces).toHaveBeenCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, 'id3', [ + mockWorkspaceId, + ]); + + expect(deleteFromWorkspaces).toHaveBeenCalledWith(DATA_SOURCE_SAVED_OBJECT_TYPE, 'id2', [ + mockWorkspaceId, + ]); + }); +}); diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index a906e3548cab..ad9d81cb952c 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -20,11 +20,12 @@ import { WorkspaceAttributeWithPermission, } from './types'; import { workspace } from './saved_objects'; -import { generateRandomId } from './utils'; +import { generateRandomId, getDataSourcesList } from './utils'; import { WORKSPACE_ID_CONSUMER_WRAPPER_ID, WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, } from '../common/constants'; +import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../data_source/common'; const WORKSPACE_ID_SIZE = 6; @@ -86,10 +87,12 @@ export class WorkspaceClient implements IWorkspaceClientImpl { } public async create( requestDetail: IRequestDetail, - payload: Omit + payload: Omit & { + dataSources?: string[]; + } ): ReturnType { try { - const { permissions, ...attributes } = payload; + const { permissions, dataSources, ...attributes } = payload; const id = generateRandomId(WORKSPACE_ID_SIZE); const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); const existingWorkspaceRes = await this.getScopedClientWithoutPermission(requestDetail)?.find( @@ -102,6 +105,15 @@ export class WorkspaceClient implements IWorkspaceClientImpl { if (existingWorkspaceRes && existingWorkspaceRes.total > 0) { throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); } + + if (dataSources) { + const promises = []; + for (const dataSourceId of dataSources) { + promises.push(client.addToWorkspaces(DATA_SOURCE_SAVED_OBJECT_TYPE, dataSourceId, [id])); + } + await Promise.all(promises); + } + const result = await client.create>( WORKSPACE_TYPE, attributes, @@ -110,6 +122,7 @@ export class WorkspaceClient implements IWorkspaceClientImpl { permissions, } ); + return { success: true, result: { @@ -173,9 +186,11 @@ export class WorkspaceClient implements IWorkspaceClientImpl { public async update( requestDetail: IRequestDetail, id: string, - payload: Partial> + payload: Partial> & { + dataSources?: string[]; + } ): Promise> { - const { permissions, ...attributes } = payload; + const { permissions, dataSources: newDataSources, ...attributes } = payload; try { const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); const workspaceInDB: SavedObject = await client.get(WORKSPACE_TYPE, id); @@ -192,6 +207,37 @@ export class WorkspaceClient implements IWorkspaceClientImpl { throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); } } + + if (newDataSources) { + const originalSelectedDataSources = await getDataSourcesList(client, [id]); + const originalSelectedDataSourceIds = originalSelectedDataSources.map((ds) => ds.id); + const dataSourcesToBeRemoved = originalSelectedDataSourceIds.filter( + (ds) => !newDataSources.find((item) => item === ds) + ); + const dataSourcesToBeAdded = newDataSources.filter( + (ds) => !originalSelectedDataSourceIds.find((item) => item === ds) + ); + + const promises = []; + if (dataSourcesToBeRemoved.length > 0) { + for (const dataSourceId of dataSourcesToBeRemoved) { + promises.push( + client.deleteFromWorkspaces(DATA_SOURCE_SAVED_OBJECT_TYPE, dataSourceId, [id]) + ); + } + } + if (dataSourcesToBeAdded.length > 0) { + for (const dataSourceId of dataSourcesToBeAdded) { + promises.push( + client.addToWorkspaces(DATA_SOURCE_SAVED_OBJECT_TYPE, dataSourceId, [id]) + ); + } + } + if (promises.length > 0) { + await Promise.all(promises); + } + } + await client.create>( WORKSPACE_TYPE, { ...workspaceInDB.attributes, ...attributes },