diff --git a/src/server/config/complete.js b/src/server/config/complete.js index 3a9da4911c2194..70acb2edf043f9 100644 --- a/src/server/config/complete.js +++ b/src/server/config/complete.js @@ -33,10 +33,6 @@ export default function (kbnServer, server, config) { return kbnServer.config; }); - server.decorate('request', 'getBasePath', function () { - return kbnServer.config.get('server.basePath'); - }); - const unusedKeys = getUnusedConfigKeys(kbnServer.disabledPluginSpecs, kbnServer.settings, config.get()) .map(key => `"${key}"`); diff --git a/src/server/config/complete.test.js b/src/server/config/complete.test.js index 2db0b1641dd19a..4ea53b761cb07f 100644 --- a/src/server/config/complete.test.js +++ b/src/server/config/complete.test.js @@ -49,7 +49,6 @@ describe('server/config completeMixin()', function () { callCompleteMixin(); sinon.assert.callCount(server.decorate, 2); sinon.assert.calledWithExactly(server.decorate, 'server', 'config', sinon.match.func); - sinon.assert.calledWithExactly(server.decorate, 'request', 'getBasePath', sinon.match.func); }); }); diff --git a/src/server/http/index.js b/src/server/http/index.js index d42e2bcc031d8d..af43411595c554 100644 --- a/src/server/http/index.js +++ b/src/server/http/index.js @@ -11,6 +11,7 @@ import { setupConnection } from './setup_connection'; import { setupRedirectServer } from './setup_redirect_server'; import { registerHapiPlugins } from './register_hapi_plugins'; import { setupBasePathRewrite } from './setup_base_path_rewrite'; +import { setupBasePathProvider } from './setup_base_path_provider'; import { setupXsrf } from './xsrf'; export default async function (kbnServer, server, config) { @@ -20,6 +21,7 @@ export default async function (kbnServer, server, config) { setupConnection(server, config); setupBasePathRewrite(server, config); + setupBasePathProvider(server, config); await setupRedirectServer(config); registerHapiPlugins(server); diff --git a/src/server/http/setup_base_path_provider.js b/src/server/http/setup_base_path_provider.js new file mode 100644 index 00000000000000..a1655d0124dbc4 --- /dev/null +++ b/src/server/http/setup_base_path_provider.js @@ -0,0 +1,19 @@ +export function setupBasePathProvider(server, config) { + + server.decorate('request', 'setBasePath', function (basePath) { + const request = this; + if (request.app._basePath) { + throw new Error(`Request basePath was previously set. Setting multiple times is not supported.`); + } + request.app._basePath = basePath; + }); + + server.decorate('request', 'getBasePath', function () { + const request = this; + + const serverBasePath = config.get('server.basePath'); + const requestBasePath = request.app._basePath || ''; + + return `${serverBasePath}${requestBasePath}`; + }); +} diff --git a/x-pack/plugins/spaces/index.js b/x-pack/plugins/spaces/index.js index 71bc7da340c563..5780998378cf84 100644 --- a/x-pack/plugins/spaces/index.js +++ b/x-pack/plugins/spaces/index.js @@ -8,6 +8,7 @@ import { resolve } from 'path'; import { validateConfig } from './server/lib/validate_config'; import { checkLicense } from './server/lib/check_license'; import { initSpacesApi } from './server/routes/api/v1/spaces'; +import { initSpacesRequestInterceptors } from './server/lib/space_request_interceptors'; import { mirrorPluginStatus } from '../../server/lib/mirror_plugin_status'; import mappings from './mappings.json'; @@ -36,7 +37,9 @@ export const spaces = (kibana) => new kibana.Plugin({ mappings, home: ['plugins/spaces/register_feature'], injectDefaultVars: function () { - return { }; + return { + spaces: [] + }; } }, @@ -53,5 +56,7 @@ export const spaces = (kibana) => new kibana.Plugin({ validateConfig(config, message => server.log(['spaces', 'warning'], message)); initSpacesApi(server); + + initSpacesRequestInterceptors(server); } }); diff --git a/x-pack/plugins/spaces/mappings.json b/x-pack/plugins/spaces/mappings.json index 835d2c299e0edc..2a26218cd48fb2 100644 --- a/x-pack/plugins/spaces/mappings.json +++ b/x-pack/plugins/spaces/mappings.json @@ -1,9 +1,15 @@ { + "spaceId": { + "type": "keyword" + }, "space": { "properties": { "name": { "type": "text" }, + "urlContext": { + "type": "keyword" + }, "description": { "type": "text" } diff --git a/x-pack/plugins/spaces/public/views/management/components/__snapshots__/page_header.test.js.snap b/x-pack/plugins/spaces/public/views/management/components/__snapshots__/page_header.test.js.snap new file mode 100644 index 00000000000000..936f678055d413 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/__snapshots__/page_header.test.js.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`it renders without crashing 1`] = ` +
+
+
+
+
+`; diff --git a/x-pack/plugins/spaces/public/views/management/components/manage_space_page.js b/x-pack/plugins/spaces/public/views/management/components/manage_space_page.js index 7e04213f6d4667..b7cc6e887e84d3 100644 --- a/x-pack/plugins/spaces/public/views/management/components/manage_space_page.js +++ b/x-pack/plugins/spaces/public/views/management/components/manage_space_page.js @@ -23,6 +23,8 @@ import { PageHeader } from './page_header'; import { DeleteSpacesButton } from './delete_spaces_button'; import { Notifier, toastNotifications } from 'ui/notify'; +import { UrlContext } from './url_context'; +import { toUrlContext, isValidUrlContext } from '../lib/url_context_utils'; export class ManageSpacePage extends React.Component { state = { @@ -105,6 +107,13 @@ export class ManageSpacePage extends React.Component { /> + + Save @@ -146,10 +155,21 @@ export class ManageSpacePage extends React.Component { }; onNameChange = (e) => { + const canUpdateContext = !this.editingExistingSpace(); + + let { + urlContext + } = this.state.space; + + if (canUpdateContext) { + urlContext = toUrlContext(e.target.value); + } + this.setState({ space: { ...this.state.space, - name: e.target.value + name: e.target.value, + urlContext } }); }; @@ -163,6 +183,15 @@ export class ManageSpacePage extends React.Component { }); }; + onUrlContextChange = (e) => { + this.setState({ + space: { + ...this.state.space, + urlContext: toUrlContext(e.target.value) + } + }); + }; + saveSpace = () => { this.setState({ validate: true @@ -176,8 +205,9 @@ export class ManageSpacePage extends React.Component { _performSave = () => { const { name = '', - id = name.toLowerCase().replace(/\s/g, '-'), - description + id = toUrlContext(name), + description, + urlContext } = this.state.space; const { httpAgent, chrome } = this.props; @@ -185,7 +215,8 @@ export class ManageSpacePage extends React.Component { const params = { name, id, - description + description, + urlContext }; const overwrite = this.editingExistingSpace(); @@ -249,12 +280,39 @@ export class ManageSpacePage extends React.Component { return {}; }; + validateUrlContext = () => { + if (!this.state.validate) { + return {}; + } + + const { + urlContext + } = this.state.space; + + if (!urlContext) { + return { + isInvalid: true, + error: 'URL Context is required' + }; + } + + if (!isValidUrlContext(urlContext)) { + return { + isInvalid: true, + error: 'URL Context only allows a-z, 0-9, and the "-" character' + }; + } + + return {}; + + }; + validateForm = () => { if (!this.state.validate) { return {}; } - const validations = [this.validateName(), this.validateDescription()]; + const validations = [this.validateName(), this.validateDescription(), this.validateUrlContext()]; if (validations.some(validation => validation.isInvalid)) { return { isInvalid: true diff --git a/x-pack/plugins/spaces/public/views/management/components/page_header.test.js b/x-pack/plugins/spaces/public/views/management/components/page_header.test.js new file mode 100644 index 00000000000000..b960175b38debd --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/page_header.test.js @@ -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 React from 'react'; +import { PageHeader } from './page_header'; +import { render } from 'enzyme'; +import renderer from 'react-test-renderer'; + +test('it renders without crashing', () => { + const component = renderer.create( + + ); + expect(component).toMatchSnapshot(); +}); + +test('it renders breadcrumbs', () => { + const component = render( + + ); + + expect(component.find('a')).toHaveLength(2); + +}); diff --git a/x-pack/plugins/spaces/public/views/management/components/spaces_grid_page.js b/x-pack/plugins/spaces/public/views/management/components/spaces_grid_page.js index 4bb49bcbe23e7e..c89ae778afbc5b 100644 --- a/x-pack/plugins/spaces/public/views/management/components/spaces_grid_page.js +++ b/x-pack/plugins/spaces/public/views/management/components/spaces_grid_page.js @@ -155,6 +155,10 @@ export class SpacesGridPage extends Component { field: 'description', name: 'Description', sortable: true + }, { + field: 'urlContext', + name: 'URL Context', + sortable: true }]; } diff --git a/x-pack/plugins/spaces/public/views/management/components/url_context.js b/x-pack/plugins/spaces/public/views/management/components/url_context.js new file mode 100644 index 00000000000000..418a51b29347e4 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/components/url_context.js @@ -0,0 +1,97 @@ +/* + * 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 { + EuiFormRow, + EuiLink, + EuiFieldText, + EuiCallOut, + EuiSpacer +} from '@elastic/eui'; + +export class UrlContext extends Component { + textFieldRef = null; + + state = { + editing: false + }; + + render() { + const { + urlContext = '' + } = this.props.space; + + return ( + + +
+ this.textFieldRef = ref} + /> + {this.getCallOut()} +
+
+
+ ); + } + + getLabel = () => { + const editLinkText = this.state.editing ? `[stop editing]` : `[edit]`; + return (

URL Context {editLinkText}

); + }; + + getHelpText = () => { + return (

Links within Kibana will include this space identifier

); + }; + + getCallOut = () => { + if (this.props.editingExistingSpace && this.state.editing) { + return ( + + + + + ); + } + + return null; + }; + + onEditClick = () => { + this.setState({ + editing: !this.state.editing + }, () => { + if (this.textFieldRef && this.state.editing) { + this.textFieldRef.focus(); + } + }); + }; + + onChange = (e) => { + if (!this.state.editing) return; + this.props.onChange(e); + }; +} + +UrlContext.propTypes = { + space: PropTypes.object.isRequired, + editable: PropTypes.bool.isRequired, + editingExistingSpace: PropTypes.bool.isRequired, + onChange: PropTypes.func +}; diff --git a/x-pack/plugins/spaces/public/views/management/lib/url_context_utils.js b/x-pack/plugins/spaces/public/views/management/lib/url_context_utils.js new file mode 100644 index 00000000000000..bf9fe48d4037ac --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/lib/url_context_utils.js @@ -0,0 +1,13 @@ +/* + * 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 function toUrlContext(value = '') { + return value.toLowerCase().replace(/[^a-z0-9]/g, '-'); +} + +export function isValidUrlContext(value = '') { + return value === toUrlContext(value); +} diff --git a/x-pack/plugins/spaces/public/views/management/lib/url_context_utils.test.js b/x-pack/plugins/spaces/public/views/management/lib/url_context_utils.test.js new file mode 100644 index 00000000000000..522436163271fb --- /dev/null +++ b/x-pack/plugins/spaces/public/views/management/lib/url_context_utils.test.js @@ -0,0 +1,24 @@ +/* + * 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 { toUrlContext } from './url_context_utils'; + +test('it converts whitespace to dashes', () => { + const input = `this is a test`; + expect(toUrlContext(input)).toEqual('this-is-a-test'); +}); + +test('it converts everything to lowercase', () => { + const input = `THIS IS A TEST`; + expect(toUrlContext(input)).toEqual('this-is-a-test'); +}); + +test('it converts non-alphanumeric characters to dashes', () => { + const input = `~!@#$%^&*()_+-=[]{}\|';:"/.,<>?` + "`"; + + const expectedResult = new Array(input.length + 1).join('-'); + + expect(toUrlContext(input)).toEqual(expectedResult); +}); diff --git a/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap b/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap new file mode 100644 index 00000000000000..cfb329dd6d7a16 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/space_selector/__snapshots__/space_selector.test.js.snap @@ -0,0 +1,77 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`it renders without crashing 1`] = ` +
+
+
+ + + + + + +
+
+
+
+
+

+ Welcome to Kibana. +

+

+ Select a space to begin. +

+
+
+
+

+ You can change your workspace at anytime by accessing your profile within Kibana. +

+
+
+
+
+`; diff --git a/x-pack/plugins/spaces/public/views/space_selector/index.js b/x-pack/plugins/spaces/public/views/space_selector/index.js index ee258cc542bd14..21efd3d762dda6 100644 --- a/x-pack/plugins/spaces/public/views/space_selector/index.js +++ b/x-pack/plugins/spaces/public/views/space_selector/index.js @@ -15,9 +15,9 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { SpaceSelector } from './space_selector'; const module = uiModules.get('spaces_selector', []); -module.controller('spacesSelectorController', ($scope, $http) => { +module.controller('spacesSelectorController', ($scope, $http, spaces) => { const domNode = document.getElementById('spaceSelectorRoot'); - render(, domNode); + render(, domNode); // unmount react on controller destroy $scope.$on('$destroy', () => { diff --git a/x-pack/plugins/spaces/public/views/space_selector/space_selector.js b/x-pack/plugins/spaces/public/views/space_selector/space_selector.js index 4a4a042c587f02..9137262bc0efd5 100644 --- a/x-pack/plugins/spaces/public/views/space_selector/space_selector.js +++ b/x-pack/plugins/spaces/public/views/space_selector/space_selector.js @@ -5,6 +5,7 @@ */ import React, { Component } from 'react'; +import PropTypes from 'prop-types'; import { EuiPage, EuiPageHeader, @@ -24,8 +25,17 @@ export class SpaceSelector extends Component { spaces: [] }; + constructor(props) { + super(props); + if (Array.isArray(props.spaces)) { + this.state.spaces = [...props.spaces]; + } + } + componentDidMount() { - this.loadSpaces(); + if (this.state.spaces.length === 0) { + this.loadSpaces(); + } } loadSpaces() { @@ -81,7 +91,7 @@ export class SpaceSelector extends Component { icon={} title={this.renderSpaceTitle(space)} description={space.description} - onClick={() => window.alert('Card clicked')} + onClick={this.createSpaceClickHandler(space)} /> ); @@ -96,4 +106,16 @@ export class SpaceSelector extends Component { ); }; + createSpaceClickHandler = (space) => { + return () => { + window.location = this.props.chrome.addBasePath(`/s/${space.urlContext}`); + }; + } + } + +SpaceSelector.propTypes = { + spaces: PropTypes.array, + httpAgent: PropTypes.func.isRequired, + chrome: PropTypes.object.isRequired, +}; diff --git a/x-pack/plugins/spaces/public/views/space_selector/space_selector.test.js b/x-pack/plugins/spaces/public/views/space_selector/space_selector.test.js new file mode 100644 index 00000000000000..e32f6045191299 --- /dev/null +++ b/x-pack/plugins/spaces/public/views/space_selector/space_selector.test.js @@ -0,0 +1,71 @@ +/* + * 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 { SpaceSelector } from './space_selector'; +import chrome from 'ui/chrome'; +import renderer from 'react-test-renderer'; +import { render, shallow } from 'enzyme'; + +function getHttpAgent(spaces = []) { + const httpAgent = () => {}; + httpAgent.get = jest.fn(() => Promise.resolve({ data: spaces })); + + return httpAgent; +} + + +test('it renders without crashing', () => { + const httpAgent = getHttpAgent(); + const component = renderer.create( + + ); + expect(component).toMatchSnapshot(); +}); + +test('it uses the spaces on props, when provided', () => { + const httpAgent = getHttpAgent(); + + const spaces = [{ + id: 'space-1', + name: 'Space 1', + description: 'This is the first space', + urlContext: 'space-1-context' + }]; + + const component = render( + + ); + + return Promise + .resolve() + .then(() => { + expect(component.find('.spaceCard')).toHaveLength(1); + expect(httpAgent.get).toHaveBeenCalledTimes(0); + }); +}); + +test('it queries for spaces when not provided on props', () => { + const spaces = [{ + id: 'space-1', + name: 'Space 1', + description: 'This is the first space', + urlContext: 'space-1-context' + }]; + + const httpAgent = getHttpAgent(spaces); + + const component = shallow( + + ); + + return Promise + .resolve() + .then(() => { + expect(httpAgent.get).toHaveBeenCalledTimes(1); + expect(component.update().find('.spaceCard')).toHaveLength(1); + }); +}); diff --git a/x-pack/plugins/spaces/server/lib/space_request_interceptors.js b/x-pack/plugins/spaces/server/lib/space_request_interceptors.js new file mode 100644 index 00000000000000..c7d8f71d713132 --- /dev/null +++ b/x-pack/plugins/spaces/server/lib/space_request_interceptors.js @@ -0,0 +1,73 @@ +/* + * 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 './errors'; + +export function initSpacesRequestInterceptors(server) { + const contextCache = new WeakMap(); + + server.ext('onRequest', async function spacesOnRequestHandler(request, reply) { + const path = request.path; + + // If navigating within the context of a space, then we store the Space's URL Context on the request, + // and rewrite the request to not include the space identifier in the URL. + + if (path.startsWith('/s/')) { + const pathParts = path.split('/'); + + const spaceUrlContext = pathParts[2]; + + const reqBasePath = `/s/${spaceUrlContext}`; + request.setBasePath(reqBasePath); + + const newUrl = { + ...request.url, + path: path.substr(reqBasePath.length) || '/', + pathname: path.substr(reqBasePath.length) || '/', + href: path.substr(reqBasePath.length) || '/' + }; + + request.setUrl(newUrl); + contextCache.set(request, spaceUrlContext); + } + + return reply.continue(); + }); + + server.ext('onPostAuth', async function spacesOnRequestHandler(request, reply) { + const path = request.path; + + const isRequestingKibanaRoot = path === '/'; + const urlContext = contextCache.get(request); + + // if requesting the application root, then show the Space Selector UI to allow the user to choose which space + // they wish to visit. This is done "onPostAuth" to allow the Saved Objects Client to use the request's auth scope, + // which is not available at the time of "onRequest". + if (isRequestingKibanaRoot && !urlContext) { + try { + const client = request.getSavedObjectsClient(); + const { total, saved_objects: spaceObjects } = await client.find({ + type: 'space' + }); + + if (total > 0) { + // render spaces selector instead of home page + const app = server.getHiddenUiAppById('space_selector'); + return reply.renderApp(app, { + spaces: spaceObjects.map(so => ({ ...so.attributes, id: so.id })) + }); + } + + } catch(e) { + return reply(wrapError(e)); + } + } + + return reply.continue(); + }); + + +} diff --git a/x-pack/plugins/spaces/server/lib/space_schema.js b/x-pack/plugins/spaces/server/lib/space_schema.js index 4ed8d965fe0400..d4f7af6f4c37f8 100644 --- a/x-pack/plugins/spaces/server/lib/space_schema.js +++ b/x-pack/plugins/spaces/server/lib/space_schema.js @@ -6,9 +6,9 @@ import Joi from 'joi'; -export const spaceSchema = { +export const spaceSchema = Joi.object({ id: Joi.string(), name: Joi.string().required(), description: Joi.string().required(), - metadata: Joi.object(), -}; \ No newline at end of file + urlContext: Joi.string().regex(/[a-z0-9\-]+/, `lower case, a-z, 0-9, and "-" are allowed`).required() +}).default(); 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 003e4e7f55cb2d..647cebe8dfce56 100644 --- a/x-pack/plugins/spaces/server/routes/api/v1/spaces.js +++ b/x-pack/plugins/spaces/server/routes/api/v1/spaces.js @@ -44,21 +44,23 @@ export function initSpacesApi(server) { method: 'GET', path: '/api/spaces/v1/spaces/{id}', async handler(request, reply) { - const id = request.params.id; + const spaceId = request.params.id; const client = request.getSavedObjectsClient(); - let space; try { - space = await client.get('space', id); + const { + id, + attributes + } = await client.get('space', spaceId); + + return reply({ + id, + ...attributes + }); } catch (e) { return reply(wrapError(e)); } - - return reply({ - ...space.attributes, - id: space.id - }); }, config: { pre: [routePreCheckLicenseFn]