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]