diff --git a/x-pack/plugins/spaces/common/constants.js b/x-pack/plugins/spaces/common/constants.ts similarity index 100% rename from x-pack/plugins/spaces/common/constants.js rename to x-pack/plugins/spaces/common/constants.ts diff --git a/x-pack/plugins/spaces/common/index.js b/x-pack/plugins/spaces/common/index.ts similarity index 82% rename from x-pack/plugins/spaces/common/index.js rename to x-pack/plugins/spaces/common/index.ts index 05320f110f4c76..0e605562ea3ea4 100644 --- a/x-pack/plugins/spaces/common/index.js +++ b/x-pack/plugins/spaces/common/index.ts @@ -7,7 +7,4 @@ export { isReservedSpace } from './is_reserved_space'; export { MAX_SPACE_INITIALS } from './constants'; -export { - getSpaceInitials, - getSpaceColor, -} from './space_attributes'; \ No newline at end of file +export { getSpaceInitials, getSpaceColor } from './space_attributes'; diff --git a/x-pack/plugins/spaces/common/is_reserved_space.js b/x-pack/plugins/spaces/common/is_reserved_space.ts similarity index 81% rename from x-pack/plugins/spaces/common/is_reserved_space.js rename to x-pack/plugins/spaces/common/is_reserved_space.ts index 7fe0a647268863..0889686aa77f59 100644 --- a/x-pack/plugins/spaces/common/is_reserved_space.js +++ b/x-pack/plugins/spaces/common/is_reserved_space.ts @@ -5,6 +5,7 @@ */ import { get } from 'lodash'; +import { Space } from './model/space'; /** * Returns whether the given Space is reserved or not. @@ -12,6 +13,6 @@ import { get } from 'lodash'; * @param space the space * @returns boolean */ -export function isReservedSpace(space) { +export function isReservedSpace(space: Space): boolean { return get(space, '_reserved', false); } diff --git a/x-pack/plugins/spaces/common/model/space.ts b/x-pack/plugins/spaces/common/model/space.ts new file mode 100644 index 00000000000000..15148231984fc3 --- /dev/null +++ b/x-pack/plugins/spaces/common/model/space.ts @@ -0,0 +1,14 @@ +/* + * 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. + */ + +export interface Space { + id: string; + name: string; + description?: string; + color?: string; + initials?: string; + _reserved?: boolean; +} diff --git a/x-pack/plugins/spaces/common/space_attributes.js b/x-pack/plugins/spaces/common/space_attributes.ts similarity index 69% rename from x-pack/plugins/spaces/common/space_attributes.js rename to x-pack/plugins/spaces/common/space_attributes.ts index 2b0cee34023a3c..c73a4b5aca7aaf 100644 --- a/x-pack/plugins/spaces/common/space_attributes.js +++ b/x-pack/plugins/spaces/common/space_attributes.ts @@ -6,6 +6,10 @@ import { VISUALIZATION_COLORS } from '@elastic/eui'; import { MAX_SPACE_INITIALS } from './constants'; +import { Space } from './model/space'; + +// code point for lowercase "a" +const FALLBACK_CODE_POINT = 97; /** * Determines the color for the provided space. @@ -14,17 +18,16 @@ import { MAX_SPACE_INITIALS } from './constants'; * * @param {Space} space */ -export function getSpaceColor(space = {}) { - const { - color, - name = '', - } = space; +export function getSpaceColor(space: Partial = {}) { + const { color, name = '' } = space; if (color) { return color; } - return VISUALIZATION_COLORS[name.codePointAt(0) % VISUALIZATION_COLORS.length]; + const firstCodePoint = name.codePointAt(0) || FALLBACK_CODE_POINT; + + return VISUALIZATION_COLORS[firstCodePoint % VISUALIZATION_COLORS.length]; } /** @@ -34,17 +37,14 @@ export function getSpaceColor(space = {}) { * * @param {Space} space */ -export function getSpaceInitials(space = {}) { - const { - initials, - name = '' - } = space; +export function getSpaceInitials(space: Partial = {}) { + const { initials, name = '' } = space; if (initials) { return initials; } - const words = name.split(" "); + const words = name.split(' '); const numInitials = Math.min(MAX_SPACE_INITIALS, words.length); diff --git a/x-pack/plugins/spaces/public/lib/constants.ts b/x-pack/plugins/spaces/public/lib/constants.ts new file mode 100644 index 00000000000000..59ac4b3aa64c37 --- /dev/null +++ b/x-pack/plugins/spaces/public/lib/constants.ts @@ -0,0 +1,9 @@ +/* + * 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 chrome from 'ui/chrome'; + +export const MANAGE_SPACES_URL = chrome.addBasePath(`/app/kibana#/management/spaces/list`); diff --git a/x-pack/plugins/spaces/public/lib/spaces_manager.js b/x-pack/plugins/spaces/public/lib/spaces_manager.js deleted file mode 100644 index 7d0243dc038c44..00000000000000 --- a/x-pack/plugins/spaces/public/lib/spaces_manager.js +++ /dev/null @@ -1,66 +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 { toastNotifications } from 'ui/notify'; - -import { EventEmitter } from 'events'; - -export class SpacesManager extends EventEmitter { - constructor(httpAgent, chrome) { - super(); - this._httpAgent = httpAgent; - this._baseUrl = chrome.addBasePath(`/api/spaces/v1`); - } - - async getSpaces() { - return await this._httpAgent - .get(`${this._baseUrl}/spaces`) - .then(response => response.data); - } - - async getSpace(id) { - return await this._httpAgent - .get(`${this._baseUrl}/space/${id}`); - } - - async createSpace(space) { - return await this._httpAgent - .post(`${this._baseUrl}/space`, space); - } - - async updateSpace(space) { - return await this._httpAgent - .put(`${this._baseUrl}/space/${space.id}?overwrite=true`, space); - } - - async deleteSpace(space) { - return await this._httpAgent - .delete(`${this._baseUrl}/space/${space.id}`); - } - - async changeSelectedSpace(space) { - return await this._httpAgent - .post(`${this._baseUrl}/space/${space.id}/select`) - .then(response => { - if (response.data && response.data.location) { - window.location = response.data.location; - } else { - this._displayError(); - } - }) - .catch(() => this._displayError()); - } - - async requestRefresh() { - this.emit('request_refresh'); - } - - _displayError() { - toastNotifications.addDanger({ - title: 'Unable to change your Space', - text: 'please try again later' - }); - } -} diff --git a/x-pack/plugins/spaces/public/lib/spaces_manager.ts b/x-pack/plugins/spaces/public/lib/spaces_manager.ts new file mode 100644 index 00000000000000..a6b21f2fa5229f --- /dev/null +++ b/x-pack/plugins/spaces/public/lib/spaces_manager.ts @@ -0,0 +1,67 @@ +/* + * 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 { toastNotifications } from 'ui/notify'; + +import { IHttpResponse } from 'angular'; +import { EventEmitter } from 'events'; +import { Space } from '../../common/model/space'; + +export class SpacesManager extends EventEmitter { + private httpAgent: any; + private baseUrl: any; + + constructor(httpAgent: any, chrome: any) { + super(); + this.httpAgent = httpAgent; + this.baseUrl = chrome.addBasePath(`/api/spaces/v1`); + } + + public async getSpaces(): Promise { + return await this.httpAgent + .get(`${this.baseUrl}/spaces`) + .then((response: IHttpResponse) => response.data); + } + + public async getSpace(id: string): Promise { + return await this.httpAgent.get(`${this.baseUrl}/space/${id}`); + } + + public async createSpace(space: Space) { + return await this.httpAgent.post(`${this.baseUrl}/space`, space); + } + + public async updateSpace(space: Space) { + return await this.httpAgent.put(`${this.baseUrl}/space/${space.id}?overwrite=true`, space); + } + + public async deleteSpace(space: Space) { + return await this.httpAgent.delete(`${this.baseUrl}/space/${space.id}`); + } + + public async changeSelectedSpace(space: Space) { + return await this.httpAgent + .post(`${this.baseUrl}/space/${space.id}/select`) + .then((response: IHttpResponse) => { + if (response.data && response.data.location) { + window.location = response.data.location; + } else { + this._displayError(); + } + }) + .catch(() => this._displayError()); + } + + public async requestRefresh() { + this.emit('request_refresh'); + } + + public _displayError() { + toastNotifications.addDanger({ + title: 'Unable to change your Space', + text: 'please try again later', + }); + } +} diff --git a/x-pack/plugins/spaces/public/views/components/index.js b/x-pack/plugins/spaces/public/views/components/index.ts similarity index 73% rename from x-pack/plugins/spaces/public/views/components/index.js rename to x-pack/plugins/spaces/public/views/components/index.ts index 16d03e0d129bc1..cb41cc6e31e0d8 100644 --- a/x-pack/plugins/spaces/public/views/components/index.js +++ b/x-pack/plugins/spaces/public/views/components/index.ts @@ -5,4 +5,5 @@ */ export { SpaceAvatar } from './space_avatar'; -export { SpaceCards } from './space_cards'; \ No newline at end of file +export { SpaceCards } from './space_cards'; +export { ManageSpacesButton } from './manage_spaces_button'; diff --git a/x-pack/plugins/spaces/public/views/components/manage_spaces_button.tsx b/x-pack/plugins/spaces/public/views/components/manage_spaces_button.tsx new file mode 100644 index 00000000000000..4f84b573b1da01 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/components/manage_spaces_button.tsx @@ -0,0 +1,35 @@ +/* + * 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 { EuiButton } from '@elastic/eui'; +import React, { Component } from 'react'; +import { MANAGE_SPACES_URL } from '../../lib/constants'; + +interface Props { + isDisabled?: boolean; + size?: 's' | 'l'; + style?: CSSProperties; +} + +export class ManageSpacesButton extends Component { + public render() { + return ( + + Manage Spaces + + ); + } + + private navigateToManageSpaces = () => { + window.location.replace(MANAGE_SPACES_URL); + }; +} diff --git a/x-pack/plugins/spaces/public/views/components/space_avatar.js b/x-pack/plugins/spaces/public/views/components/space_avatar.tsx similarity index 59% rename from x-pack/plugins/spaces/public/views/components/space_avatar.js rename to x-pack/plugins/spaces/public/views/components/space_avatar.tsx index b7908d00f2dde8..375c13f51ffe32 100644 --- a/x-pack/plugins/spaces/public/views/components/space_avatar.js +++ b/x-pack/plugins/spaces/public/views/components/space_avatar.tsx @@ -4,19 +4,25 @@ * you may not use this file except in compliance with the Elastic License. */ +import { EuiAvatar } from '@elastic/eui'; import React from 'react'; -import PropTypes from 'prop-types'; -import { - EuiAvatar -} from '@elastic/eui'; -import { MAX_SPACE_INITIALS, getSpaceInitials, getSpaceColor } from '../../../common'; +import { getSpaceColor, getSpaceInitials, MAX_SPACE_INITIALS } from '../../../common'; +import { Space } from '../../../common/model/space'; + +interface Props { + space: Space; + size?: string; + className?: string; +} + +export const SpaceAvatar = (props: Props) => { + const { space, size, ...rest } = props; -export const SpaceAvatar = ({ space, size, ...rest }) => { return ( { /> ); }; - -SpaceAvatar.propTypes = { - space: PropTypes.object.isRequired, - size: PropTypes.string, -}; diff --git a/x-pack/plugins/spaces/public/views/nav_control/__snapshots__/nav_control_popover.test.js.snap b/x-pack/plugins/spaces/public/views/nav_control/__snapshots__/nav_control_popover.test.js.snap new file mode 100644 index 00000000000000..a1092de10c954c --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/__snapshots__/nav_control_popover.test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NavControlPopover renders without crashing 1`] = `""`; diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/__snapshots__/spaces_description.test.tsx.snap b/x-pack/plugins/spaces/public/views/nav_control/components/__snapshots__/spaces_description.test.tsx.snap new file mode 100644 index 00000000000000..5f439351549c1d --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/components/__snapshots__/spaces_description.test.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SpacesDescription renders without crashing 1`] = ` + + +

+ Use Spaces within Kibana to organize your Dashboards, Visualizations, and other saved objects. +

+
+
+ +
+
+`; diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.less b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.less new file mode 100644 index 00000000000000..ceff24115bcf34 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.less @@ -0,0 +1,8 @@ +.spacesDescription { + max-width: 300px; +} + +.spacesDescription__text, +.spacesDescription__manageButtonWrapper { + padding: 12px; +} diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.test.tsx b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.test.tsx new file mode 100644 index 00000000000000..0308b8b5a4d210 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.test.tsx @@ -0,0 +1,15 @@ +/* + * 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 { shallow } from 'enzyme'; +import React from 'react'; +import { SpacesDescription } from './spaces_description'; + +describe('SpacesDescription', () => { + it('renders without crashing', () => { + expect(shallow()).toMatchSnapshot(); + }); +}); diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.tsx b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.tsx new file mode 100644 index 00000000000000..4f700113437dcc --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_description.tsx @@ -0,0 +1,31 @@ +/* + * 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 { EuiContextMenuPanel, EuiText } from '@elastic/eui'; +import React, { SFC } from 'react'; +import { ManageSpacesButton } from '../../components'; +import './spaces_description.less'; + +export const SpacesDescription: SFC = () => { + const panelProps = { + className: 'spacesDescription', + title: 'Spaces', + }; + + return ( + + +

+ Use Spaces within Kibana to organize your Dashboards, Visualizations, and other saved + objects. +

+
+
+ +
+
+ ); +}; diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.less b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.less new file mode 100644 index 00000000000000..4ae51c954b00ac --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.less @@ -0,0 +1,9 @@ +.spacesMenu__spacesList { + max-height: 320px; + overflow-y: auto; +} + +.spacesMenu__searchFieldWrapper, +.spacesMenu__manageButtonWrapper { + padding: 12px; +} diff --git a/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.tsx b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.tsx new file mode 100644 index 00000000000000..829820e3290ad9 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/components/spaces_menu.tsx @@ -0,0 +1,159 @@ +/* + * 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 { EuiContextMenuItem, EuiContextMenuPanel, EuiFieldSearch, EuiText } from '@elastic/eui'; +import React, { Component } from 'react'; +import { SPACE_SEARCH_COUNT_THRESHOLD } from '../../../../common/constants'; +import { Space } from '../../../../common/model/space'; +import { ManageSpacesButton, SpaceAvatar } from '../../components'; +import './spaces_menu.less'; + +interface Props { + spaces: Space[]; + onSelectSpace: (space: Space) => void; +} + +interface State { + searchTerm: string; + allowSpacesListFocus: boolean; +} + +export class SpacesMenu extends Component { + public state = { + searchTerm: '', + allowSpacesListFocus: false, + }; + + public render() { + const { searchTerm } = this.state; + + const items = this.getVisibleSpaces(searchTerm).map(this.renderSpaceMenuItem); + + const panelProps = { + className: 'spacesMenu', + title: 'Change current space', + watchedItemProps: ['data-search-term'], + }; + + if (this.props.spaces.length >= SPACE_SEARCH_COUNT_THRESHOLD) { + return ( + + {this.renderSearchField()} + {this.renderSpacesListPanel(items, searchTerm)} + {this.renderManageButton()} + + ); + } + + items.push(this.renderManageButton()); + + return ; + } + + private getVisibleSpaces = (searchTerm: string): Space[] => { + const { spaces } = this.props; + + let filteredSpaces = spaces; + if (searchTerm) { + filteredSpaces = spaces.filter(space => { + const { name, description = '' } = space; + return ( + name.toLowerCase().indexOf(searchTerm) >= 0 || + description.toLowerCase().indexOf(searchTerm) >= 0 + ); + }); + } + + return filteredSpaces; + }; + + private renderSpacesListPanel = (items: JSX.Element[], searchTerm: string) => { + if (items.length === 0) { + return ( + + {' '} + no spaces found{' '} + + ); + } + + return ( + + ); + }; + + private renderSearchField = () => { + return ( +
+ +
+ ); + }; + + private onSearchKeyDown = (e: any) => { + // 9: tab + // 13: enter + // 40: arrow-down + const focusableKeyCodes = [9, 13, 40]; + + const keyCode = e.keyCode; + if (focusableKeyCodes.includes(keyCode)) { + // Allows the spaces list panel to recieve focus. This enables keyboard and screen reader navigation + this.setState({ + allowSpacesListFocus: true, + }); + } + }; + + private onSearchFocus = () => { + this.setState({ + allowSpacesListFocus: false, + }); + }; + + private renderManageButton = () => { + return ( +
+ +
+ ); + }; + + private onSearch = (searchTerm: string) => { + this.setState({ + searchTerm: searchTerm.trim().toLowerCase(), + }); + }; + + private renderSpaceMenuItem = (space: Space): JSX.Element => { + const icon = ; + return ( + + {space.name} + + ); + }; +} diff --git a/x-pack/plugins/spaces/public/views/nav_control/index.js b/x-pack/plugins/spaces/public/views/nav_control/index.ts similarity index 100% rename from x-pack/plugins/spaces/public/views/nav_control/index.js rename to x-pack/plugins/spaces/public/views/nav_control/index.ts diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control.js b/x-pack/plugins/spaces/public/views/nav_control/nav_control.js index 4011fc47fc7dc6..9dfcaf8ede84a9 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/nav_control.js +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control.js @@ -13,7 +13,7 @@ import 'plugins/spaces/views/nav_control/nav_control.less'; import React from 'react'; import { render, unmountComponentAtNode } from 'react-dom'; -import { NavControlModal } from 'plugins/spaces/views/nav_control/nav_control_modal'; +import { NavControlPopover } from 'plugins/spaces/views/nav_control/nav_control_popover'; chromeNavControlsRegistry.register(constant({ name: 'spaces', @@ -30,7 +30,7 @@ module.controller('spacesNavController', ($scope, $http, chrome, activeSpace) => spacesManager = new SpacesManager($http, chrome); - render(, domNode); + render(, domNode); // unmount react on controller destroy $scope.$on('$destroy', () => { diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control.less b/x-pack/plugins/spaces/public/views/nav_control/nav_control.less index 19f56c5ea10776..6feccde1080a2b 100644 --- a/x-pack/plugins/spaces/public/views/nav_control/nav_control.less +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control.less @@ -1,8 +1,3 @@ .global-nav-link__icon .spaceNavGraphic { margin-top: 0.5em; } - -.selectSpaceModal { - min-width: 450px; - max-width: 1200px; -} diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js b/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js deleted file mode 100644 index 6ef9b338645ecc..00000000000000 --- a/x-pack/plugins/spaces/public/views/nav_control/nav_control_modal.js +++ /dev/null @@ -1,189 +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 React, { Component, Fragment } from 'react'; -import PropTypes from 'prop-types'; -import { - EuiCallOut, - EuiText, - EuiModal, - EuiModalHeader, - EuiModalHeaderTitle, - EuiModalBody, - EuiOverlayMask, - EuiAvatar, - EuiSpacer, -} from '@elastic/eui'; -import { SpaceCards, SpaceAvatar } from '../components'; -import { Notifier } from 'ui/notify'; - -export class NavControlModal extends Component { - state = { - isOpen: false, - loading: false, - activeSpaceExists: true, - spaces: [] - }; - - notifier = new Notifier(`Spaces`); - - async loadSpaces() { - const { - spacesManager, - activeSpace, - } = this.props; - - this.setState({ - loading: true - }); - - const spaces = await spacesManager.getSpaces(); - - let activeSpaceExists = this.state.activeSpaceExists; - if (activeSpace.valid) { - activeSpaceExists = !!spaces.find(space => space.id === this.props.activeSpace.space.id); - } - - this.setState({ - spaces, - activeSpaceExists, - isOpen: this.state.isOpen || !activeSpaceExists, - loading: false - }); - } - - componentDidMount() { - const { - activeSpace - } = this.props; - - if (activeSpace && !activeSpace.valid) { - const { error = {} } = activeSpace; - if (error.message) { - this.notifier.error(error.message); - } - } - - this.loadSpaces(); - - if (this.props.spacesManager) { - this.props.spacesManager.on('request_refresh', () => { - this.loadSpaces(); - }); - } - } - - render() { - let modal; - if (this.state.isOpen) { - modal = ( - - {this.getActivePortal()} - - ); - } - - return ( -
{this.getActiveSpaceButton()}{modal}
- ); - } - - getActiveSpaceButton = () => { - const { - activeSpace - } = this.props; - - if (!activeSpace) { - return null; - } - - // 0 or 1 spaces are available. Either either way, there is no need to render a space selection button - if (this.state.spaces.length < 2) { - return null; - } - - if (activeSpace.valid && activeSpace.space) { - return this.getButton( - , - activeSpace.space.name - ); - } else if (activeSpace.error) { - return this.getButton( - , - 'error' - ); - } - - return null; - }; - - getButton = (linkIcon, linkTitle) => { - return ( - - ); - }; - - getActivePortal = () => { - let callout; - - if (!this.state.activeSpaceExists) { - callout = ( - - - - Please choose a new Space to continue using Kibana - - - - - ); - - } - - return ( - - - Select a space - - - {callout} - - - - ); - } - - togglePortal = () => { - const isOpening = !this.state.isOpen; - if (isOpening) { - this.loadSpaces(); - } - - this.setState({ - isOpen: !this.state.isOpen - }); - }; - - closePortal = () => { - this.setState({ - isOpen: false - }); - } - - onSelectSpace = (space) => { - this.props.spacesManager.changeSelectedSpace(space); - } -} - -NavControlModal.propTypes = { - activeSpace: PropTypes.object, - spacesManager: PropTypes.object.isRequired -}; diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.test.js b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.test.js new file mode 100644 index 00000000000000..09dd22d0b1ac47 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.test.js @@ -0,0 +1,69 @@ +/* + * 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 React from 'react'; +import { shallow, mount } from 'enzyme'; +import { NavControlPopover } from './nav_control_popover'; +import { SpacesManager } from '../../lib/spaces_manager'; +import { SpaceAvatar } from '../components/space_avatar'; + +const mockChrome = { + addBasePath: jest.fn((a) => a) +}; + +const createMockHttpAgent = (withSpaces = false) => { + + const mockHttpAgent = { + get: async () => { + const result = withSpaces ? [{ + name: 'space 1' + }, { + name: 'space 2' + }] : []; + + return { + data: result + }; + } + }; + return mockHttpAgent; +}; + +describe('NavControlPopover', () => { + it('renders without crashing', () => { + const activeSpace = { + space: { name: 'foo' }, + valid: true + }; + + const spacesManager = new SpacesManager(createMockHttpAgent(), mockChrome); + + const wrapper = shallow(); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders a SpaceAvatar with the active space', async () => { + const activeSpace = { + space: { name: 'foo' }, + valid: true + }; + + const mockAgent = createMockHttpAgent(true); + + const spacesManager = new SpacesManager(mockAgent, mockChrome); + + const wrapper = mount(); + + return new Promise((resolve) => { + setTimeout(() => { + expect(wrapper.state().spaces).toHaveLength(2); + wrapper.update(); + expect(wrapper.find(SpaceAvatar)).toHaveLength(1); + resolve(); + }, 20); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.tsx b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.tsx new file mode 100644 index 00000000000000..1345d3013000ea --- /dev/null +++ b/x-pack/plugins/spaces/public/views/nav_control/nav_control_popover.tsx @@ -0,0 +1,153 @@ +/* + * 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 { EuiAvatar, EuiPopover } from '@elastic/eui'; +import React, { Component } from 'react'; +import { Space } from '../../../common/model/space'; +import { SpacesManager } from '../../lib/spaces_manager'; +import { SpaceAvatar } from '../components'; +import { SpacesDescription } from './components/spaces_description'; +import { SpacesMenu } from './components/spaces_menu'; + +interface Props { + spacesManager: SpacesManager; + activeSpace: { + valid: boolean; + error: string; + space: Space; + }; +} + +interface State { + isOpen: boolean; + loading: boolean; + activeSpaceExists: boolean; + spaces: Space[]; +} + +export class NavControlPopover extends Component { + public state = { + isOpen: false, + loading: false, + activeSpaceExists: true, + spaces: [], + }; + + public componentDidMount() { + this.loadSpaces(); + + if (this.props.spacesManager) { + this.props.spacesManager.on('request_refresh', () => { + this.loadSpaces(); + }); + } + } + + public render() { + const button = this.getActiveSpaceButton(); + if (!button) { + return null; + } + + let element: React.ReactNode; + if (this.state.spaces.length < 2) { + element = ; + } else { + element = ; + } + + return ( + + {element} + + ); + } + + private async loadSpaces() { + const { spacesManager, activeSpace } = this.props; + + this.setState({ + loading: true, + }); + + const spaces = await spacesManager.getSpaces(); + + let activeSpaceExists = this.state.activeSpaceExists; + if (activeSpace.valid) { + activeSpaceExists = !!spaces.find(space => space.id === this.props.activeSpace.space.id); + } + + this.setState({ + spaces, + activeSpaceExists, + isOpen: this.state.isOpen || !activeSpaceExists, + loading: false, + }); + } + + private getActiveSpaceButton = () => { + const { activeSpace } = this.props; + + if (!activeSpace) { + return null; + } + + if (activeSpace.valid && activeSpace.space) { + return this.getButton( + , + activeSpace.space.name + ); + } else if (activeSpace.error) { + return this.getButton( + , + 'error' + ); + } + + return null; + }; + + private getButton = (linkIcon: JSX.Element, linkTitle: string) => { + // Mimics the current angular-based navigation link + return ( + + ); + }; + + private togglePortal = () => { + const isOpening = !this.state.isOpen; + if (isOpening) { + this.loadSpaces(); + } + + this.setState({ + isOpen: !this.state.isOpen, + }); + }; + + private closePortal = () => { + this.setState({ + isOpen: false, + }); + }; + + private onSelectSpace = (space: Space) => { + this.props.spacesManager.changeSelectedSpace(space); + }; +} diff --git a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js index 740b444e65e695..ad0bab30ccc90f 100644 --- a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js +++ b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js @@ -32,7 +32,8 @@ export function initSpacesApi(server) { try { const result = await client.find({ - type: 'space' + type: 'space', + sortField: 'name.keyword', }); spaces = result.saved_objects.map(convertSavedObjectToSpace);