[7.x] Spaces - Client NP Migration, Phase 1 (#40856) (#53113)

* Spaces - Client NP Migration, Phase 1 (#40856)

* shimming NP for spaces client-side plugin

* refresh active space in nav control when updated

* fix advanced settings screen

* allow npStart from unauthed routes

* use NP for deriving space management url

* remove security's usage of SpacesManager

* remove usages of ui/capabilities

* fix tests

* implement NP plugin interface

* remove hack in favor of convention in migration guide

* shim feature catalogue registration

* streamline nav control, and handle async loading more gracefully

* adding opaqueId

* fixes from merge

* fix merge from master

* fixing merge from master

* move _active_space route to NP

* moving to the NP feature catalogue registry

* moving setup to setup phase

* optimizing active space retrieval

* reverting test isolation change

* Apply suggestions from code review

Co-Authored-By: Aleh Zasypkin <aleh.zasypkin@gmail.com>

* removing unnecessary PluginInitializerContext

* updating advanced settings subtitle

* using NP anonymousPaths service

* additional nav_control_popover cleanup

* additional cleanup

* testing out onActiveSpaceChange$ property

* make the linter happy

* make the type checker happy

* fixing types

* fix merge from master

* spaces LP init should run on all pages, not just the kibana app

* address nits

* fix infra/logs, and the spaces disabled scenario

* fix typescript errors

* revert changes to infra plugin

* reintroducing activeSpace injected var for legacy plugins

* fixing react deprecation warning and unhandled promise rejection

* restore activeSpace default var

* spaces does not need to check its own enabled status

* fix from merge

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

* fix backport merge
This commit is contained in:
Larry Gregory 2019-12-16 12:13:05 -05:00 committed by GitHub
parent 4e3095be0e
commit 9c2fa892ca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
57 changed files with 791 additions and 666 deletions

View file

@ -11,6 +11,7 @@ import * as rt from 'io-ts';
import { useKibanaInjectedVar } from './use_kibana_injected_var';
export const useKibanaSpaceId = (): string => {
// NOTICE: use of `activeSpace` is deprecated and will not be made available in the New Platform.
const activeSpace = useKibanaInjectedVar('activeSpace');
return pipe(

View file

@ -15,7 +15,6 @@ import 'plugins/security/services/shield_user';
import 'plugins/security/services/shield_role';
import 'plugins/security/services/shield_indices';
import { xpackInfo } from 'plugins/xpack_main/services/xpack_info';
import { SpacesManager } from '../../../../../spaces/public/lib';
import { ROLES_PATH, CLONE_ROLES_PATH, EDIT_ROLES_PATH } from '../management_urls';
import { getEditRoleBreadcrumbs, getCreateRoleBreadcrumbs } from '../breadcrumbs';
@ -79,7 +78,7 @@ const routeDefinition = action => ({
},
spaces(spacesEnabled) {
if (spacesEnabled) {
return new SpacesManager().getSpaces();
return kfetch({ method: 'get', pathname: '/api/spaces/space' });
}
return [];
},

View file

@ -48,7 +48,6 @@ export const spaces = (kibana: Record<string, any>) =>
},
uiExports: {
chromeNavControls: ['plugins/spaces/views/nav_control'],
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
managementSections: ['plugins/spaces/views/management'],
apps: [
@ -60,7 +59,7 @@ export const spaces = (kibana: Record<string, any>) =>
hidden: true,
},
],
hacks: [],
hacks: ['plugins/spaces/legacy'],
mappings,
migrations: {
space: {
@ -73,19 +72,21 @@ export const spaces = (kibana: Record<string, any>) =>
hidden: true,
},
},
home: ['plugins/spaces/register_feature'],
injectDefaultVars(server: any) {
home: [],
injectDefaultVars(server: Server) {
return {
spaces: [],
activeSpace: null,
serverBasePath: server.config().get('server.basePath'),
activeSpace: null,
};
},
async replaceInjectedVars(
vars: Record<string, any>,
request: Legacy.Request,
server: Record<string, any>
server: Server
) {
// NOTICE: use of `activeSpace` is deprecated and will not be made available in the New Platform.
// Known usages:
// - x-pack/legacy/plugins/infra/public/utils/use_kibana_space_id.ts
const spacesPlugin = server.newPlatform.setup.plugins.spaces as SpacesPluginSetup;
if (!spacesPlugin) {
throw new Error('New Platform XPack Spaces plugin is not available.');

View file

@ -1,24 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
jest.mock('ui/capabilities', () => ({
capabilities: {
get: jest.fn().mockReturnValue({
navLinks: {},
management: {},
catalogue: {},
spaces: {
manage: true,
},
}),
},
}));
import { capabilities, UICapabilities } from 'ui/capabilities';
export function setMockCapabilities(mockCapabilities: UICapabilities) {
((capabilities.get as unknown) as jest.Mock).mockReturnValue(mockCapabilities);
}

View file

@ -4,37 +4,40 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { setMockCapabilities } from '../__mocks__/ui_capabilities';
import React from 'react';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { ManageSpacesButton } from './manage_spaces_button';
describe('ManageSpacesButton', () => {
it('renders as expected', () => {
setMockCapabilities({
navLinks: {},
management: {},
catalogue: {},
spaces: {
manage: true,
},
});
const component = <ManageSpacesButton />;
const component = (
<ManageSpacesButton
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: {
manage: true,
},
}}
/>
);
expect(shallowWithIntl(component)).toMatchSnapshot();
});
it(`doesn't render if user profile forbids managing spaces`, () => {
setMockCapabilities({
navLinks: {},
management: {},
catalogue: {},
spaces: {
manage: false,
},
});
const component = <ManageSpacesButton />;
const component = (
<ManageSpacesButton
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: {
manage: false,
},
}}
/>
);
expect(shallowWithIntl(component)).toMatchSnapshot();
});
});

View file

@ -7,8 +7,8 @@
import { EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { Component, CSSProperties } from 'react';
import { capabilities } from 'ui/capabilities';
import { MANAGE_SPACES_URL } from '../lib/constants';
import { Capabilities } from 'src/core/public';
import { getManageSpacesUrl } from '../lib/constants';
interface Props {
isDisabled?: boolean;
@ -16,11 +16,12 @@ interface Props {
size?: 's' | 'm';
style?: CSSProperties;
onClick?: () => void;
capabilities: Capabilities;
}
export class ManageSpacesButton extends Component<Props, {}> {
public render() {
if (!capabilities.get().spaces.manage) {
if (!this.props.capabilities.spaces.manage) {
return null;
}
@ -44,6 +45,6 @@ export class ManageSpacesButton extends Component<Props, {}> {
if (this.props.onClick) {
this.props.onClick();
}
window.location.replace(MANAGE_SPACES_URL);
window.location.replace(getManageSpacesUrl());
};
}

View file

@ -6,13 +6,12 @@
import { i18n } from '@kbn/i18n';
import {
FeatureCatalogueEntry,
FeatureCatalogueCategory,
FeatureCatalogueRegistryProvider,
// @ts-ignore
} from 'ui/registry/feature_catalogue';
} from '../../../../../src/plugins/home/public';
import { getSpacesFeatureDescription } from './lib/constants';
FeatureCatalogueRegistryProvider.register(() => {
export const createSpacesFeatureCatalogueEntry = (): FeatureCatalogueEntry => {
return {
id: 'spaces',
title: i18n.translate('xpack.spaces.spacesTitle', {
@ -24,4 +23,4 @@ FeatureCatalogueRegistryProvider.register(() => {
showOnHomePage: true,
category: FeatureCatalogueCategory.ADMIN,
};
});
};

View file

@ -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;
* you may not use this file except in compliance with the Elastic License.
*/
import { SpacesPlugin } from './plugin';
export const plugin = () => {
return new SpacesPlugin();
};

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { npSetup, npStart } from 'ui/new_platform';
import { plugin } from '.';
import { SpacesPlugin, PluginsSetup } from './plugin';
const spacesPlugin: SpacesPlugin = plugin();
const plugins: PluginsSetup = {
home: npSetup.plugins.home,
};
export const setup = spacesPlugin.setup(npSetup.core, plugins);
export const start = spacesPlugin.start(npStart.core);

View file

@ -5,7 +5,7 @@
*/
import { i18n } from '@kbn/i18n';
import chrome from 'ui/chrome';
import { npSetup } from 'ui/new_platform';
let spacesFeatureDescription: string;
@ -20,4 +20,5 @@ export const getSpacesFeatureDescription = () => {
return spacesFeatureDescription;
};
export const MANAGE_SPACES_URL = chrome.addBasePath(`/app/kibana#/management/spaces/list`);
export const getManageSpacesUrl = () =>
npSetup.core.http.basePath.prepend(`/app/kibana#/management/spaces/list`);

View file

@ -11,7 +11,6 @@ import {
SavedObjectsManagementRecord,
} from '../../../../../../../src/legacy/core_plugins/management/public';
import { CopySavedObjectsToSpaceFlyout } from '../../views/management/components/copy_saved_objects_to_space';
import { Space } from '../../../common/model/space';
import { SpacesManager } from '../spaces_manager';
export class CopyToSpaceSavedObjectsManagementAction extends SavedObjectsManagementAction {
@ -31,7 +30,7 @@ export class CopyToSpaceSavedObjectsManagementAction extends SavedObjectsManagem
},
};
constructor(private readonly spacesManager: SpacesManager, private readonly activeSpace: Space) {
constructor(private readonly spacesManager: SpacesManager) {
super();
}
@ -44,7 +43,6 @@ export class CopyToSpaceSavedObjectsManagementAction extends SavedObjectsManagem
onClose={this.onClose}
savedObject={this.record}
spacesManager={this.spacesManager}
activeSpace={this.activeSpace}
toastNotifications={toastNotifications}
/>
);

View file

@ -4,18 +4,21 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { of, Observable } from 'rxjs';
import { Space } from '../../common/model/space';
function createSpacesManagerMock() {
return {
onActiveSpaceChange$: (of(undefined) as unknown) as Observable<Space>,
getSpaces: jest.fn().mockResolvedValue([]),
getSpace: jest.fn().mockResolvedValue(undefined),
getActiveSpace: jest.fn().mockResolvedValue(undefined),
createSpace: jest.fn().mockResolvedValue(undefined),
updateSpace: jest.fn().mockResolvedValue(undefined),
deleteSpace: jest.fn().mockResolvedValue(undefined),
copySavedObjects: jest.fn().mockResolvedValue(undefined),
resolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(undefined),
redirectToSpaceSelector: jest.fn().mockResolvedValue(undefined),
requestRefresh: jest.fn(),
on: jest.fn(),
};
}

View file

@ -3,8 +3,9 @@
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { EventEmitter } from 'events';
import { kfetch } from 'ui/kfetch';
import { Observable, BehaviorSubject } from 'rxjs';
import { skipWhile } from 'rxjs/operators';
import { HttpSetup } from 'src/core/public';
import { SavedObjectsManagementRecord } from '../../../../../../src/legacy/core_plugins/management/public';
import { Space } from '../../common/model/space';
import { GetSpacePurpose } from '../../common/model/types';
@ -12,43 +13,57 @@ import { CopySavedObjectsToSpaceResponse } from './copy_saved_objects_to_space/t
import { ENTER_SPACE_PATH } from '../../common/constants';
import { addSpaceIdToPath } from '../../../../../plugins/spaces/common';
export class SpacesManager extends EventEmitter {
constructor(private readonly serverBasePath: string) {
super();
export class SpacesManager {
private activeSpace$: BehaviorSubject<Space | null> = new BehaviorSubject<Space | null>(null);
public readonly onActiveSpaceChange$: Observable<Space>;
constructor(private readonly serverBasePath: string, private readonly http: HttpSetup) {
this.onActiveSpaceChange$ = this.activeSpace$
.asObservable()
.pipe(skipWhile((v: Space | null) => v == null)) as Observable<Space>;
this.refreshActiveSpace();
}
public async getSpaces(purpose?: GetSpacePurpose): Promise<Space[]> {
return await kfetch({ pathname: '/api/spaces/space', query: { purpose } });
return await this.http.get('/api/spaces/space', { query: { purpose } });
}
public async getSpace(id: string): Promise<Space> {
return await kfetch({ pathname: `/api/spaces/space/${encodeURIComponent(id)}` });
return await this.http.get(`/api/spaces/space/${encodeURIComponent(id)}`);
}
public getActiveSpace({ forceRefresh = false } = {}) {
if (!forceRefresh && this.activeSpace$.value) {
return Promise.resolve(this.activeSpace$.value);
}
return this.http.get('/internal/spaces/_active_space') as Promise<Space>;
}
public async createSpace(space: Space) {
return await kfetch({
pathname: `/api/spaces/space`,
method: 'POST',
await this.http.post(`/api/spaces/space`, {
body: JSON.stringify(space),
});
}
public async updateSpace(space: Space) {
return await kfetch({
pathname: `/api/spaces/space/${encodeURIComponent(space.id)}`,
await this.http.put(`/api/spaces/space/${encodeURIComponent(space.id)}`, {
query: {
overwrite: true,
},
method: 'PUT',
body: JSON.stringify(space),
});
const activeSpaceId = (await this.getActiveSpace()).id;
if (space.id === activeSpaceId) {
this.refreshActiveSpace();
}
}
public async deleteSpace(space: Space) {
return await kfetch({
pathname: `/api/spaces/space/${encodeURIComponent(space.id)}`,
method: 'DELETE',
});
await this.http.delete(`/api/spaces/space/${encodeURIComponent(space.id)}`);
}
public async copySavedObjects(
@ -57,9 +72,7 @@ export class SpacesManager extends EventEmitter {
includeReferences: boolean,
overwrite: boolean
): Promise<CopySavedObjectsToSpaceResponse> {
return await kfetch({
pathname: `/api/spaces/_copy_saved_objects`,
method: 'POST',
return this.http.post('/api/spaces/_copy_saved_objects', {
body: JSON.stringify({
objects,
spaces,
@ -74,9 +87,7 @@ export class SpacesManager extends EventEmitter {
retries: unknown,
includeReferences: boolean
): Promise<CopySavedObjectsToSpaceResponse> {
return await kfetch({
pathname: `/api/spaces/_resolve_copy_saved_objects_errors`,
method: 'POST',
return this.http.post(`/api/spaces/_resolve_copy_saved_objects_errors`, {
body: JSON.stringify({
objects,
includeReferences,
@ -93,7 +104,8 @@ export class SpacesManager extends EventEmitter {
window.location.href = `${this.serverBasePath}/spaces/space_selector`;
}
public async requestRefresh() {
this.emit('request_refresh');
private async refreshActiveSpace() {
const activeSpace = await this.getActiveSpace({ forceRefresh: true });
this.activeSpace$.next(activeSpace);
}
}

View file

@ -0,0 +1,40 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { CoreSetup, CoreStart, Plugin } from 'src/core/public';
import { HomePublicPluginSetup } from 'src/plugins/home/public';
import { SpacesManager } from './lib';
import { initSpacesNavControl } from './views/nav_control';
import { createSpacesFeatureCatalogueEntry } from './create_feature_catalogue_entry';
export interface SpacesPluginStart {
spacesManager: SpacesManager | null;
}
export interface PluginsSetup {
home?: HomePublicPluginSetup;
}
export class SpacesPlugin implements Plugin<void, SpacesPluginStart, PluginsSetup> {
private spacesManager: SpacesManager | null = null;
public async start(core: CoreStart) {
const serverBasePath = core.injectedMetadata.getInjectedVar('serverBasePath') as string;
this.spacesManager = new SpacesManager(serverBasePath, core.http);
initSpacesNavControl(this.spacesManager, core);
return {
spacesManager: this.spacesManager,
};
}
public async setup(core: CoreSetup, plugins: PluginsSetup) {
if (plugins.home) {
plugins.home.featureCatalogue.register(createSpacesFeatureCatalogueEntry());
}
}
}

View file

@ -65,28 +65,6 @@ exports[`ConfirmDeleteModal renders as expected 1`] = `
value=""
/>
</EuiFormRow>
<EuiSpacer />
<EuiCallOut
color="warning"
>
<EuiText>
<FormattedMessage
defaultMessage="You are about to delete your current space {name}. You will be redirected to choose a different space if you continue."
id="xpack.spaces.management.confirmDeleteModal.redirectAfterDeletingCurrentSpaceWarningMessage"
values={
Object {
"name": <span>
(
<strong>
My Space
</strong>
)
</span>,
}
}
/>
</EuiText>
</EuiCallOut>
</EuiText>
</EuiModalBody>
<EuiModalFooter>

View file

@ -1,28 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AdvancedSettingsSubtitle renders as expected 1`] = `
<Fragment>
<EuiSpacer
size="m"
/>
<EuiCallOut
color="primary"
iconType="spacesApp"
title={
<p>
<FormattedMessage
defaultMessage="The settings on this page apply to the {spaceName} space, unless otherwise specified."
id="xpack.spaces.management.advancedSettingsSubtitle.applyingSettingsOnPageToSpaceDescription"
values={
Object {
"spaceName": <strong>
My Space
</strong>,
}
}
/>
</p>
}
/>
</Fragment>
`;

View file

@ -4,16 +4,28 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { AdvancedSettingsSubtitle } from './advanced_settings_subtitle';
import { EuiCallOut } from '@elastic/eui';
describe('AdvancedSettingsSubtitle', () => {
it('renders as expected', () => {
it('renders as expected', async () => {
const space = {
id: 'my-space',
name: 'My Space',
disabledFeatures: [],
};
expect(shallowWithIntl(<AdvancedSettingsSubtitle space={space} />)).toMatchSnapshot();
const wrapper = mountWithIntl(
<AdvancedSettingsSubtitle getActiveSpace={() => Promise.resolve(space)} />
);
// Wait for active space to resolve before requesting the component to update
await Promise.resolve();
await Promise.resolve();
wrapper.update();
expect(wrapper.find(EuiCallOut)).toHaveLength(1);
});
});

View file

@ -6,30 +6,40 @@
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React, { Fragment } from 'react';
import React, { Fragment, useState, useEffect } from 'react';
import { Space } from '../../../../../common/model/space';
interface Props {
space: Space;
getActiveSpace: () => Promise<Space>;
}
export const AdvancedSettingsSubtitle = (props: Props) => (
<Fragment>
<EuiSpacer size={'m'} />
<EuiCallOut
color="primary"
iconType="spacesApp"
title={
<p>
<FormattedMessage
id="xpack.spaces.management.advancedSettingsSubtitle.applyingSettingsOnPageToSpaceDescription"
defaultMessage="The settings on this page apply to the {spaceName} space, unless otherwise specified."
values={{
spaceName: <strong>{props.space.name}</strong>,
}}
/>
</p>
}
/>
</Fragment>
);
export const AdvancedSettingsSubtitle = (props: Props) => {
const [activeSpace, setActiveSpace] = useState<Space | null>(null);
useEffect(() => {
props.getActiveSpace().then(space => setActiveSpace(space));
}, [props]);
if (!activeSpace) return null;
return (
<Fragment>
<EuiSpacer size={'m'} />
<EuiCallOut
color="primary"
iconType="spacesApp"
title={
<p>
<FormattedMessage
id="xpack.spaces.management.advancedSettingsSubtitle.applyingSettingsOnPageToSpaceDescription"
defaultMessage="The settings on this page apply to the {spaceName} space, unless otherwise specified."
values={{
spaceName: <strong>{activeSpace.name}</strong>,
}}
/>
</p>
}
/>
</Fragment>
);
};

View file

@ -1,45 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`AdvancedSettingsTitle renders as expected 1`] = `
<EuiFlexGroup
alignItems="center"
gutterSize="s"
responsive={false}
>
<EuiFlexItem
grow={false}
>
<SpaceAvatar
announceSpaceName={true}
space={
Object {
"disabledFeatures": Array [],
"id": "my-space",
"name": "My Space",
}
}
/>
</EuiFlexItem>
<EuiFlexItem
style={
Object {
"marginLeft": "10px",
}
}
>
<EuiTitle
size="m"
>
<h1
data-test-subj="managementSettingsTitle"
>
<FormattedMessage
defaultMessage="Settings"
id="xpack.spaces.management.advancedSettingsTitle.settingsTitle"
values={Object {}}
/>
</h1>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
`;

View file

@ -4,16 +4,25 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { AdvancedSettingsTitle } from './advanced_settings_title';
import { SpaceAvatar } from '../../../../components';
describe('AdvancedSettingsTitle', () => {
it('renders as expected', () => {
it('renders without crashing', async () => {
const space = {
id: 'my-space',
name: 'My Space',
disabledFeatures: [],
};
expect(shallowWithIntl(<AdvancedSettingsTitle space={space} />)).toMatchSnapshot();
const wrapper = mountWithIntl(
<AdvancedSettingsTitle getActiveSpace={() => Promise.resolve(space)} />
);
await Promise.resolve();
await Promise.resolve();
wrapper.update();
expect(wrapper.find(SpaceAvatar)).toHaveLength(1);
});
});

View file

@ -6,28 +6,38 @@
import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import React from 'react';
import React, { useState, useEffect } from 'react';
import { Space } from '../../../../../common/model/space';
import { SpaceAvatar } from '../../../../components';
interface Props {
space: Space;
getActiveSpace: () => Promise<Space>;
}
export const AdvancedSettingsTitle = (props: Props) => (
<EuiFlexGroup gutterSize="s" responsive={false} alignItems={'center'}>
<EuiFlexItem grow={false}>
<SpaceAvatar space={props.space} />
</EuiFlexItem>
<EuiFlexItem style={{ marginLeft: '10px' }}>
<EuiTitle size="m">
<h1 data-test-subj="managementSettingsTitle">
<FormattedMessage
id="xpack.spaces.management.advancedSettingsTitle.settingsTitle"
defaultMessage="Settings"
/>
</h1>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
);
export const AdvancedSettingsTitle = (props: Props) => {
const [activeSpace, setActiveSpace] = useState<Space | null>(null);
useEffect(() => {
props.getActiveSpace().then(space => setActiveSpace(space));
}, [props]);
if (!activeSpace) return null;
return (
<EuiFlexGroup gutterSize="s" responsive={false} alignItems={'center'}>
<EuiFlexItem grow={false}>
<SpaceAvatar space={activeSpace} />
</EuiFlexItem>
<EuiFlexItem style={{ marginLeft: '10px' }}>
<EuiTitle size="m">
<h1 data-test-subj="managementSettingsTitle">
<FormattedMessage
id="xpack.spaces.management.advancedSettingsTitle.settingsTitle"
defaultMessage="Settings"
/>
</h1>
</EuiTitle>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -6,7 +6,6 @@
import React from 'react';
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { SpacesNavState } from '../../nav_control';
import { ConfirmDeleteModal } from './confirm_delete_modal';
import { spacesManagerMock } from '../../../lib/mocks';
import { SpacesManager } from '../../../lib';
@ -20,11 +19,7 @@ describe('ConfirmDeleteModal', () => {
};
const spacesManager = spacesManagerMock.create();
const spacesNavState: SpacesNavState = {
getActiveSpace: () => space,
refreshSpacesList: jest.fn(),
};
spacesManager.getActiveSpace.mockResolvedValue(space);
const onCancel = jest.fn();
const onConfirm = jest.fn();
@ -34,7 +29,6 @@ describe('ConfirmDeleteModal', () => {
<ConfirmDeleteModal.WrappedComponent
space={space}
spacesManager={(spacesManager as unknown) as SpacesManager}
spacesNavState={spacesNavState}
onCancel={onCancel}
onConfirm={onConfirm}
intl={null as any}
@ -51,11 +45,7 @@ describe('ConfirmDeleteModal', () => {
};
const spacesManager = spacesManagerMock.create();
const spacesNavState: SpacesNavState = {
getActiveSpace: () => space,
refreshSpacesList: jest.fn(),
};
spacesManager.getActiveSpace.mockResolvedValue(space);
const onCancel = jest.fn();
const onConfirm = jest.fn();
@ -64,7 +54,6 @@ describe('ConfirmDeleteModal', () => {
<ConfirmDeleteModal.WrappedComponent
space={space}
spacesManager={(spacesManager as unknown) as SpacesManager}
spacesNavState={spacesNavState}
onCancel={onCancel}
onConfirm={onConfirm}
intl={null as any}

View file

@ -24,7 +24,6 @@ import {
EuiText,
} from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { SpacesNavState } from 'plugins/spaces/views/nav_control';
import React, { ChangeEvent, Component } from 'react';
import { Space } from '../../../../common/model/space';
import { SpacesManager } from '../../../lib';
@ -32,7 +31,6 @@ import { SpacesManager } from '../../../lib';
interface Props {
space: Space;
spacesManager: SpacesManager;
spacesNavState: SpacesNavState;
onCancel: () => void;
onConfirm: () => void;
intl: InjectedIntl;
@ -42,6 +40,7 @@ interface State {
confirmSpaceName: string;
error: boolean | null;
deleteInProgress: boolean;
isDeletingCurrentSpace: boolean;
}
class ConfirmDeleteModalUI extends Component<Props, State> {
@ -49,13 +48,23 @@ class ConfirmDeleteModalUI extends Component<Props, State> {
confirmSpaceName: '',
error: null,
deleteInProgress: false,
isDeletingCurrentSpace: false,
};
public componentDidMount() {
isCurrentSpace(this.props.space, this.props.spacesManager).then(result => {
this.setState({
isDeletingCurrentSpace: result,
});
});
}
public render() {
const { space, spacesNavState, onCancel, intl } = this.props;
const { space, onCancel, intl } = this.props;
const { isDeletingCurrentSpace } = this.state;
let warning = null;
if (isDeletingCurrentSpace(space, spacesNavState)) {
if (isDeletingCurrentSpace) {
const name = (
<span>
(<strong>{space.name}</strong>)
@ -186,7 +195,7 @@ class ConfirmDeleteModalUI extends Component<Props, State> {
private onConfirm = async () => {
if (this.state.confirmSpaceName === this.props.space.name) {
const needsRedirect = isDeletingCurrentSpace(this.props.space, this.props.spacesNavState);
const needsRedirect = this.state.isDeletingCurrentSpace;
const spacesManager = this.props.spacesManager;
this.setState({
@ -210,8 +219,8 @@ class ConfirmDeleteModalUI extends Component<Props, State> {
};
}
function isDeletingCurrentSpace(space: Space, spacesNavState: SpacesNavState) {
return space.id === spacesNavState.getActiveSpace().id;
async function isCurrentSpace(space: Space, spacesManager: SpacesManager) {
return space.id === (await spacesManager.getActiveSpace()).id;
}
export const ConfirmDeleteModal = injectI18n(ConfirmDeleteModalUI);

View file

@ -33,6 +33,13 @@ const setup = async (opts: SetupOpts = {}) => {
const onClose = jest.fn();
const mockSpacesManager = spacesManagerMock.create();
mockSpacesManager.getActiveSpace.mockResolvedValue({
id: 'my-active-space',
name: 'my active space',
disabledFeatures: [],
});
mockSpacesManager.getSpaces.mockResolvedValue(
opts.mockSpaces || [
{
@ -79,11 +86,6 @@ const setup = async (opts: SetupOpts = {}) => {
<CopySavedObjectsToSpaceFlyout
savedObject={savedObjectToCopy}
spacesManager={(mockSpacesManager as unknown) as SpacesManager}
activeSpace={{
id: 'my-active-space',
name: 'my active space',
disabledFeatures: [],
}}
toastNotifications={(mockToastNotifications as unknown) as ToastNotifications}
onClose={onClose}
/>
@ -92,6 +94,7 @@ const setup = async (opts: SetupOpts = {}) => {
if (!opts.returnBeforeSpacesLoad) {
// Wait for spaces manager to complete and flyout to rerender
await Promise.resolve();
await Promise.resolve();
wrapper.update();
}

View file

@ -38,7 +38,6 @@ interface Props {
onClose: () => void;
savedObject: SavedObjectsManagementRecord;
spacesManager: SpacesManager;
activeSpace: Space;
toastNotifications: ToastNotifications;
}
@ -57,12 +56,13 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => {
}
);
useEffect(() => {
spacesManager
.getSpaces('copySavedObjectsIntoSpace')
.then(response => {
const getSpaces = spacesManager.getSpaces('copySavedObjectsIntoSpace');
const getActiveSpace = spacesManager.getActiveSpace();
Promise.all([getSpaces, getActiveSpace])
.then(([allSpaces, activeSpace]) => {
setSpacesState({
isLoading: false,
spaces: response,
spaces: allSpaces.filter(space => space.id !== activeSpace.id),
});
})
.catch(e => {
@ -73,7 +73,6 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => {
});
});
}, [spacesManager, toastNotifications]);
const eligibleSpaces = spaces.filter(space => space.id !== props.activeSpace.id);
const [copyInProgress, setCopyInProgress] = useState(false);
const [conflictResolutionInProgress, setConflictResolutionInProgress] = useState(false);
@ -159,7 +158,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => {
}
// Step 1a: assets loaded, but no spaces are available for copy.
if (eligibleSpaces.length === 0) {
if (spaces.length === 0) {
return (
<EuiEmptyPrompt
body={
@ -185,11 +184,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => {
// Step 2: Copy has not been initiated yet; User must fill out form to continue.
if (!copyInProgress) {
return (
<CopyToSpaceForm
spaces={eligibleSpaces}
copyOptions={copyOptions}
onUpdate={setCopyOptions}
/>
<CopyToSpaceForm spaces={spaces} copyOptions={copyOptions} onUpdate={setCopyOptions} />
);
}
@ -200,7 +195,7 @@ export const CopySavedObjectsToSpaceFlyout = (props: Props) => {
copyInProgress={copyInProgress}
conflictResolutionInProgress={conflictResolutionInProgress}
copyResult={copyResult}
spaces={eligibleSpaces}
spaces={spaces}
copyOptions={copyOptions}
retries={retries}
onRetriesChange={onRetriesChange}

View file

@ -6,7 +6,6 @@
import React from 'react';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { SpacesNavState } from '../../nav_control';
import { DeleteSpacesButton } from './delete_spaces_button';
import { spacesManagerMock } from '../../../lib/mocks';
import { SpacesManager } from '../../../lib';
@ -21,16 +20,10 @@ describe('DeleteSpacesButton', () => {
it('renders as expected', () => {
const spacesManager = spacesManagerMock.create();
const spacesNavState: SpacesNavState = {
getActiveSpace: () => space,
refreshSpacesList: jest.fn(),
};
const wrapper = shallowWithIntl(
<DeleteSpacesButton.WrappedComponent
space={space}
spacesManager={(spacesManager as unknown) as SpacesManager}
spacesNavState={spacesNavState}
onDelete={jest.fn()}
intl={null as any}
/>

View file

@ -6,7 +6,6 @@
import { EuiButton, EuiButtonIcon, EuiButtonIconProps } from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { SpacesNavState } from 'plugins/spaces/views/nav_control';
import React, { Component, Fragment } from 'react';
// @ts-ignore
import { toastNotifications } from 'ui/notify';
@ -18,7 +17,6 @@ interface Props {
style?: 'button' | 'icon';
space: Space;
spacesManager: SpacesManager;
spacesNavState: SpacesNavState;
onDelete: () => void;
intl: InjectedIntl;
}
@ -81,12 +79,11 @@ class DeleteSpacesButtonUI extends Component<Props, State> {
return null;
}
const { spacesNavState, spacesManager } = this.props;
const { spacesManager } = this.props;
return (
<ConfirmDeleteModal
space={this.props.space}
spacesNavState={spacesNavState}
spacesManager={spacesManager}
onCancel={() => {
this.setState({
@ -99,7 +96,7 @@ class DeleteSpacesButtonUI extends Component<Props, State> {
};
public deleteSpaces = async () => {
const { spacesManager, space, spacesNavState, intl } = this.props;
const { spacesManager, space, intl } = this.props;
try {
await spacesManager.deleteSpace(space);
@ -139,8 +136,6 @@ class DeleteSpacesButtonUI extends Component<Props, State> {
if (this.props.onDelete) {
this.props.onDelete();
}
spacesNavState.refreshSpacesList();
};
}

View file

@ -33,7 +33,7 @@ const space: Space = {
disabledFeatures: ['feature-1', 'feature-2'],
};
const uiCapabilities = {
const capabilities = {
navLinks: {},
management: {},
catalogue: {},
@ -49,7 +49,7 @@ describe('EnabledFeatures', () => {
<EnabledFeatures
features={features}
space={space}
uiCapabilities={uiCapabilities}
capabilities={capabilities}
intl={null as any}
onChange={jest.fn()}
/>
@ -64,7 +64,7 @@ describe('EnabledFeatures', () => {
<EnabledFeatures
features={features}
space={space}
uiCapabilities={uiCapabilities}
capabilities={capabilities}
intl={null as any}
onChange={changeHandler}
/>
@ -99,7 +99,7 @@ describe('EnabledFeatures', () => {
<EnabledFeatures
features={features}
space={space}
uiCapabilities={uiCapabilities}
capabilities={capabilities}
intl={null as any}
onChange={changeHandler}
/>

View file

@ -7,7 +7,7 @@
import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui';
import { FormattedMessage, InjectedIntl } from '@kbn/i18n/react';
import React, { Component, Fragment, ReactNode } from 'react';
import { UICapabilities } from 'ui/capabilities';
import { Capabilities } from 'src/core/public';
import { Feature } from '../../../../../../../../plugins/features/server';
import { Space } from '../../../../../common/model/space';
import { getEnabledFeatures } from '../../lib/feature_utils';
@ -17,7 +17,7 @@ import { FeatureTable } from './feature_table';
interface Props {
space: Partial<Space>;
features: Feature[];
uiCapabilities: UICapabilities;
capabilities: Capabilities;
intl: InjectedIntl;
onChange: (space: Partial<Space>) => void;
}
@ -130,7 +130,7 @@ export class EnabledFeatures extends Component<Props, {}> {
defaultMessage="The feature is hidden in the UI, but is not disabled."
/>
</p>
{this.props.uiCapabilities.spaces.manage && (
{this.props.capabilities.spaces.manage && (
<p>
<FormattedMessage
id="xpack.spaces.management.enabledSpaceFeatures.goToRolesLink"

View file

@ -6,13 +6,11 @@
jest.mock('ui/kfetch', () => ({
kfetch: () => Promise.resolve([{ id: 'feature-1', name: 'feature 1' }]),
}));
import '../../../__mocks__/ui_capabilities';
import '../../../__mocks__/xpack_info';
import { EuiButton, EuiLink, EuiSwitch } from '@elastic/eui';
import { ReactWrapper } from 'enzyme';
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { SpacesNavState } from '../../nav_control';
import { ConfirmAlterActiveSpaceModal } from './confirm_alter_active_space_modal';
import { ManageSpacePage } from './manage_space_page';
import { SectionPanel } from './section_panel';
@ -29,17 +27,18 @@ describe('ManageSpacePage', () => {
it('allows a space to be created', async () => {
const spacesManager = spacesManagerMock.create();
spacesManager.createSpace = jest.fn(spacesManager.createSpace);
const spacesNavState: SpacesNavState = {
getActiveSpace: () => space,
refreshSpacesList: jest.fn(),
};
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
const wrapper = mountWithIntl(
<ManageSpacePage.WrappedComponent
spacesManager={(spacesManager as unknown) as SpacesManager}
spacesNavState={spacesNavState}
intl={null as any}
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
/>
);
@ -75,17 +74,19 @@ describe('ManageSpacePage', () => {
initials: 'AB',
disabledFeatures: [],
});
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
const spacesNavState: SpacesNavState = {
getActiveSpace: () => space,
refreshSpacesList: jest.fn(),
};
const wrapper = mountWithIntl(
<ManageSpacePage.WrappedComponent
spaceId={'existing-space'}
spacesManager={(spacesManager as unknown) as SpacesManager}
spacesNavState={spacesNavState}
intl={null as any}
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
/>
);
@ -121,17 +122,19 @@ describe('ManageSpacePage', () => {
initials: 'AB',
disabledFeatures: [],
});
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
const spacesNavState: SpacesNavState = {
getActiveSpace: () => space,
refreshSpacesList: jest.fn(),
};
const wrapper = mountWithIntl(
<ManageSpacePage.WrappedComponent
spaceId={'my-space'}
spacesManager={(spacesManager as unknown) as SpacesManager}
spacesNavState={spacesNavState}
intl={null as any}
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
/>
);
@ -176,17 +179,19 @@ describe('ManageSpacePage', () => {
initials: 'AB',
disabledFeatures: [],
});
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
const spacesNavState: SpacesNavState = {
getActiveSpace: () => space,
refreshSpacesList: jest.fn(),
};
const wrapper = mountWithIntl(
<ManageSpacePage.WrappedComponent
spaceId={'my-space'}
spacesManager={(spacesManager as unknown) as SpacesManager}
spacesNavState={spacesNavState}
intl={null as any}
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
/>
);

View file

@ -16,12 +16,11 @@ import {
} from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import _ from 'lodash';
import { SpacesNavState } from 'plugins/spaces/views/nav_control';
import React, { Component, Fragment } from 'react';
import { capabilities } from 'ui/capabilities';
import { Breadcrumb } from 'ui/chrome';
import { kfetch } from 'ui/kfetch';
import { toastNotifications } from 'ui/notify';
import { Capabilities } from 'src/core/public';
import { Feature } from '../../../../../../../plugins/features/server';
import { isReservedSpace } from '../../../../common';
import { Space } from '../../../../common/model/space';
@ -39,9 +38,9 @@ import { ReservedSpaceBadge } from './reserved_space_badge';
interface Props {
spacesManager: SpacesManager;
spaceId?: string;
spacesNavState: SpacesNavState;
intl: InjectedIntl;
setBreadcrumbs?: (breadcrumbs: Breadcrumb[]) => void;
capabilities: Capabilities;
}
interface State {
@ -73,7 +72,7 @@ class ManageSpacePageUI extends Component<Props, State> {
}
public async componentDidMount() {
if (!capabilities.get().spaces.manage) {
if (!this.props.capabilities.spaces.manage) {
return;
}
@ -139,7 +138,7 @@ class ManageSpacePageUI extends Component<Props, State> {
);
public getForm = () => {
if (!capabilities.get().spaces.manage) {
if (!this.props.capabilities.spaces.manage) {
return <UnauthorizedPrompt />;
}
@ -173,7 +172,7 @@ class ManageSpacePageUI extends Component<Props, State> {
<EnabledFeatures
space={this.state.space}
features={this.state.features}
uiCapabilities={capabilities.get()}
capabilities={this.props.capabilities}
onChange={this.onSpaceChange}
intl={this.props.intl}
/>
@ -269,7 +268,6 @@ class ManageSpacePageUI extends Component<Props, State> {
data-test-subj="delete-space-button"
space={this.state.space as Space}
spacesManager={this.props.spacesManager}
spacesNavState={this.props.spacesNavState}
onDelete={this.backToSpacesList}
/>
</EuiFlexItem>
@ -298,27 +296,30 @@ class ManageSpacePageUI extends Component<Props, State> {
}
if (this.editingExistingSpace()) {
const { spacesNavState } = this.props;
const { spacesManager } = this.props;
const originalSpace: Space = this.state.originalSpace as Space;
const space: Space = this.state.space as Space;
const editingActiveSpace = spacesNavState.getActiveSpace().id === originalSpace.id;
spacesManager.getActiveSpace().then(activeSpace => {
const editingActiveSpace = activeSpace.id === originalSpace.id;
const haveDisabledFeaturesChanged =
space.disabledFeatures.length !== originalSpace.disabledFeatures.length ||
_.difference(space.disabledFeatures, originalSpace.disabledFeatures).length > 0;
const haveDisabledFeaturesChanged =
space.disabledFeatures.length !== originalSpace.disabledFeatures.length ||
_.difference(space.disabledFeatures, originalSpace.disabledFeatures).length > 0;
if (editingActiveSpace && haveDisabledFeaturesChanged) {
this.setState({
showAlteringActiveSpaceDialog: true,
});
if (editingActiveSpace && haveDisabledFeaturesChanged) {
this.setState({
showAlteringActiveSpaceDialog: true,
});
return;
}
return;
}
this.performSave();
});
} else {
this.performSave();
}
this.performSave();
};
private performSave = (requireRefresh = false) => {
@ -358,7 +359,6 @@ class ManageSpacePageUI extends Component<Props, State> {
action
.then(() => {
this.props.spacesNavState.refreshSpacesList();
toastNotifications.addSuccess(
intl.formatMessage(
{

View file

@ -15,16 +15,16 @@ import {
// @ts-ignore
import routes from 'ui/routes';
import { setup as managementSetup } from '../../../../../../../src/legacy/core_plugins/management/public/legacy';
import { SpacesManager } from '../../lib';
import { AdvancedSettingsSubtitle } from './components/advanced_settings_subtitle';
import { AdvancedSettingsTitle } from './components/advanced_settings_title';
import { start as spacesNPStart } from '../../legacy';
import { CopyToSpaceSavedObjectsManagementAction } from '../../lib/copy_saved_objects_to_space';
const MANAGE_SPACES_KEY = 'spaces';
routes.defaults(/\/management/, {
resolve: {
spacesManagementSection(activeSpace: any, serverBasePath: string) {
spacesManagementSection() {
function getKibanaSection() {
return management.getSection('kibana');
}
@ -48,21 +48,24 @@ routes.defaults(/\/management/, {
}
// Customize Saved Objects Management
const action = new CopyToSpaceSavedObjectsManagementAction(
new SpacesManager(serverBasePath),
activeSpace.space
);
// This route resolve function executes any time the management screen is loaded, and we want to ensure
// that this action is only registered once.
if (!managementSetup.savedObjects.registry.has(action.id)) {
managementSetup.savedObjects.registry.register(action);
}
spacesNPStart.then(({ spacesManager }) => {
const action = new CopyToSpaceSavedObjectsManagementAction(spacesManager!);
// This route resolve function executes any time the management screen is loaded, and we want to ensure
// that this action is only registered once.
if (!managementSetup.savedObjects.registry.has(action.id)) {
managementSetup.savedObjects.registry.register(action);
}
});
// Customize Advanced Settings
const PageTitle = () => <AdvancedSettingsTitle space={activeSpace.space} />;
const getActiveSpace = async () => {
const { spacesManager } = await spacesNPStart;
return spacesManager!.getActiveSpace();
};
const PageTitle = () => <AdvancedSettingsTitle getActiveSpace={getActiveSpace} />;
registerSettingsComponent(PAGE_TITLE_COMPONENT, PageTitle, true);
const SubTitle = () => <AdvancedSettingsSubtitle space={activeSpace.space} />;
const SubTitle = () => <AdvancedSettingsSubtitle getActiveSpace={getActiveSpace} />;
registerSettingsComponent(PAGE_SUBTITLE_COMPONENT, SubTitle, true);
}

View file

@ -5,31 +5,36 @@
*/
// @ts-ignore
import template from 'plugins/spaces/views/management/template.html';
import { SpacesNavState } from 'plugins/spaces/views/nav_control';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { I18nContext } from 'ui/i18n';
// @ts-ignore
import routes from 'ui/routes';
import { SpacesManager } from '../../lib/spaces_manager';
import { npStart } from 'ui/new_platform';
import { ManageSpacePage } from './edit_space';
import { getCreateBreadcrumbs, getEditBreadcrumbs, getListBreadcrumbs } from './lib';
import { SpacesGridPage } from './spaces_grid';
import { start as spacesNPStart } from '../../legacy';
const reactRootNodeId = 'manageSpacesReactRoot';
routes.when('/management/spaces/list', {
template,
k7Breadcrumbs: getListBreadcrumbs,
requireUICapability: 'management.kibana.spaces',
controller($scope: any, spacesNavState: SpacesNavState, serverBasePath: string) {
controller($scope: any) {
$scope.$$postDigest(async () => {
const domNode = document.getElementById(reactRootNodeId);
const spacesManager = new SpacesManager(serverBasePath);
const { spacesManager } = await spacesNPStart;
render(
<I18nContext>
<SpacesGridPage spacesManager={spacesManager} spacesNavState={spacesNavState} />
<SpacesGridPage
spacesManager={spacesManager!}
capabilities={npStart.core.application.capabilities}
/>
</I18nContext>,
domNode
);
@ -48,15 +53,18 @@ routes.when('/management/spaces/create', {
template,
k7Breadcrumbs: getCreateBreadcrumbs,
requireUICapability: 'management.kibana.spaces',
controller($scope: any, spacesNavState: SpacesNavState, serverBasePath: string) {
controller($scope: any) {
$scope.$$postDigest(async () => {
const domNode = document.getElementById(reactRootNodeId);
const spacesManager = new SpacesManager(serverBasePath);
const { spacesManager } = await spacesNPStart;
render(
<I18nContext>
<ManageSpacePage spacesManager={spacesManager} spacesNavState={spacesNavState} />
<ManageSpacePage
spacesManager={spacesManager!}
capabilities={npStart.core.application.capabilities}
/>
</I18nContext>,
domNode
);
@ -79,29 +87,21 @@ routes.when('/management/spaces/edit/:spaceId', {
template,
k7Breadcrumbs: () => getEditBreadcrumbs(),
requireUICapability: 'management.kibana.spaces',
controller(
$scope: any,
$route: any,
chrome: any,
spacesNavState: SpacesNavState,
serverBasePath: string
) {
controller($scope: any, $route: any) {
$scope.$$postDigest(async () => {
const domNode = document.getElementById(reactRootNodeId);
const { spaceId } = $route.current.params;
const spacesManager = new SpacesManager(serverBasePath);
const { spacesManager } = await spacesNPStart;
render(
<I18nContext>
<ManageSpacePage
spaceId={spaceId}
spacesManager={spacesManager}
spacesNavState={spacesNavState}
setBreadcrumbs={breadcrumbs => {
chrome.breadcrumbs.set(breadcrumbs);
}}
spacesManager={spacesManager!}
setBreadcrumbs={npStart.core.chrome.setBreadcrumbs}
capabilities={npStart.core.application.capabilities}
/>
</I18nContext>,
domNode

View file

@ -19,10 +19,9 @@ import {
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { capabilities } from 'ui/capabilities';
import { kfetch } from 'ui/kfetch';
// @ts-ignore
import { toastNotifications } from 'ui/notify';
import { Capabilities } from 'src/core/public';
import { Feature } from '../../../../../../../plugins/features/server';
import { isReservedSpace } from '../../../../common';
import { DEFAULT_SPACE_ID } from '../../../../common/constants';
@ -30,7 +29,6 @@ import { Space } from '../../../../common/model/space';
import { SpaceAvatar } from '../../../components';
import { getSpacesFeatureDescription } from '../../../lib/constants';
import { SpacesManager } from '../../../lib/spaces_manager';
import { SpacesNavState } from '../../nav_control';
import { ConfirmDeleteModal } from '../components/confirm_delete_modal';
import { SecureSpaceMessage } from '../components/secure_space_message';
import { UnauthorizedPrompt } from '../components/unauthorized_prompt';
@ -38,8 +36,8 @@ import { getEnabledFeatures } from '../lib/feature_utils';
interface Props {
spacesManager: SpacesManager;
spacesNavState: SpacesNavState;
intl: InjectedIntl;
capabilities: Capabilities;
}
interface State {
@ -65,7 +63,7 @@ class SpacesGridPageUI extends Component<Props, State> {
}
public componentDidMount() {
if (capabilities.get().spaces.manage) {
if (this.props.capabilities.spaces.manage) {
this.loadGrid();
}
}
@ -83,7 +81,7 @@ class SpacesGridPageUI extends Component<Props, State> {
public getPageContent() {
const { intl } = this.props;
if (!capabilities.get().spaces.manage) {
if (!this.props.capabilities.spaces.manage) {
return <UnauthorizedPrompt />;
}
@ -159,12 +157,11 @@ class SpacesGridPageUI extends Component<Props, State> {
return null;
}
const { spacesNavState, spacesManager } = this.props;
const { spacesManager } = this.props;
return (
<ConfirmDeleteModal
space={this.state.selectedSpace}
spacesNavState={spacesNavState}
spacesManager={spacesManager}
onCancel={() => {
this.setState({
@ -178,7 +175,7 @@ class SpacesGridPageUI extends Component<Props, State> {
public deleteSpace = async () => {
const { intl } = this.props;
const { spacesManager, spacesNavState } = this.props;
const { spacesManager } = this.props;
const space = this.state.selectedSpace;
@ -221,8 +218,6 @@ class SpacesGridPageUI extends Component<Props, State> {
);
toastNotifications.addSuccess(message);
spacesNavState.refreshSpacesList();
};
public loadGrid = async () => {

View file

@ -6,14 +6,12 @@
jest.mock('ui/kfetch', () => ({
kfetch: () => Promise.resolve([]),
}));
import '../../../__mocks__/ui_capabilities';
import '../../../__mocks__/xpack_info';
import React from 'react';
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { SpaceAvatar } from '../../../components';
import { spacesManagerMock } from '../../../lib/mocks';
import { SpacesManager } from '../../../lib';
import { SpacesNavState } from '../../nav_control';
import { SpacesGridPage } from './spaces_grid_page';
const spaces = [
@ -38,11 +36,6 @@ const spaces = [
},
];
const spacesNavState: SpacesNavState = {
getActiveSpace: () => spaces[0],
refreshSpacesList: jest.fn(),
};
const spacesManager = spacesManagerMock.create();
spacesManager.getSpaces = jest.fn().mockResolvedValue(spaces);
@ -52,8 +45,13 @@ describe('SpacesGridPage', () => {
shallowWithIntl(
<SpacesGridPage.WrappedComponent
spacesManager={(spacesManager as unknown) as SpacesManager}
spacesNavState={spacesNavState}
intl={null as any}
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
/>
)
).toMatchSnapshot();
@ -63,8 +61,13 @@ describe('SpacesGridPage', () => {
const wrapper = mountWithIntl(
<SpacesGridPage.WrappedComponent
spacesManager={(spacesManager as unknown) as SpacesManager}
spacesNavState={spacesNavState}
intl={null as any}
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
/>
);

View file

@ -4,25 +4,18 @@ exports[`NavControlPopover renders without crashing 1`] = `
<EuiPopover
anchorPosition="downRight"
button={
<SpacesHeaderNavButton
linkIcon={
<SpaceAvatar
announceSpaceName={true}
className="spaceNavGraphic"
size="s"
space={
Object {
"disabledFeatures": Array [],
"id": "",
"name": "foo",
}
}
/>
}
linkTitle="foo"
spaceSelectorShown={false}
toggleSpaceSelector={[Function]}
/>
<EuiHeaderSectionItemButton
aria-controls="headerSpacesMenuList"
aria-expanded={false}
aria-haspopup="true"
aria-label="loading"
onClick={[Function]}
title="loading"
>
<EuiLoadingSpinner
size="m"
/>
</EuiHeaderSectionItemButton>
}
closePopover={[Function]}
data-test-subj="spacesNavSelector"
@ -36,6 +29,16 @@ exports[`NavControlPopover renders without crashing 1`] = `
withTitle={true}
>
<SpacesDescription
capabilities={
Object {
"catalogue": Object {},
"management": Object {},
"navLinks": Object {},
"spaces": Object {
"manage": true,
},
}
}
onManageSpacesClick={[Function]}
/>
</EuiPopover>

View file

@ -19,6 +19,16 @@ exports[`SpacesDescription renders without crashing 1`] = `
key="manageSpacesButton"
>
<ManageSpacesButton
capabilities={
Object {
"catalogue": Object {},
"management": Object {},
"navLinks": Object {},
"spaces": Object {
"manage": true,
},
}
}
onClick={[MockFunction]}
size="s"
style={

View file

@ -10,6 +10,20 @@ import { SpacesDescription } from './spaces_description';
describe('SpacesDescription', () => {
it('renders without crashing', () => {
expect(shallow(<SpacesDescription onManageSpacesClick={jest.fn()} />)).toMatchSnapshot();
expect(
shallow(
<SpacesDescription
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: {
manage: true,
},
}}
onManageSpacesClick={jest.fn()}
/>
)
).toMatchSnapshot();
});
});

View file

@ -6,11 +6,13 @@
import { EuiContextMenuPanel, EuiText } from '@elastic/eui';
import React, { FC } from 'react';
import { Capabilities } from 'src/core/public';
import { ManageSpacesButton } from '../../../components';
import { getSpacesFeatureDescription } from '../../../lib/constants';
interface Props {
onManageSpacesClick: () => void;
capabilities: Capabilities;
}
export const SpacesDescription: FC<Props> = (props: Props) => {
@ -29,6 +31,7 @@ export const SpacesDescription: FC<Props> = (props: Props) => {
size="s"
style={{ width: `100%` }}
onClick={props.onManageSpacesClick}
capabilities={props.capabilities}
/>
</div>
</EuiContextMenuPanel>

View file

@ -1,25 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
// @ts-ignore
EuiHeaderSectionItemButton,
} from '@elastic/eui';
import React from 'react';
import { ButtonProps } from '../types';
export const SpacesHeaderNavButton: React.FC<ButtonProps> = props => (
<EuiHeaderSectionItemButton
aria-controls="headerSpacesMenuList"
aria-expanded={props.spaceSelectorShown}
aria-haspopup="true"
aria-label={props.linkTitle}
title={props.linkTitle}
onClick={props.toggleSpaceSelector}
>
{props.linkIcon}
</EuiHeaderSectionItemButton>
);

View file

@ -4,18 +4,27 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiContextMenuItem, EuiContextMenuPanel, EuiFieldSearch, EuiText } from '@elastic/eui';
import {
EuiContextMenuItem,
EuiContextMenuPanel,
EuiFieldSearch,
EuiText,
EuiLoadingContent,
} from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React, { Component } from 'react';
import React, { Component, ReactElement } from 'react';
import { Capabilities } from 'src/core/public';
import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../../common/constants';
import { Space } from '../../../../common/model/space';
import { ManageSpacesButton, SpaceAvatar } from '../../../components';
interface Props {
spaces: Space[];
isLoading: boolean;
onSelectSpace: (space: Space) => void;
onManageSpacesClick: () => void;
intl: InjectedIntl;
capabilities: Capabilities;
}
interface State {
@ -30,10 +39,12 @@ class SpacesMenuUI extends Component<Props, State> {
};
public render() {
const { intl } = this.props;
const { intl, isLoading } = this.props;
const { searchTerm } = this.state;
const items = this.getVisibleSpaces(searchTerm).map(this.renderSpaceMenuItem);
const items = isLoading
? [1, 2, 3].map(this.renderPlaceholderMenuItem)
: this.getVisibleSpaces(searchTerm).map(this.renderSpaceMenuItem);
const panelProps = {
className: 'spcMenu',
@ -76,7 +87,7 @@ class SpacesMenuUI extends Component<Props, State> {
return filteredSpaces;
};
private renderSpacesListPanel = (items: JSX.Element[], searchTerm: string) => {
private renderSpacesListPanel = (items: ReactElement[], searchTerm: string) => {
if (items.length === 0) {
return (
<EuiText color="subdued" className="eui-textCenter">
@ -151,6 +162,7 @@ class SpacesMenuUI extends Component<Props, State> {
className="spcMenu__manageButton"
size="s"
onClick={this.props.onManageSpacesClick}
capabilities={this.props.capabilities}
/>
);
};
@ -175,6 +187,14 @@ class SpacesMenuUI extends Component<Props, State> {
</EuiContextMenuItem>
);
};
private renderPlaceholderMenuItem = (key: string | number): JSX.Element => {
return (
<EuiContextMenuItem key={key} disabled={true}>
<EuiLoadingContent lines={1} />
</EuiContextMenuItem>
);
};
}
export const SpacesMenu = injectI18n(SpacesMenuUI);

View file

@ -4,6 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
import './nav_control';
export { SpacesNavState } from './nav_control';
export { initSpacesNavControl } from './nav_control';

View file

@ -5,69 +5,34 @@
*/
import { SpacesManager } from 'plugins/spaces/lib/spaces_manager';
// @ts-ignore
import template from 'plugins/spaces/views/nav_control/nav_control.html';
import { NavControlPopover } from 'plugins/spaces/views/nav_control/nav_control_popover';
// @ts-ignore
import { Path } from 'plugins/xpack_main/services/path';
import React from 'react';
import ReactDOM from 'react-dom';
import { I18nContext } from 'ui/i18n';
// @ts-ignore
import { uiModules } from 'ui/modules';
import {
chromeHeaderNavControlsRegistry,
NavControlSide,
} from 'ui/registry/chrome_header_nav_controls';
// @ts-ignore
import { Space } from '../../../common/model/space';
import { SpacesHeaderNavButton } from './components/spaces_header_nav_button';
import { CoreStart } from 'src/core/public';
import { NavControlPopover } from './nav_control_popover';
const module = uiModules.get('spaces_nav', ['kibana']);
export interface SpacesNavState {
getActiveSpace: () => Space;
refreshSpacesList: () => void;
}
let spacesManager: SpacesManager;
module.service('spacesNavState', (activeSpace: any) => {
return {
getActiveSpace: () => {
return activeSpace.space;
},
refreshSpacesList: () => {
if (spacesManager) {
spacesManager.requestRefresh();
export function initSpacesNavControl(spacesManager: SpacesManager, core: CoreStart) {
const I18nContext = core.i18n.Context;
core.chrome.navControls.registerLeft({
order: 1000,
mount(targetDomElement: HTMLElement) {
if (core.http.anonymousPaths.isAnonymous(window.location.pathname)) {
return () => null;
}
ReactDOM.render(
<I18nContext>
<NavControlPopover
spacesManager={spacesManager}
anchorPosition="downLeft"
capabilities={core.application.capabilities}
/>
</I18nContext>,
targetDomElement
);
return () => {
ReactDOM.unmountComponentAtNode(targetDomElement);
};
},
} as SpacesNavState;
});
chromeHeaderNavControlsRegistry.register((chrome: any, activeSpace: any) => ({
name: 'spaces',
order: 1000,
side: NavControlSide.Left,
render(el: HTMLElement) {
if (Path.isUnauthenticated()) {
return;
}
const serverBasePath = chrome.getInjected('serverBasePath');
spacesManager = new SpacesManager(serverBasePath);
ReactDOM.render(
<I18nContext>
<NavControlPopover
spacesManager={spacesManager}
activeSpace={activeSpace}
anchorPosition="downLeft"
buttonClass={SpacesHeaderNavButton}
/>
</I18nContext>,
el
);
},
}));
});
}

View file

@ -4,40 +4,31 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { mount, shallow } from 'enzyme';
import * as Rx from 'rxjs';
import { shallow } from 'enzyme';
import React from 'react';
import { SpaceAvatar } from '../../components';
import { spacesManagerMock } from '../../lib/mocks';
import { SpacesManager } from '../../lib';
import { SpacesHeaderNavButton } from './components/spaces_header_nav_button';
import { NavControlPopover } from './nav_control_popover';
import { EuiHeaderSectionItemButton } from '@elastic/eui';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
describe('NavControlPopover', () => {
it('renders without crashing', () => {
const activeSpace = {
space: { id: '', name: 'foo', disabledFeatures: [] },
valid: true,
};
const spacesManager = spacesManagerMock.create();
const wrapper = shallow(
<NavControlPopover
activeSpace={activeSpace}
spacesManager={(spacesManager as unknown) as SpacesManager}
anchorPosition={'downRight'}
buttonClass={SpacesHeaderNavButton}
capabilities={{ navLinks: {}, management: {}, catalogue: {}, spaces: { manage: true } }}
/>
);
expect(wrapper).toMatchSnapshot();
});
it('renders a SpaceAvatar with the active space', async () => {
const activeSpace = {
space: { id: 'foo-space', name: 'foo', disabledFeatures: [] },
valid: true,
};
const spacesManager = spacesManagerMock.create();
spacesManager.getSpaces = jest.fn().mockResolvedValue([
{
@ -51,23 +42,27 @@ describe('NavControlPopover', () => {
disabledFeatures: [],
},
]);
spacesManager.onActiveSpaceChange$ = Rx.of({
id: 'foo-space',
name: 'foo',
disabledFeatures: [],
});
const wrapper = mount<any, any>(
const wrapper = mountWithIntl(
<NavControlPopover
activeSpace={activeSpace}
spacesManager={(spacesManager as unknown) as SpacesManager}
anchorPosition={'rightCenter'}
buttonClass={SpacesHeaderNavButton}
capabilities={{ navLinks: {}, management: {}, catalogue: {}, spaces: { manage: true } }}
/>
);
return new Promise(resolve => {
setTimeout(() => {
expect(wrapper.state().spaces).toHaveLength(2);
wrapper.update();
expect(wrapper.find(SpaceAvatar)).toHaveLength(1);
resolve();
}, 20);
});
wrapper.find(EuiHeaderSectionItemButton).simulate('click');
// Wait for `getSpaces` promise to resolve
await Promise.resolve();
await Promise.resolve();
wrapper.update();
expect(wrapper.find(SpaceAvatar)).toHaveLength(3);
});
});

View file

@ -4,24 +4,25 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiAvatar, EuiPopover, PopoverAnchorPosition } from '@elastic/eui';
import {
EuiPopover,
PopoverAnchorPosition,
EuiLoadingSpinner,
EuiHeaderSectionItemButton,
} from '@elastic/eui';
import React, { Component } from 'react';
import { Capabilities } from 'src/core/public';
import { Subscription } from 'rxjs';
import { Space } from '../../../common/model/space';
import { SpaceAvatar } from '../../components';
import { SpacesManager } from '../../lib/spaces_manager';
import { SpacesDescription } from './components/spaces_description';
import { SpacesMenu } from './components/spaces_menu';
import { ButtonProps } from './types';
interface Props {
spacesManager: SpacesManager;
activeSpace: {
valid: boolean;
error?: string;
space: Space;
};
anchorPosition: PopoverAnchorPosition;
buttonClass: React.ComponentType<ButtonProps>;
capabilities: Capabilities;
}
interface State {
@ -32,23 +33,31 @@ interface State {
}
export class NavControlPopover extends Component<Props, State> {
private activeSpace$?: Subscription;
constructor(props: Props) {
super(props);
this.state = {
showSpaceSelector: false,
loading: false,
activeSpace: props.activeSpace.space,
activeSpace: null,
spaces: [],
};
}
public componentDidMount() {
this.loadSpaces();
public componentWillMount() {
this.activeSpace$ = this.props.spacesManager.onActiveSpaceChange$.subscribe({
next: activeSpace => {
this.setState({
activeSpace,
});
},
});
}
if (this.props.spacesManager) {
this.props.spacesManager.on('request_refresh', () => {
this.loadSpaces();
});
public componentWillUnmount() {
if (this.activeSpace$) {
this.activeSpace$.unsubscribe();
}
}
@ -59,20 +68,26 @@ export class NavControlPopover extends Component<Props, State> {
}
let element: React.ReactNode;
if (this.state.spaces.length < 2) {
element = <SpacesDescription onManageSpacesClick={this.toggleSpaceSelector} />;
if (!this.state.loading && this.state.spaces.length < 2) {
element = (
<SpacesDescription
onManageSpacesClick={this.toggleSpaceSelector}
capabilities={this.props.capabilities}
/>
);
} else {
element = (
<SpacesMenu
spaces={this.state.spaces}
isLoading={this.state.loading}
onSelectSpace={this.onSelectSpace}
onManageSpacesClick={this.toggleSpaceSelector}
capabilities={this.props.capabilities}
/>
);
}
return (
// @ts-ignore repositionOnScroll doesn't exist on EuiPopover
<EuiPopover
id={'spcMenuPopover'}
data-test-subj={`spacesNavSelector`}
@ -91,7 +106,11 @@ export class NavControlPopover extends Component<Props, State> {
}
private async loadSpaces() {
const { spacesManager, activeSpace } = this.props;
const { spacesManager } = this.props;
if (this.state.loading) {
return;
}
this.setState({
loading: true,
@ -99,16 +118,8 @@ export class NavControlPopover extends Component<Props, State> {
const spaces = await spacesManager.getSpaces();
// Update the active space definition, if it changed since the last load operation
let activeSpaceEntry: Space | null = activeSpace.space;
if (activeSpace.valid) {
activeSpaceEntry = spaces.find(space => space.id === this.props.activeSpace.space.id) || null;
}
this.setState({
spaces,
activeSpace: activeSpaceEntry,
loading: false,
});
}
@ -117,10 +128,7 @@ export class NavControlPopover extends Component<Props, State> {
const { activeSpace } = this.state;
if (!activeSpace) {
return this.getButton(
<EuiAvatar size={'s'} className={'spaceNavGraphic'} name={'error'} />,
'error'
);
return this.getButton(<EuiLoadingSpinner size="m" />, 'loading');
}
return this.getButton(
@ -130,14 +138,17 @@ export class NavControlPopover extends Component<Props, State> {
};
private getButton = (linkIcon: JSX.Element, linkTitle: string) => {
const Button = this.props.buttonClass;
return (
<Button
linkTitle={linkTitle}
linkIcon={linkIcon}
toggleSpaceSelector={this.toggleSpaceSelector}
spaceSelectorShown={this.state.showSpaceSelector}
/>
<EuiHeaderSectionItemButton
aria-controls="headerSpacesMenuList"
aria-expanded={this.state.showSpaceSelector}
aria-haspopup="true"
aria-label={linkTitle}
title={linkTitle}
onClick={this.toggleSpaceSelector}
>
{linkIcon}
</EuiHeaderSectionItemButton>
);
};

View file

@ -63,21 +63,9 @@ exports[`it renders without crashing 1`] = `
<EuiSpacer
size="xl"
/>
<SpaceCards
onSpaceSelect={[Function]}
spaces={Array []}
<EuiLoadingSpinner
size="xl"
/>
<EuiSpacer />
<EuiText
color="subdued"
textAlign="center"
>
<FormattedMessage
defaultMessage="No spaces match search criteria"
id="xpack.spaces.spaceSelector.noSpacesMatchSearchCriteriaDescription"
values={Object {}}
/>
</EuiText>
</EuiPageContent>
</EuiPageBody>
</EuiPage>

View file

@ -4,7 +4,6 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { SpacesManager } from 'plugins/spaces/lib/spaces_manager';
// @ts-ignore
import template from 'plugins/spaces/views/space_selector/space_selector.html';
import chrome from 'ui/chrome';
@ -14,20 +13,20 @@ import { uiModules } from 'ui/modules';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Space } from '../../../common/model/space';
import { SpaceSelector } from './space_selector';
import { start as spacesNPStart } from '../../legacy';
const module = uiModules.get('spaces_selector', []);
module.controller(
'spacesSelectorController',
($scope: any, spaces: Space[], serverBasePath: string) => {
module.controller('spacesSelectorController', ($scope: any) => {
$scope.$$postDigest(async () => {
const domNode = document.getElementById('spaceSelectorRoot');
const spacesManager = new SpacesManager(serverBasePath);
const { spacesManager } = await spacesNPStart;
render(
<I18nContext>
<SpaceSelector spaces={spaces} spacesManager={spacesManager} />
<SpaceSelector spacesManager={spacesManager!} />
</I18nContext>,
domNode
);
@ -38,7 +37,7 @@ module.controller(
unmountComponentAtNode(domNode);
}
});
}
);
});
});
chrome.setVisible(false).setRootTemplate(template);

View file

@ -5,7 +5,7 @@
*/
import React from 'react';
import { renderWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { shallowWithIntl } from 'test_utils/enzyme_helpers';
import { Space } from '../../../common/model/space';
import { spacesManagerMock } from '../../lib/mocks';
import { SpaceSelector } from './space_selector';
@ -19,42 +19,12 @@ function getSpacesManager(spaces: Space[] = []) {
test('it renders without crashing', () => {
const spacesManager = getSpacesManager();
const component = shallowWithIntl(
<SpaceSelector.WrappedComponent
spaces={[]}
spacesManager={spacesManager as any}
intl={null as any}
/>
<SpaceSelector.WrappedComponent spacesManager={spacesManager as any} intl={null as any} />
);
expect(component).toMatchSnapshot();
});
test('it uses the spaces on props, when provided', () => {
const spacesManager = getSpacesManager();
const spaces = [
{
id: 'space-1',
name: 'Space 1',
description: 'This is the first space',
disabledFeatures: [],
},
];
const component = renderWithIntl(
<SpaceSelector.WrappedComponent
spaces={spaces}
spacesManager={spacesManager as any}
intl={null as any}
/>
);
return Promise.resolve().then(() => {
expect(component.find('.spaceCard')).toHaveLength(1);
expect(spacesManager.getSpaces).toHaveBeenCalledTimes(0);
});
});
test('it queries for spaces when not provided on props', () => {
test('it queries for spaces when loaded', () => {
const spaces = [
{
id: 'space-1',

View file

@ -16,6 +16,7 @@ import {
EuiSpacer,
EuiText,
EuiTitle,
EuiLoadingSpinner,
} from '@elastic/eui';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { SpacesManager } from 'plugins/spaces/lib';
@ -25,7 +26,6 @@ import { Space } from '../../../common/model/space';
import { SpaceCards } from '../components/space_cards';
interface Props {
spaces?: Space[];
spacesManager: SpacesManager;
intl: InjectedIntl;
}
@ -41,17 +41,11 @@ class SpaceSelectorUI extends Component<Props, State> {
constructor(props: Props) {
super(props);
const state: State = {
this.state = {
loading: false,
searchTerm: '',
spaces: [],
};
if (Array.isArray(props.spaces)) {
state.spaces = [...props.spaces];
}
this.state = state;
}
public setHeaderRef = (ref: HTMLElement | null) => {
@ -130,9 +124,13 @@ class SpaceSelectorUI extends Component<Props, State> {
<EuiSpacer size="xl" />
<SpaceCards spaces={filteredSpaces} onSpaceSelect={this.onSelectSpace} />
{this.state.loading && <EuiLoadingSpinner size="xl" />}
{filteredSpaces.length === 0 && (
{!this.state.loading && (
<SpaceCards spaces={filteredSpaces} onSpaceSelect={this.onSelectSpace} />
)}
{!this.state.loading && filteredSpaces.length === 0 && (
<Fragment>
<EuiSpacer />
<EuiText
@ -155,7 +153,7 @@ class SpaceSelectorUI extends Component<Props, State> {
public getSearchField = () => {
const { intl } = this.props;
if (!this.props.spaces || this.props.spaces.length < SPACE_SEARCH_COUNT_THRESHOLD) {
if (!this.state.spaces || this.state.spaces.length < SPACE_SEARCH_COUNT_THRESHOLD) {
return null;
}
return (

View file

@ -4,10 +4,10 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { UICapabilities } from 'ui/capabilities';
import { Feature } from '../../../../plugins/features/server';
import { Space } from '../../common/model/space';
import { toggleUICapabilities } from './toggle_ui_capabilities';
import { Capabilities } from 'src/core/public';
const features: Feature[] = [
{
@ -58,7 +58,7 @@ const features: Feature[] = [
},
];
const buildUiCapabilities = () =>
const buildCapabilities = () =>
Object.freeze({
navLinks: {
feature1: true,
@ -89,7 +89,7 @@ const buildUiCapabilities = () =>
foo: true,
bar: true,
},
}) as UICapabilities;
}) as Capabilities;
describe('toggleUiCapabilities', () => {
it('does not toggle capabilities when the space has no disabled features', () => {
@ -99,9 +99,9 @@ describe('toggleUiCapabilities', () => {
disabledFeatures: [],
};
const uiCapabilities: UICapabilities = buildUiCapabilities();
const result = toggleUICapabilities(features, uiCapabilities, space);
expect(result).toEqual(buildUiCapabilities());
const capabilities = buildCapabilities();
const result = toggleUICapabilities(features, capabilities, space);
expect(result).toEqual(buildCapabilities());
});
it('ignores unknown disabledFeatures', () => {
@ -111,9 +111,9 @@ describe('toggleUiCapabilities', () => {
disabledFeatures: ['i-do-not-exist'],
};
const uiCapabilities: UICapabilities = buildUiCapabilities();
const result = toggleUICapabilities(features, uiCapabilities, space);
expect(result).toEqual(buildUiCapabilities());
const capabilities = buildCapabilities();
const result = toggleUICapabilities(features, capabilities, space);
expect(result).toEqual(buildCapabilities());
});
it('disables the corresponding navLink, catalogue, management sections, and all capability flags for disabled features', () => {
@ -123,10 +123,10 @@ describe('toggleUiCapabilities', () => {
disabledFeatures: ['feature_2'],
};
const uiCapabilities: UICapabilities = buildUiCapabilities();
const result = toggleUICapabilities(features, uiCapabilities, space);
const capabilities = buildCapabilities();
const result = toggleUICapabilities(features, capabilities, space);
const expectedCapabilities = buildUiCapabilities();
const expectedCapabilities = buildCapabilities();
expectedCapabilities.navLinks.feature2 = false;
expectedCapabilities.catalogue.feature2Entry = false;
@ -144,10 +144,10 @@ describe('toggleUiCapabilities', () => {
disabledFeatures: ['feature_1', 'feature_2', 'feature_3'],
};
const uiCapabilities: UICapabilities = buildUiCapabilities();
const result = toggleUICapabilities(features, uiCapabilities, space);
const capabilities = buildCapabilities();
const result = toggleUICapabilities(features, capabilities, space);
const expectedCapabilities = buildUiCapabilities();
const expectedCapabilities = buildCapabilities();
expectedCapabilities.feature_1.bar = false;
expectedCapabilities.feature_1.foo = false;

View file

@ -10,10 +10,10 @@ import { Space } from '../../common/model/space';
export function toggleUICapabilities(
features: Feature[],
uiCapabilities: UICapabilities,
capabilities: UICapabilities,
activeSpace: Space
) {
const clonedCapabilities = _.cloneDeep(uiCapabilities);
const clonedCapabilities = _.cloneDeep(capabilities);
toggleDisabledFeatures(features, clonedCapabilities, activeSpace);
@ -22,18 +22,18 @@ export function toggleUICapabilities(
function toggleDisabledFeatures(
features: Feature[],
uiCapabilities: UICapabilities,
capabilities: UICapabilities,
activeSpace: Space
) {
const disabledFeatureKeys: string[] = activeSpace.disabledFeatures;
const disabledFeatureKeys = activeSpace.disabledFeatures;
const disabledFeatures: Feature[] = disabledFeatureKeys
const disabledFeatures = disabledFeatureKeys
.map(key => features.find(feature => feature.id === key))
.filter(feature => typeof feature !== 'undefined') as Feature[];
const navLinks: Record<string, boolean> = uiCapabilities.navLinks;
const catalogueEntries: Record<string, boolean> = uiCapabilities.catalogue;
const managementItems: Record<string, Record<string, boolean>> = uiCapabilities.management;
const navLinks = capabilities.navLinks;
const catalogueEntries = capabilities.catalogue;
const managementItems = capabilities.management;
for (const feature of disabledFeatures) {
// Disable associated navLink, if one exists
@ -42,13 +42,13 @@ function toggleDisabledFeatures(
}
// Disable associated catalogue entries
const privilegeCatalogueEntries: string[] = feature.catalogue || [];
const privilegeCatalogueEntries = feature.catalogue || [];
privilegeCatalogueEntries.forEach(catalogueEntryId => {
catalogueEntries[catalogueEntryId] = false;
});
// Disable associated management items
const privilegeManagementSections: Record<string, string[]> = feature.management || {};
const privilegeManagementSections = feature.management || {};
Object.entries(privilegeManagementSections).forEach(([sectionId, sectionItems]) => {
sectionItems.forEach(item => {
if (
@ -61,8 +61,8 @@ function toggleDisabledFeatures(
});
// Disable "sub features" that match the disabled feature
if (uiCapabilities.hasOwnProperty(feature.id)) {
const capability = uiCapabilities[feature.id];
if (capabilities.hasOwnProperty(feature.id)) {
const capability = capabilities[feature.id];
Object.keys(capability).forEach(featureKey => {
capability[featureKey] = false;
});

View file

@ -31,6 +31,8 @@ import { ConfigType } from './config';
import { toggleUICapabilities } from './lib/toggle_ui_capabilities';
import { initSpacesRequestInterceptors } from './lib/request_interceptors';
import { initExternalSpacesApi } from './routes/api/external';
import { initInternalSpacesApi } from './routes/api/internal';
/**
* Describes a set of APIs that is available in the legacy platform only and required by this plugin
* to function properly.
@ -119,6 +121,12 @@ export class Plugin {
spacesService,
});
const internalRouter = core.http.createRouter();
initInternalSpacesApi({
internalRouter,
spacesService,
});
initSpacesRequestInterceptors({
http: core.http,
log: this.log,

View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as Rx from 'rxjs';
import { createLegacyAPI, mockRouteContextWithInvalidLicense } from '../__fixtures__';
import { CoreSetup, kibanaResponseFactory } from 'src/core/server';
import { httpServiceMock, httpServerMock, elasticsearchServiceMock } from 'src/core/server/mocks';
import { SpacesService } from '../../../spaces_service';
import { SpacesAuditLogger } from '../../../lib/audit_logger';
import { spacesConfig } from '../../../lib/__fixtures__';
import { initGetActiveSpaceApi } from './get_active_space';
describe('GET /internal/spaces/_active_space', () => {
const setup = async () => {
const httpService = httpServiceMock.createSetupContract();
const router = httpServiceMock.createRouter();
const legacyAPI = createLegacyAPI();
const service = new SpacesService(null as any, () => legacyAPI);
const spacesService = await service.setup({
http: (httpService as unknown) as CoreSetup['http'],
elasticsearch: elasticsearchServiceMock.createSetupContract(),
authorization: null,
getSpacesAuditLogger: () => ({} as SpacesAuditLogger),
config$: Rx.of(spacesConfig),
});
initGetActiveSpaceApi({
internalRouter: router,
spacesService,
});
return {
routeHandler: router.get.mock.calls[0][1],
};
};
it(`returns http/403 when the license is invalid`, async () => {
const { routeHandler } = await setup();
const request = httpServerMock.createKibanaRequest({
method: 'get',
});
const response = await routeHandler(
mockRouteContextWithInvalidLicense,
request,
kibanaResponseFactory
);
expect(response.status).toEqual(403);
expect(response.payload).toEqual({
message: 'License is invalid for spaces',
});
});
});

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { wrapError } from '../../../lib/errors';
import { InternalRouteDeps } from '.';
import { createLicensedRouteHandler } from '../../lib';
export function initGetActiveSpaceApi(deps: InternalRouteDeps) {
const { internalRouter, spacesService } = deps;
internalRouter.get(
{
path: '/internal/spaces/_active_space',
validate: false,
},
createLicensedRouteHandler(async (context, request, response) => {
try {
const space = await spacesService.getActiveSpace(request);
return response.ok({ body: space });
} catch (error) {
return response.customError(wrapError(error));
}
})
);
}

View file

@ -0,0 +1,18 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { IRouter } from 'src/core/server';
import { SpacesServiceSetup } from '../../../spaces_service/spaces_service';
import { initGetActiveSpaceApi } from './get_active_space';
export interface InternalRouteDeps {
internalRouter: IRouter;
spacesService: SpacesServiceSetup;
}
export function initInternalSpacesApi(deps: InternalRouteDeps) {
initGetActiveSpaceApi(deps);
}

View file

@ -0,0 +1,64 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { FtrProviderContext } from '../../ftr_provider_context';
export default function({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const spacesService = getService('spaces');
describe('GET /internal/spaces/_active_space', () => {
before(async () => {
await spacesService.create({
id: 'foo-space',
name: 'Foo Space',
disabledFeatures: ['timelion'],
color: '#AABBCC',
});
});
after(async () => {
await spacesService.delete('foo-space');
});
it('returns the default space', async () => {
await supertest
.get('/internal/spaces/_active_space')
.set('kbn-xsrf', 'xxx')
.expect(200, {
id: 'default',
name: 'Default',
description: 'This is your default space!',
color: '#00bfb3',
disabledFeatures: [],
_reserved: true,
});
});
it('returns the foo space', async () => {
await supertest
.get('/s/foo-space/internal/spaces/_active_space')
.set('kbn-xsrf', 'xxx')
.expect(200, {
id: 'foo-space',
name: 'Foo Space',
disabledFeatures: ['timelion'],
color: '#AABBCC',
});
});
it('returns 404 when the space is not found', async () => {
await supertest
.get('/s/not-found-space/internal/spaces/_active_space')
.set('kbn-xsrf', 'xxx')
.expect(404, {
statusCode: 404,
error: 'Not Found',
message: 'Saved object [space/not-found-space] not found',
});
});
});
}

View file

@ -9,6 +9,7 @@ export default function({ loadTestFile }: FtrProviderContext) {
describe('spaces', function() {
this.tags('ciGroup6');
loadTestFile(require.resolve('./get_active_space'));
loadTestFile(require.resolve('./saved_objects'));
loadTestFile(require.resolve('./space_attributes'));
});