Skip to content

Commit

Permalink
Hide create spaces button when limit is reached (#159102)
Browse files Browse the repository at this point in the history
Resolves #159028 
Resolves #159047

## Summary

Hide create spaces button when limit is reached. 

## Screenshot


![Spaces-Management-Disabled](https://github.com/elastic/kibana/assets/190132/587dc47b-0377-4f72-8faa-7e6652cdab96)

## Testing

1. Set the maximum number of allowed spaces to 1

```yml
xpack.spaces.maxSpaces: 1
```

2. Verify that the create spaces button is hidden and that a callout is
displayed

---------

Co-authored-by: kibanamachine <[email protected]>
  • Loading branch information
thomheymann and kibanamachine authored Jun 7, 2023
1 parent ebd7ba8 commit 0f6eca7
Show file tree
Hide file tree
Showing 16 changed files with 228 additions and 72 deletions.
3 changes: 3 additions & 0 deletions config/serverless.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,6 @@ telemetry.allowChangingOptInStatus: false
server.securityResponseHeaders.strictTransportSecurity: max-age=31536000; includeSubDomains
# Disable embedding for serverless MVP
server.securityResponseHeaders.disableEmbedding: true

# Enforce single "default" space
xpack.spaces.maxSpaces: 1
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ export default function ({ getService }: PluginFunctionalProviderContext) {
'xpack.security.showInsecureClusterWarning (boolean)',
'xpack.security.showNavLinks (boolean)',
'xpack.security.ui (any)',
'xpack.spaces.maxSpaces (number)',
'xpack.securitySolution.enableExperimental (array)',
'xpack.securitySolution.prebuiltRulesPackageVersion (string)',
'xpack.snapshot_restore.slm_ui.enabled (boolean)',
Expand Down
10 changes: 10 additions & 0 deletions x-pack/plugins/spaces/public/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/

export interface ConfigType {
maxSpaces: number;
}
6 changes: 4 additions & 2 deletions x-pack/plugins/spaces/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
* 2.0.
*/

import type { PluginInitializerContext } from '@kbn/core/public';

import { SpacesPlugin } from './plugin';

export { getSpaceColor, getSpaceImageUrl, getSpaceInitials } from './space_avatar';
Expand Down Expand Up @@ -42,6 +44,6 @@ export type { SpacesContextProps, SpacesReactContextValue } from './spaces_conte

export type { LazyComponentFn, SpacesApiUi, SpacesApiUiComponent } from './ui_api';

export const plugin = () => {
return new SpacesPlugin();
export const plugin = (initializerContext: PluginInitializerContext) => {
return new SpacesPlugin(initializerContext);
};
33 changes: 18 additions & 15 deletions x-pack/plugins/spaces/public/management/management_service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,27 +10,32 @@ import { coreMock } from '@kbn/core/public/mocks';
import type { ManagementSection } from '@kbn/management-plugin/public';
import { managementPluginMock } from '@kbn/management-plugin/public/mocks';

import type { ConfigType } from '../config';
import type { PluginsStart } from '../plugin';
import { spacesManagerMock } from '../spaces_manager/mocks';
import { ManagementService } from './management_service';

describe('ManagementService', () => {
const config: ConfigType = {
maxSpaces: 1000,
};

describe('#setup', () => {
it('registers the spaces management page under the kibana section', () => {
const mockKibanaSection = {
registerApp: jest.fn(),
} as unknown as ManagementSection;
const managementMockSetup = managementPluginMock.createSetupContract();
managementMockSetup.sections.section.kibana = mockKibanaSection;
const deps = {

const service = new ManagementService();
service.setup({
management: managementMockSetup,
getStartServices: coreMock.createSetup()
.getStartServices as CoreSetup<PluginsStart>['getStartServices'],
spacesManager: spacesManagerMock.create(),
};

const service = new ManagementService();
service.setup(deps);
config,
});

expect(mockKibanaSection.registerApp).toHaveBeenCalledTimes(1);
expect(mockKibanaSection.registerApp).toHaveBeenCalledWith({
Expand All @@ -42,15 +47,14 @@ describe('ManagementService', () => {
});

it('will not crash if the kibana section is missing', () => {
const deps = {
const service = new ManagementService();
service.setup({
management: managementPluginMock.createSetupContract(),
getStartServices: coreMock.createSetup()
.getStartServices as CoreSetup<PluginsStart>['getStartServices'],
spacesManager: spacesManagerMock.create(),
};

const service = new ManagementService();
service.setup(deps);
config,
});
});
});

Expand All @@ -63,15 +67,14 @@ describe('ManagementService', () => {
const managementMockSetup = managementPluginMock.createSetupContract();
managementMockSetup.sections.section.kibana = mockKibanaSection;

const deps = {
const service = new ManagementService();
service.setup({
management: managementMockSetup,
getStartServices: coreMock.createSetup()
.getStartServices as CoreSetup<PluginsStart>['getStartServices'],
spacesManager: spacesManagerMock.create(),
};

const service = new ManagementService();
service.setup(deps);
config,
});

service.stop();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import type { StartServicesAccessor } from '@kbn/core/public';
import type { ManagementApp, ManagementSetup } from '@kbn/management-plugin/public';

import type { ConfigType } from '../config';
import type { PluginsStart } from '../plugin';
import type { SpacesManager } from '../spaces_manager';
import { spacesManagementApp } from './spaces_management_app';
Expand All @@ -16,14 +17,15 @@ interface SetupDeps {
management: ManagementSetup;
getStartServices: StartServicesAccessor<PluginsStart>;
spacesManager: SpacesManager;
config: ConfigType;
}

export class ManagementService {
private registeredSpacesManagementApp?: ManagementApp;

public setup({ getStartServices, management, spacesManager }: SetupDeps) {
public setup({ getStartServices, management, spacesManager, config }: SetupDeps) {
this.registeredSpacesManagementApp = management.sections.section.kibana.registerApp(
spacesManagementApp.create({ getStartServices, spacesManager })
spacesManagementApp.create({ getStartServices, spacesManager, config })
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from '@kbn/core/public/mocks';
import { KibanaFeature } from '@kbn/features-plugin/public';
import { featuresPluginMock } from '@kbn/features-plugin/public/mocks';
import { mountWithIntl } from '@kbn/test-jest-helpers';
import { mountWithIntl, shallowWithIntl } from '@kbn/test-jest-helpers';

import { SpaceAvatarInternal } from '../../space_avatar/space_avatar_internal';
import type { SpacesManager } from '../../spaces_manager';
Expand Down Expand Up @@ -66,6 +66,34 @@ describe('SpacesGridPage', () => {
const httpStart = httpServiceMock.createStartContract();
httpStart.get.mockResolvedValue([]);

const wrapper = shallowWithIntl(
<SpacesGridPage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
notifications={notificationServiceMock.createStartContract()}
getUrlForApp={getUrlForApp}
history={history}
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
maxSpaces={1000}
/>
);

// allow spacesManager to load spaces and lazy-load SpaceAvatar
await act(async () => {});
wrapper.update();

expect(wrapper.find('EuiInMemoryTable').prop('items')).toBe(spaces);
});

it('renders a create spaces button', async () => {
const httpStart = httpServiceMock.createStartContract();
httpStart.get.mockResolvedValue([]);

const wrapper = mountWithIntl(
<SpacesGridPage
spacesManager={spacesManager as unknown as SpacesManager}
Expand All @@ -79,14 +107,45 @@ describe('SpacesGridPage', () => {
catalogue: {},
spaces: { manage: true },
}}
maxSpaces={1000}
/>
);

// allow spacesManager to load spaces and lazy-load SpaceAvatar
await act(async () => {});
wrapper.update();

expect(wrapper.find(SpaceAvatarInternal)).toHaveLength(spaces.length);
expect(wrapper.find({ 'data-test-subj': 'createSpace' }).length).toBeTruthy();
expect(wrapper.find('EuiCallOut')).toHaveLength(0);
});

it('does not render a create spaces button when limit has been reached', async () => {
const httpStart = httpServiceMock.createStartContract();
httpStart.get.mockResolvedValue([]);

const wrapper = mountWithIntl(
<SpacesGridPage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
notifications={notificationServiceMock.createStartContract()}
getUrlForApp={getUrlForApp}
history={history}
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
maxSpaces={1}
/>
);

// allow spacesManager to load spaces and lazy-load SpaceAvatar
await act(async () => {});
wrapper.update();

expect(wrapper.find({ 'data-test-subj': 'createSpace' }).length).toBeFalsy();
expect(wrapper.find('EuiCallOut')).toHaveLength(1);
});

it('notifies when spaces fail to load', async () => {
Expand All @@ -98,7 +157,7 @@ describe('SpacesGridPage', () => {

const notifications = notificationServiceMock.createStartContract();

const wrapper = mountWithIntl(
const wrapper = shallowWithIntl(
<SpacesGridPage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
Expand All @@ -111,6 +170,7 @@ describe('SpacesGridPage', () => {
catalogue: {},
spaces: { manage: true },
}}
maxSpaces={1000}
/>
);

Expand All @@ -132,7 +192,7 @@ describe('SpacesGridPage', () => {

const notifications = notificationServiceMock.createStartContract();

const wrapper = mountWithIntl(
const wrapper = shallowWithIntl(
<SpacesGridPage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={() => Promise.reject(error)}
Expand All @@ -145,6 +205,7 @@ describe('SpacesGridPage', () => {
catalogue: {},
spaces: { manage: true },
}}
maxSpaces={1000}
/>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import {
EuiButton,
EuiButtonIcon,
EuiCallOut,
EuiInMemoryTable,
EuiLink,
EuiLoadingSpinner,
Expand Down Expand Up @@ -50,6 +51,7 @@ interface Props {
capabilities: Capabilities;
history: ScopedHistory;
getUrlForApp: ApplicationStart['getUrlForApp'];
maxSpaces: number;
}

interface State {
Expand Down Expand Up @@ -90,7 +92,11 @@ export class SpacesGridPage extends Component<Props, State> {
/>
}
description={getSpacesFeatureDescription()}
rightSideItems={[this.getPrimaryActionButton()]}
rightSideItems={
!this.state.loading && this.canCreateSpaces()
? [this.getPrimaryActionButton()]
: undefined
}
/>
<EuiSpacer size="l" />
{this.getPageContent()}
Expand All @@ -109,40 +115,52 @@ export class SpacesGridPage extends Component<Props, State> {
}

return (
<EuiInMemoryTable
itemId={'id'}
items={this.state.spaces}
tableCaption={i18n.translate('xpack.spaces.management.spacesGridPage.tableCaption', {
defaultMessage: 'Kibana spaces',
})}
rowHeader="name"
columns={this.getColumnConfig()}
hasActions
pagination={true}
sorting={true}
search={{
box: {
placeholder: i18n.translate(
'xpack.spaces.management.spacesGridPage.searchPlaceholder',
{
defaultMessage: 'Search',
}
),
},
}}
loading={this.state.loading}
message={
this.state.loading ? (
<FormattedMessage
id="xpack.spaces.management.spacesGridPage.loadingTitle"
defaultMessage="loading…"
/>
) : undefined
}
/>
<>
{!this.state.loading && !this.canCreateSpaces() ? (
<>
<EuiCallOut title="You have reached the maximum number of allowed spaces." />
<EuiSpacer />
</>
) : undefined}
<EuiInMemoryTable
itemId={'id'}
items={this.state.spaces}
tableCaption={i18n.translate('xpack.spaces.management.spacesGridPage.tableCaption', {
defaultMessage: 'Kibana spaces',
})}
rowHeader="name"
columns={this.getColumnConfig()}
hasActions
pagination={true}
sorting={true}
search={{
box: {
placeholder: i18n.translate(
'xpack.spaces.management.spacesGridPage.searchPlaceholder',
{
defaultMessage: 'Search',
}
),
},
}}
loading={this.state.loading}
message={
this.state.loading ? (
<FormattedMessage
id="xpack.spaces.management.spacesGridPage.loadingTitle"
defaultMessage="loading…"
/>
) : undefined
}
/>
</>
);
}

private canCreateSpaces() {
return this.props.maxSpaces > this.state.spaces.length;
}

public getPrimaryActionButton() {
return (
<EuiButton
Expand Down
Loading

0 comments on commit 0f6eca7

Please sign in to comment.