diff --git a/src/core/public/index.ts b/src/core/public/index.ts index cc51c7215964..d475608ce086 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -359,4 +359,4 @@ export { __osdBootstrap__ } from './osd_bootstrap'; export { WorkspacesStart, WorkspacesSetup, WorkspacesService, WorkspaceObject } from './workspace'; -export { debounce } from './utils'; +export { debounce, DEFAULT_WORKSPACE_ID } from './utils'; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 7cdc22c5d943..720d2aa02350 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -355,7 +355,7 @@ export { } from './metrics'; export { AppCategory, WorkspaceAttribute } from '../types'; -export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE } from '../utils'; +export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE, DEFAULT_WORKSPACE_ID } from '../utils'; export { SavedObject, diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index ecc1b7e863c4..04566c20ba1d 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -6,3 +6,9 @@ export const WORKSPACE_TYPE = 'workspace'; export const WORKSPACE_PATH_PREFIX = '/w'; + +/** + * deafult workspace is a virtual workspace, + * saved objects without any workspaces are consider belongs to default workspace + */ +export const DEFAULT_WORKSPACE_ID = 'default'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index a83f85a8fce0..e2f5fd90460a 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 { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId, cleanWorkspaceId } from './workspace'; +export { + WORKSPACE_PATH_PREFIX, + PUBLIC_WORKSPACE_ID, + WORKSPACE_TYPE, + DEFAULT_WORKSPACE_ID, +} from './constants'; 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..9fb7b378cf1a 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 @@ -39,8 +39,8 @@ export interface SavedObjectCountOptions { 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/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 74ae23c34dcb..693c4bad9eb1 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 { DEFAULT_WORKSPACE_ID } from '../../../../../core/public'; +import { TableProps } from './components/table'; const allowedTypes = ['index-pattern', 'visualization', 'dashboard', 'search']; @@ -576,4 +580,194 @@ describe('SavedObjectsTable', () => { expect(component.state('selectedSavedObjects').length).toBe(0); }); }); + + describe('workspace filter', () => { + it('show workspace filter when workspace turn on and 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(DEFAULT_WORKSPACE_ID); + }); + + it('show workspace filter when workspace turn on and enter 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('workspace exists 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('workspace exists in find options when workspace on and 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', 'default']), + workspacesSearchOperator: expect.stringMatching('OR'), + }) + ); + }); + }); + }); }); 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 127ed08423e3..c1d2a1a7ac48 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 @@ -69,6 +69,7 @@ import { WorkspaceAttribute, } from 'src/core/public'; import { Subscription } from 'rxjs'; +import { DEFAULT_WORKSPACE_ID } from '../../../../../core/public'; import { RedirectAppLinks } from '../../../../opensearch_dashboards_react/public'; import { IndexPatternsContract } from '../../../../data/public'; import { @@ -127,7 +128,7 @@ export interface SavedObjectsTableState { page: number; perPage: number; savedObjects: SavedObjectWithMetadata[]; - savedObjectCounts: Record; + savedObjectCounts: Record>; activeQuery: Query; selectedSavedObjects: SavedObjectWithMetadata[]; isShowingImportFlyout: boolean; @@ -142,6 +143,7 @@ export interface SavedObjectsTableState { exportAllSelectedOptions: Record; isIncludeReferencesDeepChecked: boolean; currentWorkspaceId?: string; + workspaceEnabled: boolean; availableWorkspaces?: WorkspaceAttribute[]; } export class SavedObjectsTable extends Component { @@ -152,15 +154,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, @@ -174,9 +178,48 @@ export class SavedObjectsTable extends Component ws.id).concat(DEFAULT_WORKSPACE_ID); + } else { + return [currentWorkspaceId]; + } + } + } + + private get wsNameIdLookup() { + const { availableWorkspaces } = this.state; + const workspaceNameIdMap = new Map(); + workspaceNameIdMap.set(DEFAULT_WORKSPACE_ID, DEFAULT_WORKSPACE_ID); + // Assumption: workspace name is unique across the system + availableWorkspaces?.reduce((map, ws) => { + return map.set(ws.name, ws.id); + }, workspaceNameIdMap); + return workspaceNameIdMap; + } + + private formatWorkspaceIdParams( + obj: T + ): T | Omit { + const { workspaces, ...others } = obj; + if (workspaces) { + return obj; + } + return others; + } + componentDidMount() { this._isMounted = true; this.subscribeWorkspace(); @@ -192,7 +235,9 @@ 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); @@ -207,6 +252,11 @@ export class SavedObjectsTable extends Component this.wsNameIdLookup?.get(wsName) || '') + .filter((wsId) => !!wsId); + } // These are the saved objects visible in the table. const filteredSavedObjectCounts = await getSavedObjectCounts( @@ -280,7 +330,7 @@ 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 @@ -298,6 +348,20 @@ export class SavedObjectsTable extends Component this.wsNameIdLookup?.get(wsName) || '' + ); + findOptions.workspaces = workspaceIds; + } + + if (findOptions.workspaces) { + if (findOptions.workspaces.indexOf(DEFAULT_WORKSPACE_ID) !== -1) { + // search both saved objects with workspace and without workspace + findOptions.workspacesSearchOperator = 'OR'; + } + } + if (findOptions.type.length > 1) { findOptions.sortField = 'type'; } @@ -830,6 +894,7 @@ export class SavedObjectsTable extends Component { + return this.workspaceIdQuery?.includes(ws.id); + }) + .map((ws) => { + return { + name: ws.name, + value: ws.name, + view: `${ws.name} (${wsCounts[ws.id] || 0})`, + }; + }); + + if (!currentWorkspaceId) { + wsFilterOptions.push({ + name: DEFAULT_WORKSPACE_ID, + value: DEFAULT_WORKSPACE_ID, + view: `Default (${wsCounts[DEFAULT_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/server/routes/find.ts b/src/plugins/saved_objects_management/server/routes/find.ts index dd49fc7575df..99cf50c93222 100644 --- a/src/plugins/saved_objects_management/server/routes/find.ts +++ b/src/plugins/saved_objects_management/server/routes/find.ts @@ -64,6 +64,7 @@ export const registerFindRoute = ( fields: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { defaultValue: [], }), + workspacesSearchOperator: schema.maybe(schema.string()), }), }, }, 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..1915b9be48e5 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 { DEFAULT_WORKSPACE_ID } from '../../../../core/server'; import { findAll } from '../lib'; export const registerScrollForCountRoute = (router: IRouter) => { @@ -46,7 +47,7 @@ export const registerScrollForCountRoute = (router: IRouter) => { }, router.handleLegacyErrors(async (context, req, res) => { const { client } = context.core.savedObjects; - const counts = { + const counts: Record> = { type: {}, }; @@ -58,11 +59,22 @@ 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 (findOptions.workspaces.indexOf(DEFAULT_WORKSPACE_ID) !== -1) { + // search both saved objects with workspace and without workspace + findOptions.workspacesSearchOperator = 'OR'; + } + } + if (req.body.searchString) { findOptions.search = `${req.body.searchString}*`; findOptions.searchFields = ['title']; @@ -82,6 +94,13 @@ export const registerScrollForCountRoute = (router: IRouter) => { counts.namespaces[ns]++; }); } + if (requestHasWorkspaces) { + const resultWorkspaces = result.workspaces || [DEFAULT_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 +118,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, });