From a79cda62fbb0cb4c90bf2b81a8132c2d5cb7421d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 22 Apr 2024 07:37:40 +0000 Subject: [PATCH] [Workspace] Add Workspace filter to saved objects management page (#6458) * Add workspace filter into saved objects page (#211) * Add workspace column/filter into saved objects page Signed-off-by: Hailong Cui fix failed test case Signed-off-by: Hailong Cui move workspace column to its own folder Signed-off-by: Hailong Cui * default workspace Signed-off-by: Hailong Cui fix test case Signed-off-by: Hailong Cui add test case Signed-off-by: Hailong Cui remove hide import Signed-off-by: Hailong Cui * address review comments Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui * replace default workspace with public workspace (#322) Signed-off-by: Hailong Cui * Add workspace filter Signed-off-by: Hailong Cui * revert query params change Signed-off-by: Hailong Cui * udpate test case name to more clear Signed-off-by: Hailong Cui * support public workspace Signed-off-by: Hailong Cui * Add changelog Signed-off-by: Hailong Cui * update fetchExportByTypeAndSearch parameter Co-authored-by: SuZhou-Joe Signed-off-by: Hailong Cui * remove virtrual public workspace Signed-off-by: Hailong Cui * update comments Signed-off-by: Hailong Cui * fix public workspace query when permission is not enabled Signed-off-by: Hailong Cui * home dashboards/listing page only show public workspace data Signed-off-by: Hailong Cui * Add more test case Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui Signed-off-by: SuZhou-Joe Co-authored-by: SuZhou-Joe (cherry picked from commit 9408e4986f21d6ea7d2958707448e1604702f3df) Signed-off-by: github-actions[bot] # Conflicts: # CHANGELOG.md --- src/core/public/index.ts | 8 +- src/core/server/index.ts | 7 +- src/core/utils/constants.ts | 12 + src/core/utils/index.ts | 7 +- .../lib/fetch_export_by_type_and_search.ts | 19 +- .../public/lib/get_saved_object_counts.ts | 5 +- .../public/lib/parse_query.test.ts | 3 + .../public/lib/parse_query.ts | 7 + .../saved_objects_table.test.tsx.snap | 1 + .../saved_objects_table.test.tsx | 231 +++++++++++++++++- .../objects_table/saved_objects_table.tsx | 130 ++++++++-- .../public/utils.test.ts | 20 ++ .../saved_objects_management/public/utils.ts | 14 ++ .../server/routes/find.ts | 12 +- .../server/routes/scroll_count.ts | 25 +- src/plugins/workspace/server/plugin.ts | 4 +- .../workspace_id_consumer_wrapper.test.ts | 62 +++++ .../workspace_id_consumer_wrapper.ts | 40 ++- 18 files changed, 569 insertions(+), 38 deletions(-) create mode 100644 src/plugins/saved_objects_management/public/utils.test.ts create mode 100644 src/plugins/saved_objects_management/public/utils.ts diff --git a/src/core/public/index.ts b/src/core/public/index.ts index cc51c7215964..849c954c1262 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -94,7 +94,13 @@ export type { Logos } from '../common'; export { PackageInfo, EnvironmentMode } from '../server/types'; /** @interal */ export { CoreContext, CoreSystem } from './core_system'; -export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE, cleanWorkspaceId } from '../utils'; +export { + DEFAULT_APP_CATEGORIES, + WORKSPACE_TYPE, + cleanWorkspaceId, + PUBLIC_WORKSPACE_ID, + PUBLIC_WORKSPACE_NAME, +} from '../utils'; export { AppCategory, UiSettingsParams, diff --git a/src/core/server/index.ts b/src/core/server/index.ts index ced49743c98f..5e2655bfa164 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -355,7 +355,12 @@ export { } from './metrics'; export { AppCategory, WorkspaceAttribute } from '../types'; -export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE } from '../utils'; +export { + DEFAULT_APP_CATEGORIES, + PUBLIC_WORKSPACE_ID, + PUBLIC_WORKSPACE_NAME, + WORKSPACE_TYPE, +} from '../utils'; export { SavedObject, diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index ecc1b7e863c4..c05d2b06e041 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -3,6 +3,18 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { i18n } from '@osd/i18n'; + export const WORKSPACE_TYPE = 'workspace'; export const WORKSPACE_PATH_PREFIX = '/w'; + +/** + * public workspace has parity with global tenant, + * it includes saved objects with `public` as its workspace or without any workspce info + */ +export const PUBLIC_WORKSPACE_ID = 'public'; + +export const PUBLIC_WORKSPACE_NAME = i18n.translate('workspaces.public.workspace.default.name', { + defaultMessage: 'Global workspace', +}); diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index a83f85a8fce0..9b58b7ef6d0d 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -37,5 +37,10 @@ export { IContextProvider, } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; -export { WORKSPACE_PATH_PREFIX, WORKSPACE_TYPE } from './constants'; +export { + WORKSPACE_PATH_PREFIX, + WORKSPACE_TYPE, + PUBLIC_WORKSPACE_ID, + PUBLIC_WORKSPACE_NAME, +} from './constants'; export { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId, cleanWorkspaceId } from './workspace'; diff --git a/src/plugins/saved_objects_management/public/lib/fetch_export_by_type_and_search.ts b/src/plugins/saved_objects_management/public/lib/fetch_export_by_type_and_search.ts index e5f716347a76..40f72fac1c82 100644 --- a/src/plugins/saved_objects_management/public/lib/fetch_export_by_type_and_search.ts +++ b/src/plugins/saved_objects_management/public/lib/fetch_export_by_type_and_search.ts @@ -28,19 +28,24 @@ * under the License. */ -import { HttpStart } from 'src/core/public'; +import { HttpStart, SavedObjectsBaseOptions } from 'src/core/public'; +import { formatWorkspaceIdParams } from '../utils'; export async function fetchExportByTypeAndSearch( http: HttpStart, types: string[], search: string | undefined, - includeReferencesDeep: boolean = false + includeReferencesDeep: boolean = false, + workspaces: SavedObjectsBaseOptions['workspaces'] ): Promise { return http.post('/api/saved_objects/_export', { - body: JSON.stringify({ - type: types, - search, - includeReferencesDeep, - }), + body: JSON.stringify( + formatWorkspaceIdParams({ + workspaces, + type: types, + search, + includeReferencesDeep, + }) + ), }); } diff --git a/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts b/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts index 6eaaac7d35f2..374f2720b537 100644 --- a/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts +++ b/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts @@ -34,13 +34,14 @@ export interface SavedObjectCountOptions { typesToInclude: string[]; namespacesToInclude?: string[]; searchString?: string; + workspaces?: string[]; } export async function getSavedObjectCounts( http: HttpStart, options: SavedObjectCountOptions -): Promise> { - return await http.post>( +): Promise>> { + return await http.post>>( `/api/opensearch-dashboards/management/saved_objects/scroll/counts`, { body: JSON.stringify(options) } ); diff --git a/src/plugins/saved_objects_management/public/lib/parse_query.test.ts b/src/plugins/saved_objects_management/public/lib/parse_query.test.ts index a940cf3ebbca..731bb73a4d70 100644 --- a/src/plugins/saved_objects_management/public/lib/parse_query.test.ts +++ b/src/plugins/saved_objects_management/public/lib/parse_query.test.ts @@ -39,6 +39,8 @@ describe('getQueryText', () => { return [{ value: 'lala' }, { value: 'lolo' }]; } else if (field === 'namespaces') { return [{ value: 'default' }]; + } else if (field === 'workspaces') { + return [{ value: 'workspaces' }]; } return []; }, @@ -47,6 +49,7 @@ describe('getQueryText', () => { queryText: 'foo bar', visibleTypes: 'lala', visibleNamespaces: 'default', + visibleWorkspaces: 'workspaces', }); }); }); diff --git a/src/plugins/saved_objects_management/public/lib/parse_query.ts b/src/plugins/saved_objects_management/public/lib/parse_query.ts index 24c35d500aaa..3db3f7fcee1c 100644 --- a/src/plugins/saved_objects_management/public/lib/parse_query.ts +++ b/src/plugins/saved_objects_management/public/lib/parse_query.ts @@ -33,12 +33,15 @@ import { Query } from '@elastic/eui'; interface ParsedQuery { queryText?: string; visibleTypes?: string[]; + visibleNamespaces?: string[]; + visibleWorkspaces?: string[]; } export function parseQuery(query: Query): ParsedQuery { let queryText: string | undefined; let visibleTypes: string[] | undefined; let visibleNamespaces: string[] | undefined; + let visibleWorkspaces: string[] | undefined; if (query) { if (query.ast.getTermClauses().length) { @@ -53,11 +56,15 @@ export function parseQuery(query: Query): ParsedQuery { if (query.ast.getFieldClauses('namespaces')) { visibleNamespaces = query.ast.getFieldClauses('namespaces')[0].value as string[]; } + if (query.ast.getFieldClauses('workspaces')) { + visibleWorkspaces = query.ast.getFieldClauses('workspaces')[0].value as string[]; + } } return { queryText, visibleTypes, visibleNamespaces, + visibleWorkspaces, }; } diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index f074f9715c99..fdff7b9d913b 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -231,6 +231,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` "edit": false, "read": true, }, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 74ae23c34dcb..7c6f67e741ca 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -40,6 +40,7 @@ import { import React from 'react'; import { Query } from '@elastic/eui'; +import { waitFor } from '@testing-library/dom'; import { ShallowWrapper } from 'enzyme'; import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; import { @@ -62,6 +63,9 @@ import { } from './saved_objects_table'; import { Flyout, Relationships } from './components'; import { SavedObjectWithMetadata } from '../../types'; +import { WorkspaceObject } from 'opensearch-dashboards/public'; +import { PUBLIC_WORKSPACE_NAME, PUBLIC_WORKSPACE_ID } from '../../../../../core/public'; +import { TableProps } from './components/table'; const allowedTypes = ['index-pattern', 'visualization', 'dashboard', 'search']; @@ -135,6 +139,7 @@ describe('SavedObjectsTable', () => { edit: false, delete: false, }, + workspaces: {}, }; http.post.mockResolvedValue([]); @@ -367,7 +372,8 @@ describe('SavedObjectsTable', () => { http, allowedTypes, undefined, - true + true, + undefined ); expect(saveAsMock).toHaveBeenCalledWith(blob, 'export.ndjson'); expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ @@ -397,7 +403,39 @@ describe('SavedObjectsTable', () => { http, allowedTypes, 'test*', - true + true, + undefined + ); + expect(saveAsMock).toHaveBeenCalledWith(blob, 'export.ndjson'); + expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ + title: 'Your file is downloading in the background', + }); + }); + + it('should export all, accounting for the current workspace criteria', async () => { + const component = shallowRender(); + + component.instance().onQueryChange({ + query: Query.parse(`test workspaces:("${PUBLIC_WORKSPACE_NAME}")`), + }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + // Set up mocks + const blob = new Blob([JSON.stringify(allSavedObjects)], { type: 'application/ndjson' }); + fetchExportByTypeAndSearchMock.mockImplementation(() => blob); + + await component.instance().onExportAll(); + + expect(fetchExportByTypeAndSearchMock).toHaveBeenCalledWith( + http, + allowedTypes, + 'test*', + true, + [PUBLIC_WORKSPACE_ID] ); expect(saveAsMock).toHaveBeenCalledWith(blob, 'export.ndjson'); expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ @@ -576,4 +614,193 @@ describe('SavedObjectsTable', () => { expect(component.state('selectedSavedObjects').length).toBe(0); }); }); + + describe('workspace filter', () => { + it('workspace filter include all visible workspaces when not in any workspace', async () => { + const applications = applicationServiceMock.createStartContract(); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: false, + }, + workspaces: { + enabled: true, + }, + }; + + const workspaceList: WorkspaceObject[] = [ + { + id: 'workspace1', + name: 'foo', + }, + { + id: 'workspace2', + name: 'bar', + }, + ]; + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next(''); + workspaces.currentWorkspace$.next(null); + + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + const props = component.find('Table').props() as TableProps; + const filters = props.filters; + expect(filters.length).toBe(2); + expect(filters[0].field).toBe('type'); + expect(filters[1].field).toBe('workspaces'); + expect(filters[1].options.length).toBe(3); + expect(filters[1].options[0].value).toBe('foo'); + expect(filters[1].options[1].value).toBe('bar'); + expect(filters[1].options[2].value).toBe(PUBLIC_WORKSPACE_NAME); + }); + + it('workspace filter only include current workspaces when in a workspace', async () => { + const applications = applicationServiceMock.createStartContract(); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: false, + }, + workspaces: { + enabled: true, + }, + }; + + const workspaceList: WorkspaceObject[] = [ + { + id: 'workspace1', + name: 'foo', + }, + { + id: 'workspace2', + name: 'bar', + }, + ]; + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next('workspace1'); + workspaces.currentWorkspace$.next(workspaceList[0]); + + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + const props = component.find('Table').props() as TableProps; + const filters = props.filters; + const wsFilter = filters.filter((f) => f.field === 'workspaces'); + expect(wsFilter.length).toBe(1); + expect(wsFilter[0].options.length).toBe(1); + expect(wsFilter[0].options[0].value).toBe('foo'); + }); + + it('current workspace in find options when workspace on', async () => { + findObjectsMock.mockClear(); + const applications = applicationServiceMock.createStartContract(); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: false, + }, + workspaces: { + enabled: true, + }, + }; + + const workspaceList: WorkspaceObject[] = [ + { + id: 'workspace1', + name: 'foo', + }, + { + id: 'workspace2', + name: 'bar', + }, + ]; + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next('workspace1'); + workspaces.currentWorkspace$.next(workspaceList[0]); + + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + await waitFor(() => { + expect(findObjectsMock).toBeCalledWith( + http, + expect.objectContaining({ + workspaces: expect.arrayContaining(['workspace1']), + }) + ); + }); + }); + + it('all visible workspaces in find options when not in any workspace', async () => { + findObjectsMock.mockClear(); + const applications = applicationServiceMock.createStartContract(); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: false, + }, + workspaces: { + enabled: true, + }, + }; + + const workspaceList: WorkspaceObject[] = [ + { + id: 'workspace1', + name: 'foo', + }, + { + id: 'workspace2', + name: 'bar', + }, + ]; + workspaces.workspaceList$.next(workspaceList); + + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + await waitFor(() => { + expect(findObjectsMock).toBeCalledWith( + http, + expect.objectContaining({ + workspaces: expect.arrayContaining(['workspace1', 'workspace2', PUBLIC_WORKSPACE_ID]), + }) + ); + }); + }); + }); }); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 40ebe0525a0a..56e7950efeea 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -70,6 +70,7 @@ import { WorkspaceAttribute, } from 'src/core/public'; import { Subscription } from 'rxjs'; +import { PUBLIC_WORKSPACE_ID, PUBLIC_WORKSPACE_NAME } from '../../../../../core/public'; import { RedirectAppLinks } from '../../../../opensearch_dashboards_react/public'; import { IndexPatternsContract } from '../../../../data/public'; import { @@ -95,6 +96,7 @@ import { } from '../../services'; import { Header, Table, Flyout, Relationships } from './components'; import { DataPublicPluginStart } from '../../../../../plugins/data/public'; +import { formatWorkspaceIdParams } from '../../utils'; interface ExportAllOption { id: string; @@ -128,7 +130,7 @@ export interface SavedObjectsTableState { page: number; perPage: number; savedObjects: SavedObjectWithMetadata[]; - savedObjectCounts: Record; + savedObjectCounts: Record>; activeQuery: Query; selectedSavedObjects: SavedObjectWithMetadata[]; isShowingImportFlyout: boolean; @@ -143,6 +145,7 @@ export interface SavedObjectsTableState { exportAllSelectedOptions: Record; isIncludeReferencesDeepChecked: boolean; currentWorkspaceId?: string; + workspaceEnabled: boolean; availableWorkspaces?: WorkspaceAttribute[]; } export class SavedObjectsTable extends Component { @@ -153,15 +156,17 @@ export class SavedObjectsTable extends Component { + typeToCountMap[type] = 0; + return typeToCountMap; + }, {} as Record); + this.state = { totalCount: 0, page: 0, perPage: props.perPageConfig || 50, savedObjects: [], - savedObjectCounts: props.allowedTypes.reduce((typeToCountMap, type) => { - typeToCountMap[type] = 0; - return typeToCountMap; - }, {} as Record), + savedObjectCounts: { type: typeCounts } as Record>, activeQuery: Query.parse(''), selectedSavedObjects: [], isShowingImportFlyout: false, @@ -175,9 +180,50 @@ export class SavedObjectsTable extends Component ws.id).concat(PUBLIC_WORKSPACE_ID); + } else { + return [currentWorkspaceId]; + } + } + } + + private get workspaceNameIdLookup() { + const { availableWorkspaces } = this.state; + const workspaceNameIdMap = new Map(); + workspaceNameIdMap.set(PUBLIC_WORKSPACE_NAME, PUBLIC_WORKSPACE_ID); + // workspace name is unique across the system + availableWorkspaces?.forEach((workspace) => { + workspaceNameIdMap.set(workspace.name, workspace.id); + }); + return workspaceNameIdMap; + } + + /** + * convert workspace names to ids + * @param workspaceNames workspace name list + * @returns workspace id list + */ + private workspaceNamesToIds(workspaceNames?: string[]): string[] | undefined { + return workspaceNames + ?.map((wsName) => this.workspaceNameIdLookup.get(wsName) || '') + .filter((wsId) => !!wsId); + } + componentDidMount() { this._isMounted = true; this.subscribeWorkspace(); @@ -193,21 +239,27 @@ export class SavedObjectsTable extends Component { const { allowedTypes, namespaceRegistry } = this.props; - const { queryText, visibleTypes, visibleNamespaces } = parseQuery(this.state.activeQuery); + const { queryText, visibleTypes, visibleNamespaces, visibleWorkspaces } = parseQuery( + this.state.activeQuery + ); const filteredTypes = filterQuery(allowedTypes, visibleTypes); const availableNamespaces = namespaceRegistry.getAll()?.map((ns) => ns.id) || []; - const filteredCountOptions: SavedObjectCountOptions = { + const filteredCountOptions: SavedObjectCountOptions = formatWorkspaceIdParams({ typesToInclude: filteredTypes, searchString: queryText, - }; + workspaces: this.workspaceIdQuery, + }); if (availableNamespaces.length) { const filteredNamespaces = filterQuery(availableNamespaces, visibleNamespaces); filteredCountOptions.namespacesToInclude = filteredNamespaces; } + if (visibleWorkspaces?.length) { + filteredCountOptions.workspaces = this.workspaceNamesToIds(visibleWorkspaces); + } // These are the saved objects visible in the table. const filteredSavedObjectCounts = await getSavedObjectCounts( @@ -231,10 +283,11 @@ export class SavedObjectsTable extends Component { const { activeQuery: query, page, perPage } = this.state; const { notifications, http, allowedTypes, namespaceRegistry } = this.props; - const { queryText, visibleTypes, visibleNamespaces } = parseQuery(query); + const { queryText, visibleTypes, visibleNamespaces, visibleWorkspaces } = parseQuery(query); const filteredTypes = filterQuery(allowedTypes, visibleTypes); // "searchFields" is missing from the "findOptions" but gets injected via the API. // The API extracts the fields from each uiExports.savedObjectsManagement "defaultSearchField" attribute - const findOptions: SavedObjectsFindOptions = { + const findOptions: SavedObjectsFindOptions = formatWorkspaceIdParams({ search: queryText ? `${queryText}*` : undefined, perPage, page: page + 1, fields: ['id'], type: filteredTypes, - }; + workspaces: this.workspaceIdQuery, + }); const availableNamespaces = namespaceRegistry.getAll()?.map((ns) => ns.id) || []; if (availableNamespaces.length) { @@ -299,6 +353,10 @@ export class SavedObjectsTable extends Component 1) { findOptions.sortField = 'type'; } @@ -453,7 +511,7 @@ export class SavedObjectsTable extends Component { if (selected) { accum.push(id); @@ -461,13 +519,17 @@ export class SavedObjectsTable extends Component workspace.id === PUBLIC_WORKSPACE_ID) > -1; + const wsFilterOptions = availableWorkspaces + .filter((ws) => { + return this.workspaceIdQuery?.includes(ws.id); + }) + .map((ws) => { + return { + name: ws.name, + value: ws.name, + view: `${ws.name} (${wsCounts[ws.id] || 0})`, + }; + }); + + // add public workspace option only if we don't have it as real workspace + if (!currentWorkspaceId && !publicWorkspaceExists) { + wsFilterOptions.push({ + name: PUBLIC_WORKSPACE_NAME, + value: PUBLIC_WORKSPACE_NAME, + view: `${PUBLIC_WORKSPACE_NAME} (${wsCounts[PUBLIC_WORKSPACE_ID] || 0})`, + }); + } + + filters.push({ + type: 'field_value_selection', + field: 'workspaces', + name: i18n.translate('savedObjectsManagement.objectsTable.table.workspaceFilterName', { + defaultMessage: 'Workspaces', + }), + multiSelect: 'or', + options: wsFilterOptions, + }); + } + return ( {this.renderFlyout()} diff --git a/src/plugins/saved_objects_management/public/utils.test.ts b/src/plugins/saved_objects_management/public/utils.test.ts new file mode 100644 index 000000000000..3b714a169219 --- /dev/null +++ b/src/plugins/saved_objects_management/public/utils.test.ts @@ -0,0 +1,20 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { formatWorkspaceIdParams } from './utils'; + +describe('Utils', () => { + it('formatWorkspaceIdParams with workspace null/undefined', async () => { + let obj = formatWorkspaceIdParams({ foo: 'bar', workspaces: null }); + expect(obj).not.toHaveProperty('workspaces'); + obj = formatWorkspaceIdParams({ foo: 'bar', workspaces: undefined }); + expect(obj).not.toHaveProperty('workspaces'); + }); + + it('formatWorkspaceIdParams with workspace exists', async () => { + const obj = formatWorkspaceIdParams({ foo: 'bar', workspaces: ['foo'] }); + expect(obj).toEqual({ foo: 'bar', workspaces: ['foo'] }); + }); +}); diff --git a/src/plugins/saved_objects_management/public/utils.ts b/src/plugins/saved_objects_management/public/utils.ts new file mode 100644 index 000000000000..84727ab6a356 --- /dev/null +++ b/src/plugins/saved_objects_management/public/utils.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export function formatWorkspaceIdParams( + obj: T +): T | Omit { + const { workspaces, ...others } = obj; + if (workspaces) { + return obj; + } + return others; +} diff --git a/src/plugins/saved_objects_management/server/routes/find.ts b/src/plugins/saved_objects_management/server/routes/find.ts index dd49fc7575df..3f77457bc245 100644 --- a/src/plugins/saved_objects_management/server/routes/find.ts +++ b/src/plugins/saved_objects_management/server/routes/find.ts @@ -29,7 +29,7 @@ */ import { schema } from '@osd/config-schema'; -import { IRouter } from 'src/core/server'; +import { IRouter, SavedObjectsFindOptions } from 'src/core/server'; import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; import { getIndexPatternTitle } from '../../../data/common/index_patterns/utils'; import { injectMetaAttributes } from '../lib'; @@ -64,6 +64,9 @@ export const registerFindRoute = ( fields: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { defaultValue: [], }), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), }, }, @@ -90,11 +93,14 @@ export const registerFindRoute = ( return await client.get('data-source', id); }; - const findResponse = await client.find({ + const findOptions = { ...req.query, fields: undefined, searchFields: [...searchFields], - }); + workspaces: req.query.workspaces ? Array().concat(req.query.workspaces) : undefined, + } as SavedObjectsFindOptions; + + const findResponse = await client.find(findOptions); const savedObjects = await Promise.all( findResponse.saved_objects.map(async (obj) => { diff --git a/src/plugins/saved_objects_management/server/routes/scroll_count.ts b/src/plugins/saved_objects_management/server/routes/scroll_count.ts index 63233748a896..370c9c1a5d72 100644 --- a/src/plugins/saved_objects_management/server/routes/scroll_count.ts +++ b/src/plugins/saved_objects_management/server/routes/scroll_count.ts @@ -30,6 +30,7 @@ import { schema } from '@osd/config-schema'; import { IRouter, SavedObjectsFindOptions } from 'src/core/server'; +import { PUBLIC_WORKSPACE_ID } from '../../../../core/server'; import { findAll } from '../lib'; export const registerScrollForCountRoute = (router: IRouter) => { @@ -41,12 +42,13 @@ export const registerScrollForCountRoute = (router: IRouter) => { typesToInclude: schema.arrayOf(schema.string()), namespacesToInclude: schema.maybe(schema.arrayOf(schema.string())), searchString: schema.maybe(schema.string()), + workspaces: schema.maybe(schema.arrayOf(schema.string())), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { client } = context.core.savedObjects; - const counts = { + const counts: Record> = { type: {}, }; @@ -58,11 +60,18 @@ export const registerScrollForCountRoute = (router: IRouter) => { const requestHasNamespaces = Array.isArray(req.body.namespacesToInclude) && req.body.namespacesToInclude.length; + const requestHasWorkspaces = Array.isArray(req.body.workspaces) && req.body.workspaces.length; + if (requestHasNamespaces) { counts.namespaces = {}; findOptions.namespaces = req.body.namespacesToInclude; } + if (requestHasWorkspaces) { + counts.workspaces = {}; + findOptions.workspaces = req.body.workspaces; + } + if (req.body.searchString) { findOptions.search = `${req.body.searchString}*`; findOptions.searchFields = ['title']; @@ -82,6 +91,13 @@ export const registerScrollForCountRoute = (router: IRouter) => { counts.namespaces[ns]++; }); } + if (requestHasWorkspaces) { + const resultWorkspaces = result.workspaces || [PUBLIC_WORKSPACE_ID]; + resultWorkspaces.forEach((ws) => { + counts.workspaces[ws] = counts.workspaces[ws] || 0; + counts.workspaces[ws]++; + }); + } counts.type[type] = counts.type[type] || 0; counts.type[type]++; }); @@ -99,6 +115,13 @@ export const registerScrollForCountRoute = (router: IRouter) => { } } + const workspacesToInclude = req.body.workspaces || []; + for (const ws of workspacesToInclude) { + if (!counts.workspaces[ws]) { + counts.workspaces[ws] = 0; + } + } + return res.ok({ body: counts, }); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index db0921483eb4..0056e9ac784d 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -68,7 +68,7 @@ export class WorkspacePlugin implements Plugin { workspaces: ['foo'], }); }); + + it(`Should set workspacesSearchOperator to OR when search with public workspace`, async () => { + await wrapperClient.find({ + type: 'dashboard', + workspaces: [PUBLIC_WORKSPACE_ID], + }); + expect(mockedClient.find).toBeCalledWith({ + type: 'dashboard', + workspaces: [PUBLIC_WORKSPACE_ID], + workspacesSearchOperator: 'OR', + }); + }); + + it(`Should set workspace as pubic when workspace is not specified`, async () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + updateWorkspaceState(mockRequest, {}); + const mockedWrapperClient = wrapperInstance.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: mockRequest, + }); + await mockedWrapperClient.find({ + type: ['dashboard', 'visualization'], + }); + expect(mockedClient.find).toBeCalledWith({ + type: ['dashboard', 'visualization'], + workspaces: [PUBLIC_WORKSPACE_ID], + workspacesSearchOperator: 'OR', + }); + }); + + it(`Should remove public workspace when permission control is enabled`, async () => { + const consumer = new WorkspaceIdConsumerWrapper(true); + const client = consumer.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: workspaceEnabledMockRequest, + }); + await client.find({ + type: 'dashboard', + workspaces: ['bar', PUBLIC_WORKSPACE_ID], + }); + expect(mockedClient.find).toBeCalledWith({ + type: 'dashboard', + workspaces: ['bar'], + workspacesSearchOperator: 'OR', + }); + }); + + it(`Should not override workspacesSearchOperator when workspacesSearchOperator is specified`, async () => { + await wrapperClient.find({ + type: 'dashboard', + workspaces: [PUBLIC_WORKSPACE_ID], + workspacesSearchOperator: 'AND', + }); + expect(mockedClient.find).toBeCalledWith({ + type: 'dashboard', + workspaces: [PUBLIC_WORKSPACE_ID], + workspacesSearchOperator: 'AND', + }); + }); }); }); 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 74e8e99af71e..b620b5556b77 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 @@ -12,6 +12,8 @@ import { SavedObjectsCheckConflictsObject, OpenSearchDashboardsRequest, SavedObjectsFindOptions, + PUBLIC_WORKSPACE_ID, + WORKSPACE_TYPE, } from '../../../../core/server'; type WorkspaceOptions = Pick | undefined; @@ -37,6 +39,15 @@ export class WorkspaceIdConsumerWrapper { ...(finalWorkspaces.length ? { workspaces: finalWorkspaces } : {}), }; } + + private isWorkspaceType(type: SavedObjectsFindOptions['type']): boolean { + if (Array.isArray(type)) { + return type.every((item) => item === WORKSPACE_TYPE); + } + + return type === WORKSPACE_TYPE; + } + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { return { ...wrapperOptions.client, @@ -63,8 +74,31 @@ export class WorkspaceIdConsumerWrapper { this.formatWorkspaceIdParams(wrapperOptions.request, options) ), delete: wrapperOptions.client.delete, - find: (options: SavedObjectsFindOptions) => - wrapperOptions.client.find(this.formatWorkspaceIdParams(wrapperOptions.request, options)), + find: (options: SavedObjectsFindOptions) => { + const findOptions = this.formatWorkspaceIdParams(wrapperOptions.request, options); + if (this.isWorkspaceType(findOptions.type)) { + return wrapperOptions.client.find(findOptions); + } + + // if workspace is enabled, we always find by workspace + if (!findOptions.workspaces || findOptions.workspaces.length === 0) { + findOptions.workspaces = [PUBLIC_WORKSPACE_ID]; + } + + // `PUBLIC_WORKSPACE_ID` includes both saved objects without any workspace and with `PUBLIC_WORKSPACE_ID` workspace + const index = findOptions.workspaces + ? findOptions.workspaces.indexOf(PUBLIC_WORKSPACE_ID) + : -1; + if (!findOptions.workspacesSearchOperator && findOptions.workspaces && index !== -1) { + findOptions.workspacesSearchOperator = 'OR'; + // remove this deletion logic when public workspace becomes to real + if (this.isPermissionControlEnabled) { + // remove public workspace to make sure we can pass permission control validation, more details in `WorkspaceSavedObjectsClientWrapper` + findOptions.workspaces.splice(index, 1); + } + } + return wrapperOptions.client.find(findOptions); + }, bulkGet: wrapperOptions.client.bulkGet, get: wrapperOptions.client.get, update: wrapperOptions.client.update, @@ -75,5 +109,5 @@ export class WorkspaceIdConsumerWrapper { }; }; - constructor() {} + constructor(private isPermissionControlEnabled?: boolean) {} }