From 6f12320e807cacf1ec877041f8a310b8b3148fe1 Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Mon, 11 Mar 2024 10:23:09 +0800 Subject: [PATCH] Add workspace filter/column 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 --- src/core/public/index.ts | 2 +- src/core/server/index.ts | 7 +- src/core/utils/constants.ts | 6 + src/core/utils/index.ts | 7 +- .../public/lib/get_saved_object_counts.ts | 4 +- .../public/lib/parse_query.test.ts | 3 + .../public/lib/parse_query.ts | 7 + .../saved_objects_table.test.tsx.snap | 2 + .../objects_table/components/table.test.tsx | 45 ++++ .../objects_table/components/table.tsx | 25 ++- .../saved_objects_table.test.tsx | 194 ++++++++++++++++++ .../objects_table/saved_objects_table.tsx | 118 ++++++++++- .../server/routes/find.ts | 1 + .../server/routes/scroll_count.ts | 26 ++- .../workspace/opensearch_dashboards.json | 2 +- .../components/workspace_column/index.ts | 6 + .../workspace_column/workspace_colum.test.tsx | 59 ++++++ .../workspace_column/workspace_column.tsx | 49 +++++ src/plugins/workspace/public/plugin.test.ts | 19 +- src/plugins/workspace/public/plugin.ts | 13 +- 20 files changed, 567 insertions(+), 28 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_column/index.ts create mode 100644 src/plugins/workspace/public/components/workspace_column/workspace_colum.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_column/workspace_column.tsx diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 22b85f30605b..01e018d07df3 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -362,4 +362,4 @@ export { __osdBootstrap__ } from './osd_bootstrap'; export { WorkspacesStart, WorkspacesSetup, WorkspacesService } from './workspace'; -export { WORKSPACE_TYPE, cleanWorkspaceId } from '../utils'; +export { WORKSPACE_TYPE, cleanWorkspaceId, DEFAULT_WORKSPACE_ID } from '../utils'; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 1af8818ab0f0..84ee65dcb199 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -354,7 +354,12 @@ export { } from './metrics'; export { AppCategory, WorkspaceAttribute } from '../types'; -export { DEFAULT_APP_CATEGORIES, PUBLIC_WORKSPACE_ID, WORKSPACE_TYPE } from '../utils'; +export { + DEFAULT_APP_CATEGORIES, + PUBLIC_WORKSPACE_ID, + WORKSPACE_TYPE, + DEFAULT_WORKSPACE_ID, +} from '../utils'; export { SavedObject, diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index 2ea3b423a7cb..5c2a24f59b0d 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -8,3 +8,9 @@ export const WORKSPACE_TYPE = 'workspace'; export const WORKSPACE_PATH_PREFIX = '/w'; export const PUBLIC_WORKSPACE_ID = 'public'; + +/** + * 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 ec7971865566..e2f5fd90460a 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -38,4 +38,9 @@ export { } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; export { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId, cleanWorkspaceId } from './workspace'; -export { WORKSPACE_PATH_PREFIX, PUBLIC_WORKSPACE_ID, WORKSPACE_TYPE } from './constants'; +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 9039dae2be53..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 @@ -40,8 +40,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/__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 d44cecb5b412..ab8a16be5cbe 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 @@ -260,6 +260,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` "has": [MockFunction], } } + availableWorkspaces={Array []} basePath={ BasePath { "basePath": "", @@ -280,6 +281,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` "has": [MockFunction], } } + currentWorkspaceId="" filters={ Array [ Object { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx index 7e5bb318f4d0..0ce820f5a02b 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx @@ -37,6 +37,7 @@ import { actionServiceMock } from '../../../services/action_service.mock'; import { columnServiceMock } from '../../../services/column_service.mock'; import { SavedObjectsManagementAction } from '../../..'; import { Table, TableProps } from './table'; +import { WorkspaceAttribute } from 'opensearch-dashboards/public'; const defaultProps: TableProps = { basePath: httpServiceMock.createSetupContract().basePath, @@ -115,6 +116,50 @@ describe('Table', () => { expect(component).toMatchSnapshot(); }); + it('should render gotoApp link correctly for workspace', () => { + const item = { + id: 'dashboard-1', + type: 'dashboard', + workspaces: ['ws-1'], + attributes: {}, + references: [], + meta: { + title: `My-Dashboard-test`, + icon: 'indexPatternApp', + editUrl: '/management/opensearch-dashboards/objects/savedDashboards/dashboard-1', + inAppUrl: { + path: '/app/dashboards#/view/dashboard-1', + uiCapabilitiesPath: 'dashboard.show', + }, + }, + }; + const props = { + ...defaultProps, + availableWorkspaces: [{ id: 'ws-1', name: 'My workspace' } as WorkspaceAttribute], + items: [item], + }; + // not in a workspace + let component = shallowWithI18nProvider(); + + let table = component.find('EuiBasicTable'); + let columns = table.prop('columns') as any[]; + let content = columns[1].render('My-Dashboard-test', item); + expect(content.props.href).toEqual('http://localhost/w/ws-1/app/dashboards#/view/dashboard-1'); + + // in a workspace + const currentWorkspaceId = 'foo-ws'; + component = shallowWithI18nProvider( +
+ ); + + table = component.find('EuiBasicTable'); + columns = table.prop('columns') as any[]; + content = columns[1].render('My-Dashboard-test', item); + expect(content.props.href).toEqual( + `http://localhost/w/${currentWorkspaceId}/app/dashboards#/view/dashboard-1` + ); + }); + it('should handle query parse error', () => { const onQueryChangeMock = jest.fn(); const customizedProps = { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index 636933d449df..1a1df64e3752 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -28,7 +28,7 @@ * under the License. */ -import { IBasePath } from 'src/core/public'; +import { IBasePath, WorkspaceAttribute } from 'src/core/public'; import React, { PureComponent, Fragment } from 'react'; import moment from 'moment'; import { @@ -56,6 +56,7 @@ import { SavedObjectsManagementAction, SavedObjectsManagementColumnServiceStart, } from '../../../services'; +import { formatUrlWithWorkspaceId } from '../../../../../../core/public/utils'; export interface TableProps { basePath: IBasePath; @@ -83,6 +84,8 @@ export interface TableProps { onShowRelationships: (object: SavedObjectWithMetadata) => void; canGoInApp: (obj: SavedObjectWithMetadata) => boolean; dateFormat: string; + availableWorkspaces?: WorkspaceAttribute[]; + currentWorkspaceId?: string; } interface TableState { @@ -177,8 +180,12 @@ export class Table extends PureComponent { columnRegistry, namespaceRegistry, dateFormat, + availableWorkspaces, + currentWorkspaceId, } = this.props; + const visibleWsIds = availableWorkspaces?.map((ws) => ws.id) || []; + const pagination = { pageIndex, pageSize, @@ -231,9 +238,19 @@ export class Table extends PureComponent { if (!canGoInApp) { return {title || getDefaultTitle(object)}; } - return ( - {title || getDefaultTitle(object)} - ); + let inAppUrl = basePath.prepend(path); + if (object.workspaces?.length) { + if (currentWorkspaceId) { + inAppUrl = formatUrlWithWorkspaceId(path, currentWorkspaceId, basePath); + } else { + // first workspace user have permission + const [workspaceId] = object.workspaces.filter((wsId) => visibleWsIds.includes(wsId)); + if (workspaceId) { + inAppUrl = formatUrlWithWorkspaceId(path, workspaceId, basePath); + } + } + } + return {title || getDefaultTitle(object)}; }, } as EuiTableFieldDataColumnType>, { 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 a0a6329ac5e0..8f895419400d 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']; @@ -645,4 +649,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 28d041563a71..1b1de928be03 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 @@ -66,7 +66,10 @@ import { OverlayStart, NotificationsStart, ApplicationStart, + 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 { @@ -125,7 +128,7 @@ export interface SavedObjectsTableState { page: number; perPage: number; savedObjects: SavedObjectWithMetadata[]; - savedObjectCounts: Record; + savedObjectCounts: Record>; activeQuery: Query; selectedSavedObjects: SavedObjectWithMetadata[]; isShowingImportFlyout: boolean; @@ -139,25 +142,30 @@ export interface SavedObjectsTableState { exportAllOptions: ExportAllOption[]; exportAllSelectedOptions: Record; isIncludeReferencesDeepChecked: boolean; - currentWorkspaceId: string | null; + currentWorkspaceId?: string; + availableWorkspaces?: WorkspaceAttribute[]; workspaceEnabled: boolean; } export class SavedObjectsTable extends Component { private _isMounted = false; + private currentWorkspaceIdSubscription?: Subscription; + private workspacesSubscription?: Subscription; constructor(props: SavedObjectsTableProps) { super(props); + const typeCounts = props.allowedTypes.reduce((typeToCountMap, type) => { + 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, @@ -172,24 +180,37 @@ 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 { @@ -202,6 +223,8 @@ 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); @@ -229,6 +256,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( @@ -278,6 +310,19 @@ export class SavedObjectsTable extends Component { + const workspace = this.props.workspaces; + this.currentWorkspaceIdSubscription = workspace.currentWorkspaceId$.subscribe((workspaceId) => + this.setState({ + currentWorkspaceId: workspaceId, + }) + ); + + this.workspacesSubscription = workspace.workspaceList$.subscribe((workspaceList) => { + this.setState({ availableWorkspaces: workspaceList }); + }); + }; + fetchSavedObject = (type: string, id: string) => { this.setState({ isSearching: true }, () => this.debouncedFetchObject(type, id)); }; @@ -285,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 @@ -304,6 +349,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'; } @@ -846,6 +905,9 @@ 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()} @@ -933,6 +1029,8 @@ export class SavedObjectsTable extends Component diff --git a/src/plugins/saved_objects_management/server/routes/find.ts b/src/plugins/saved_objects_management/server/routes/find.ts index 61211532e96c..8d94c0d935c9 100644 --- a/src/plugins/saved_objects_management/server/routes/find.ts +++ b/src/plugins/saved_objects_management/server/routes/find.ts @@ -67,6 +67,7 @@ export const registerFindRoute = ( workspaces: schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) ), + 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 221d39392842..ec8af5482855 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) => { @@ -47,7 +48,7 @@ export const registerScrollForCountRoute = (router: IRouter) => { }, router.handleLegacyErrors(async (context, req, res) => { const { client } = context.core.savedObjects; - const counts = { + const counts: Record> = { type: {}, }; @@ -56,18 +57,23 @@ export const registerScrollForCountRoute = (router: IRouter) => { perPage: 1000, }; - const requestHasWorkspaces = Array.isArray(req.body.workspaces) && req.body.workspaces.length; - 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) { @@ -89,6 +95,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]++; }); @@ -106,6 +119,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/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index 4443b7e99834..7d94a7491a00 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -6,6 +6,6 @@ "requiredPlugins": [ "savedObjects" ], - "optionalPlugins": [], + "optionalPlugins": ["savedObjectsManagement"], "requiredBundles": ["opensearchDashboardsReact"] } diff --git a/src/plugins/workspace/public/components/workspace_column/index.ts b/src/plugins/workspace/public/components/workspace_column/index.ts new file mode 100644 index 000000000000..a9325eb49279 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_column/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { getWorkspaceColumn, WorkspaceColumn } from './workspace_column'; diff --git a/src/plugins/workspace/public/components/workspace_column/workspace_colum.test.tsx b/src/plugins/workspace/public/components/workspace_column/workspace_colum.test.tsx new file mode 100644 index 000000000000..655bd9e57f2c --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_column/workspace_colum.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { coreMock } from '../../../../../core/public/mocks'; +import { render } from '@testing-library/react'; +import { WorkspaceColumn } from './workspace_column'; + +describe('workspace column in saved objects page', () => { + const coreSetup = coreMock.createSetup(); + const workspaceList = [ + { + id: 'ws-1', + name: 'foo', + }, + { + id: 'ws-2', + name: 'bar', + }, + ]; + coreSetup.workspaces.workspaceList$.next(workspaceList); + + it('should show workspace name correctly', () => { + const workspaces = ['ws-1', 'ws-2']; + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+
+ foo | bar +
+
+ `); + }); + + it('show empty when no workspace', () => { + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); + }); + + it('show empty when workspace can not found', () => { + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
+
+
+ `); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx b/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx new file mode 100644 index 000000000000..3d964009ee86 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx @@ -0,0 +1,49 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiText } from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; +import { i18n } from '@osd/i18n'; +import { WorkspaceAttribute, CoreSetup } from '../../../../../core/public'; +import { SavedObjectsManagementColumn } from '../../../../saved_objects_management/public'; + +interface WorkspaceColumnProps { + coreSetup: CoreSetup; + workspaces?: string[]; +} + +export function WorkspaceColumn({ coreSetup, workspaces }: WorkspaceColumnProps) { + const workspaceList = useObservable(coreSetup.workspaces.workspaceList$); + + const wsLookUp = workspaceList?.reduce((map, ws) => { + return map.set(ws.id, ws.name); + }, new Map()); + + const workspaceNames = workspaces?.map((wsId) => wsLookUp?.get(wsId)).join(' | '); + + return {workspaceNames}; +} + +export function getWorkspaceColumn( + coreSetup: CoreSetup +): SavedObjectsManagementColumn { + return { + id: 'workspace_column', + euiColumn: { + align: 'left', + field: 'workspaces', + name: i18n.translate('savedObjectsManagement.objectsTable.table.columnWorkspacesName', { + defaultMessage: 'Workspaces', + }), + render: (workspaces: string[]) => { + return ; + }, + }, + loadData: () => { + return Promise.resolve(undefined); + }, + }; +} diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index f1f70b8e1d36..ef601da5f943 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -9,6 +9,7 @@ import { applicationServiceMock, chromeServiceMock, coreMock } from '../../../co import { WorkspacePlugin } from './plugin'; import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; import { Observable, Subscriber } from 'rxjs'; +import { savedObjectsManagementPluginMock } from '../../saved_objects_management/public/mocks'; describe('Workspace plugin', () => { beforeEach(() => { @@ -17,11 +18,15 @@ describe('Workspace plugin', () => { }); it('#setup', async () => { const setupMock = coreMock.createSetup(); + const savedObjectManagementSetupMock = savedObjectsManagementPluginMock.createSetupContract(); const workspacePlugin = new WorkspacePlugin(); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, { + savedObjectsManagement: savedObjectManagementSetupMock, + }); expect(setupMock.application.register).toBeCalledTimes(1); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); + expect(savedObjectManagementSetupMock.columns.register).toBeCalledTimes(1); }); it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', () => { @@ -62,7 +67,9 @@ describe('Workspace plugin', () => { }); const workspacePlugin = new WorkspacePlugin(); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, { + savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract(), + }); expect(setupMock.application.register).toBeCalledTimes(1); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); @@ -116,7 +123,9 @@ describe('Workspace plugin', () => { }); const workspacePlugin = new WorkspacePlugin(); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, { + savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract(), + }); currentAppIdSubscriber?.next(WORKSPACE_FATAL_ERROR_APP_ID); expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); windowSpy.mockRestore(); @@ -133,7 +142,9 @@ describe('Workspace plugin', () => { it('#setup register workspace dropdown menu when setup', async () => { const setupMock = coreMock.createSetup(); const workspacePlugin = new WorkspacePlugin(); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, { + savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract(), + }); expect(setupMock.chrome.registerCollapsibleNavHeader).toBeCalledTimes(1); }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index d7f1ba6192ad..3363af857c37 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,6 +4,7 @@ */ import type { Subscription } from 'rxjs'; +import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; import { featureMatchesConfig } from './utils'; import { AppMountParameters, @@ -19,10 +20,15 @@ import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { renderWorkspaceMenu } from './render_workspace_menu'; import { Services } from './types'; import { WorkspaceClient } from './workspace_client'; +import { getWorkspaceColumn } from './components/workspace_column'; import { NavLinkWrapper } from '../../../core/public/chrome/nav_links/nav_link'; type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; +interface WorkspacePluginSetupDeps { + savedObjectsManagement?: SavedObjectsManagementPluginSetup; +} + export class WorkspacePlugin implements Plugin<{}, {}, {}> { private coreStart?: CoreStart; private currentWorkspaceIdSubscription?: Subscription; @@ -88,7 +94,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { } } - public async setup(core: CoreSetup) { + public async setup(core: CoreSetup, { savedObjectsManagement }: WorkspacePluginSetupDeps) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); /** @@ -161,6 +167,11 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { return renderWorkspaceMenu(this.coreStart); }); + /** + * register workspace column into saved objects table + */ + savedObjectsManagement?.columns.register(getWorkspaceColumn(core)); + return {}; }