Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Workspace] feat: Add workspace navigation for default route #7785

Merged
2 changes: 2 additions & 0 deletions changelogs/fragments/7785.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- [Workspace] Add workspace navigation for default route ([#7785](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7785))
2 changes: 2 additions & 0 deletions src/plugins/workspace/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const WORKSPACE_CREATE_APP_ID = 'workspace_create';
export const WORKSPACE_LIST_APP_ID = 'workspace_list';
export const WORKSPACE_DETAIL_APP_ID = 'workspace_detail';
export const WORKSPACE_INITIAL_APP_ID = 'workspace_initial';
export const WORKSPACE_NAVIGATION_APP_ID = 'workspace_navigation';

/**
* Since every workspace always have overview and update page, these features will be selected by default
* and can't be changed in the workspace form feature selector
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,14 @@ export const WorkspaceInitial = () => {
const isDarkTheme = uiSettings.get('theme:darkMode');
const backGroundUrl = isDarkTheme ? BackgroundDarkSVG : BackgroundLightSVG;

const noAdminToolTip = i18n.translate('workspace.initial.card.createWorkspace.toolTip', {
defaultMessage:
'Contact your administrator to create a workspace or to be added to an existing one.',
});
const noAdminToolTip = (
<>
{i18n.translate('workspace.initial.card.createWorkspace.toolTip', {
defaultMessage:
'Contact your administrator to create a workspace or to be added to an existing one.',
})}
</>
);
yubonluo marked this conversation as resolved.
Show resolved Hide resolved

const createButton = (
<EuiSmallButton
Expand All @@ -69,7 +73,7 @@ export const WorkspaceInitial = () => {
defaultMessage: 'Create a workspace',
})}
description={
<EuiToolTip content={isDashboardAdmin || noAdminToolTip}>
<EuiToolTip content={isDashboardAdmin ? null : noAdminToolTip}>
<>
{i18n.translate('workspace.initial.card.createWorkspace.description', {
defaultMessage: 'Organize projects by use case in a collaborative workspace.',
Expand Down
19 changes: 16 additions & 3 deletions src/plugins/workspace/public/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('Workspace plugin', () => {
savedObjectsManagement: savedObjectManagementSetupMock,
management: managementPluginMock.createSetupContract(),
});
expect(setupMock.application.register).toBeCalledTimes(5);
expect(setupMock.application.register).toBeCalledTimes(6);
yubonluo marked this conversation as resolved.
Show resolved Hide resolved
expect(WorkspaceClientMock).toBeCalledTimes(1);
expect(savedObjectManagementSetupMock.columns.register).toBeCalledTimes(1);
});
Expand All @@ -53,7 +53,7 @@ describe('Workspace plugin', () => {
workspacePlugin.start(coreStart, mockDependencies);
coreStart.workspaces.currentWorkspaceId$.next('foo');
expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo');
expect(setupMock.application.register).toBeCalledTimes(5);
expect(setupMock.application.register).toBeCalledTimes(6);
expect(WorkspaceClientMock).toBeCalledTimes(1);
expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0);
});
Expand Down Expand Up @@ -90,7 +90,7 @@ describe('Workspace plugin', () => {
await workspacePlugin.setup(setupMock, {
management: managementPluginMock.createSetupContract(),
});
expect(setupMock.application.register).toBeCalledTimes(5);
expect(setupMock.application.register).toBeCalledTimes(6);
expect(WorkspaceClientMock).toBeCalledTimes(1);
expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId');
expect(setupMock.getStartServices).toBeCalledTimes(2);
Expand Down Expand Up @@ -215,6 +215,19 @@ describe('Workspace plugin', () => {
);
});

it('#setup should register workspace navigation with a visible application', async () => {
const setupMock = coreMock.createSetup();
const workspacePlugin = new WorkspacePlugin();
await workspacePlugin.setup(setupMock, {});

expect(setupMock.application.register).toHaveBeenCalledWith(
expect.objectContaining({
id: 'workspace_navigation',
navLinkStatus: AppNavLinkStatus.hidden,
})
);
});

it('#start add workspace detail page to breadcrumbs when start', async () => {
const startMock = coreMock.createStart();
const workspaceObject = {
Expand Down
28 changes: 28 additions & 0 deletions src/plugins/workspace/public/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
WORKSPACE_LIST_APP_ID,
WORKSPACE_USE_CASES,
WORKSPACE_INITIAL_APP_ID,
WORKSPACE_NAVIGATION_APP_ID,
} from '../common/constants';
import { getWorkspaceIdFromUrl } from '../../../core/public/utils';
import { Services, WorkspaceUseCase } from './types';
Expand All @@ -45,6 +46,7 @@
enrichBreadcrumbsWithWorkspace,
filterWorkspaceConfigurableApps,
getFirstUseCaseOfFeatureConfigs,
getUseCaseUrl,
isAppAccessibleInWorkspace,
isNavGroupInFeatureConfigs,
} from './utils';
Expand Down Expand Up @@ -374,6 +376,32 @@
workspaceAvailability: WorkspaceAvailability.outsideWorkspace,
});

const registeredUseCases$ = this.registeredUseCases$;
// register workspace navigation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice to have a more context about this change

core.application.register({
id: WORKSPACE_NAVIGATION_APP_ID,
title: '',
chromeless: true,
navLinkStatus: AppNavLinkStatus.hidden,
async mount() {
const [coreStart] = await core.getStartServices();
const { application, http, workspaces } = coreStart;
const workspace = workspaces.currentWorkspace$.getValue();

Check warning on line 389 in src/plugins/workspace/public/plugin.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/workspace/public/plugin.ts#L387-L389

Added lines #L387 - L389 were not covered by tests
if (workspace) {
const availableUseCases = registeredUseCases$.getValue();
const currentUseCase = availableUseCases.find(

Check warning on line 392 in src/plugins/workspace/public/plugin.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/workspace/public/plugin.ts#L391-L392

Added lines #L391 - L392 were not covered by tests
(useCase) => useCase.id === getFirstUseCaseOfFeatureConfigs(workspace?.features ?? [])
);
const useCaseUrl = getUseCaseUrl(currentUseCase, workspace, application, http);
application.navigateToUrl(useCaseUrl);

Check warning on line 396 in src/plugins/workspace/public/plugin.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/workspace/public/plugin.ts#L395-L396

Added lines #L395 - L396 were not covered by tests
} else {
application.navigateToApp('home');

Check warning on line 398 in src/plugins/workspace/public/plugin.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/workspace/public/plugin.ts#L398

Added line #L398 was not covered by tests
}
return () => {};

Check warning on line 400 in src/plugins/workspace/public/plugin.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/workspace/public/plugin.ts#L400

Added line #L400 was not covered by tests
},
workspaceAvailability: WorkspaceAvailability.insideWorkspace,
});

// workspace list
core.application.register({
id: WORKSPACE_LIST_APP_ID,
Expand Down
105 changes: 95 additions & 10 deletions src/plugins/workspace/server/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { OnPostAuthHandler, OnPreRoutingHandler } from 'src/core/server';
import { coreMock, httpServerMock } from '../../../core/server/mocks';
import {
OnPostAuthHandler,
OnPreRoutingHandler,
SavedObjectsClientContract,
} from 'src/core/server';
import { coreMock, httpServerMock, savedObjectsClientMock } from '../../../core/server/mocks';
import { WorkspacePlugin } from './plugin';
import { getWorkspaceState, updateWorkspaceState } from '../../../core/server/utils';
import * as utilsExports from './utils';
import { SavedObjectsPermissionControl } from './permission_control/client';

describe('Workspace server plugin', () => {
afterEach(() => {
jest.clearAllMocks();
});

it('#setup', async () => {
let value;
const capabilities = {} as any;
Expand Down Expand Up @@ -192,7 +200,18 @@ describe('Workspace server plugin', () => {
const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock);
const response = httpServerMock.createResponseFactory();

it('with / request path', async () => {
it('without / request path', async () => {
const request = httpServerMock.createOpenSearchDashboardsRequest({
path: '/foo',
});
await workspacePlugin.setup(setupMock);
const toolKitMock = httpServerMock.createToolkit();

await registerOnPostAuthFn(request, response, toolKitMock);
expect(toolKitMock.next).toBeCalledTimes(1);
});

it('with / request path and no workspaces', async () => {
const request = httpServerMock.createOpenSearchDashboardsRequest({
path: '/',
});
Expand All @@ -205,31 +224,97 @@ describe('Workspace server plugin', () => {
});
});

it('without / request path', async () => {
it('with / request path and one workspace', async () => {
const request = httpServerMock.createOpenSearchDashboardsRequest({
path: '/foo',
path: '/',
});
const workspaceSetup = await workspacePlugin.setup(setupMock);
const client = workspaceSetup.client;
jest.spyOn(client, 'list').mockResolvedValue({
success: true,
result: {
total: 1,
per_page: 100,
page: 1,
workspaces: [{ id: 'workspace-1', name: 'workspace-1' }],
},
});
await workspacePlugin.setup(setupMock);
const toolKitMock = httpServerMock.createToolkit();

await registerOnPostAuthFn(request, response, toolKitMock);
expect(toolKitMock.next).toBeCalledTimes(1);
expect(response.redirected).toBeCalledWith({
headers: {
location: '/mock-server-basepath/w/workspace-1/app/workspace_navigation',
},
});
});

it('with more than one workspace', async () => {
it('with / request path and more than one workspaces', async () => {
const request = httpServerMock.createOpenSearchDashboardsRequest({
path: '/',
});
const workspaceSetup = await workspacePlugin.setup(setupMock);
const client = workspaceSetup.client;
jest.spyOn(client, 'list').mockResolvedValue({
success: true,
result: { total: 1 },
result: {
total: 2,
per_page: 100,
page: 1,
workspaces: [
{ id: 'workspace-1', name: 'workspace-1' },
{ id: 'workspace-2', name: 'workspace-2' },
],
},
});
const toolKitMock = httpServerMock.createToolkit();

await registerOnPostAuthFn(request, response, toolKitMock);
expect(toolKitMock.next).toBeCalledTimes(1);
expect(response.redirected).toBeCalledWith({
headers: {
location: '/mock-server-basepath/app/home',
},
});
});

it('with / request path and default workspace', async () => {
const request = httpServerMock.createOpenSearchDashboardsRequest({
path: '/',
});
const savedObjectsClient: jest.Mocked<SavedObjectsClientContract> = savedObjectsClientMock.create();
const mockUiSettings = {
get: jest.fn().mockResolvedValue('default'),
};
yubonluo marked this conversation as resolved.
Show resolved Hide resolved
setupMock.getStartServices.mockResolvedValue([
{
uiSettings: { asScopedToClient: () => mockUiSettings },
savedObjects: { getScopedClient: () => savedObjectsClient },
},
{},
{},
]);
const workspaceSetup = await workspacePlugin.setup(setupMock);
const client = workspaceSetup.client;
jest.spyOn(client, 'list').mockResolvedValue({
success: true,
result: {
total: 2,
per_page: 100,
page: 1,
workspaces: [
{ id: 'default', name: 'default-workspace' },
{ id: 'workspace-2', name: 'workspace-2' },
],
},
});
const toolKitMock = httpServerMock.createToolkit();

await registerOnPostAuthFn(request, response, toolKitMock);
expect(response.redirected).toBeCalledWith({
headers: {
location: '/mock-server-basepath/w/default/app/workspace_navigation',
},
});
});
});

Expand Down
39 changes: 36 additions & 3 deletions src/plugins/workspace/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
WORKSPACE_UI_SETTINGS_CLIENT_WRAPPER_ID,
PRIORITY_FOR_WORKSPACE_UI_SETTINGS_WRAPPER,
WORKSPACE_INITIAL_APP_ID,
WORKSPACE_NAVIGATION_APP_ID,
} from '../common/constants';
import { IWorkspaceClientImpl, WorkspacePluginSetup, WorkspacePluginStart } from './types';
import { WorkspaceClient } from './workspace_client';
Expand Down Expand Up @@ -115,12 +116,44 @@ export class WorkspacePlugin implements Plugin<WorkspacePluginSetup, WorkspacePl
if (path === '/') {
const workspaceListResponse = await this.client?.list(
{ request, logger: this.logger },
{ page: 1, perPage: 1 }
{ page: 1, perPage: 100 }
);
const basePath = core.http.basePath.serverBasePath;

if (workspaceListResponse?.success && workspaceListResponse.result.total > 0) {
return toolkit.next();
const workspaceList = workspaceListResponse.result.workspaces;
// If user only has one workspace, go to overview page of that workspace
if (workspaceList.length === 1) {
return response.redirected({
headers: {
location: `${basePath}/w/${workspaceList[0].id}/app/${WORKSPACE_NAVIGATION_APP_ID}`,
SuZhou-Joe marked this conversation as resolved.
Show resolved Hide resolved
},
});
}
const [coreStart] = await core.getStartServices();
const uiSettings = coreStart.uiSettings.asScopedToClient(
coreStart.savedObjects.getScopedClient(request)
);
// Temporarily use defaultWorkspace as a placeholder
const defaultWorkspaceId = await uiSettings.get('defaultWorkspace');
const defaultWorkspace = workspaceList.find(
(workspace) => workspace.id === defaultWorkspaceId
);
// If user has a default workspace configured, go to overview page of that workspace
// If user has more than one workspaces, go to homepage
if (defaultWorkspace) {
return response.redirected({
headers: {
location: `${basePath}/w/${defaultWorkspace.id}/app/${WORKSPACE_NAVIGATION_APP_ID}`,
},
});
} else {
return response.redirected({
headers: { location: `${basePath}/app/home` },
yubonluo marked this conversation as resolved.
Show resolved Hide resolved
});
}
}
const basePath = core.http.basePath.serverBasePath;
// If user has no workspaces, go to initial page
return response.redirected({
headers: { location: `${basePath}/app/${WORKSPACE_INITIAL_APP_ID}` },
});
Expand Down
Loading