Merge branch 'master' of github.com:elastic/kibana into implement/testSubjectHelper

This commit is contained in:
spalger 2015-09-23 11:48:02 -07:00
commit 6f7b0377db
38 changed files with 1043 additions and 169 deletions

View file

@ -19,9 +19,14 @@ You need at least one saved <<visualize, visualization>> to use a dashboard.
The first time you click the *Dashboard* tab, Kibana displays an empty dashboard.
image:images/NewDashboard.jpg[New Dashboard screen]
image:images/NewDashboard.png[New Dashboard screen]
Build your dashboard by adding visualizations.
Build your dashboard by adding visualizations. By default, Kibana dashboards use a light color theme. To use a dark color
theme instead, click the *Settings* image:images/SettingsButton.jpg[Gear] button and check the *Use dark theme* box.
image:images/darktheme.png[Dark Theme Example]
NOTE: You can change the default theme in the *Advanced* section of the *Settings* tab.
[float]
[[dash-autorefresh]]

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
docs/images/darktheme.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

View file

@ -100,7 +100,7 @@ class BaseOptimizer {
test: /\.less$/,
loader: ExtractTextPlugin.extract(
'style',
`css${mapQ}!autoprefixer?{ "browsers": ["last 2 versions","> 5%"] }!less${mapQ}`
`css${mapQ}!autoprefixer${mapQ ? mapQ + '&' : '?'}{ "browsers": ["last 2 versions","> 5%"] }!less${mapQ}`
)
},
{ test: /\.css$/, loader: ExtractTextPlugin.extract('style', `css${mapQ}`) },

View file

@ -1,12 +0,0 @@
module.exports = function (kibana) {
return new kibana.Plugin({
uiExports: {
app: {
id: 'appSwitcher',
main: 'plugins/appSwitcher/appSwitcher',
hidden: true,
autoload: kibana.autoload.styles
}
}
});
};

View file

@ -1,4 +0,0 @@
{
"name": "appSwitcher",
"version": "1.0.0"
}

View file

@ -1,14 +0,0 @@
<div ng-if="switcher.loading">
<div class="spinner large"></div>
</div>
<div ng-if="!switcher.loading" class="app-links">
<div class="app-link" ng-repeat="app in switcher.apps | orderBy:'title'">
<a ng-href="/app/{{app.id}}">
<div ng-if="app.icon" ng-style="{ 'background-image': 'url(../' + app.icon + ')' }" class="app-icon"></div>
<div ng-if="!app.icon" class="app-icon app-icon-missing">{{app.title[0]}}</div>
<div class="app-title">{{ app.title }}</div>
</a>
</div>
</div>

View file

@ -81,11 +81,16 @@ define(function (require) {
var stateDefaults = {
title: dash.title,
panels: dash.panelsJSON ? JSON.parse(dash.panelsJSON) : [],
options: dash.optionsJSON ? JSON.parse(dash.optionsJSON) : {},
query: extractQueryFromFilters(dash.searchSource.getOwn('filter')) || {query_string: {query: '*'}},
filters: _.reject(dash.searchSource.getOwn('filter'), matchQueryFilter)
filters: _.reject(dash.searchSource.getOwn('filter'), matchQueryFilter),
};
var $state = $scope.state = new AppState(stateDefaults);
$scope.$watchCollection('state.options', function (newVal, oldVal) {
if (!angular.equals(newVal, oldVal)) $state.save();
});
$scope.$watch('state.options.darkTheme', setDarkTheme);
$scope.configTemplate = new ConfigTemplate({
save: require('plugins/kibana/dashboard/partials/save_dashboard.html'),
@ -103,11 +108,6 @@ define(function (require) {
courier.setRootSearchSource(dash.searchSource);
setDarkTheme(dash.darkTheme);
$scope.$watch('dash.darkTheme', function (value) {
setDarkTheme(value);
});
function init() {
updateQueryOnRootSource();
@ -131,7 +131,7 @@ define(function (require) {
}
function setDarkTheme(enabled) {
var theme = !!enabled ? 'theme-dark' : 'theme-light';
var theme = Boolean(enabled) ? 'theme-dark' : 'theme-light';
chrome.removeApplicationClass(['theme-dark', 'theme-light']);
chrome.addApplicationClass(theme);
}
@ -161,6 +161,7 @@ define(function (require) {
dash.panelsJSON = angular.toJson($state.panels);
dash.timeFrom = dash.timeRestore ? timefilter.time.from : undefined;
dash.timeTo = dash.timeRestore ? timefilter.time.to : undefined;
dash.optionsJSON = angular.toJson($state.options);
dash.save()
.then(function (id) {
@ -204,6 +205,7 @@ define(function (require) {
// Setup configurable values for config directive, after objects are initialized
$scope.opts = {
dashboard: dash,
ui: $state.options,
save: $scope.save,
addVis: $scope.addVis,
addSearch: $scope.addSearch,

View file

@ -2,7 +2,7 @@
<p>
<div class="input-group">
<label>
<input type="checkbox" ng-model="opts.dashboard.darkTheme" ng-checked="opts.dashboard.darkTheme">
<input type="checkbox" ng-model="opts.ui.darkTheme" ng-checked="opts.ui.darkTheme">
Use dark theme
</label>
</div>

View file

@ -1,5 +1,6 @@
define(function (require) {
var module = require('ui/modules').get('app/dashboard');
var angular = require('angular');
var _ = require('lodash');
var moment = require('moment');
@ -24,11 +25,13 @@ define(function (require) {
hits: 0,
description: '',
panelsJSON: '[]',
optionsJSON: angular.toJson({
darkTheme: config.get('dashboard:defaultDarkTheme')
}),
version: 1,
timeRestore: false,
timeTo: undefined,
timeFrom: undefined,
darkTheme: config.get('dashboard:defaultDarkTheme')
},
// if an indexPattern was saved with the searchsource of a SavedDashboard
@ -46,11 +49,11 @@ define(function (require) {
hits: 'integer',
description: 'string',
panelsJSON: 'string',
optionsJSON: 'string',
version: 'integer',
timeRestore: 'boolean',
timeTo: 'string',
timeFrom: 'string',
darkTheme: 'boolean'
};
SavedDashboard.searchsource = true;

View file

@ -23,7 +23,7 @@ chrome
.setNavBackground('#222222')
.setTabDefaults({
resetWhenActive: true,
trackLastPath: true,
lastUrlStore: window.sessionStore,
activeIndicatorColor: '#656a76'
})
.setTabs([

View file

@ -5,6 +5,7 @@ module.exports = function (kibana) {
title: 'Server Status',
main: 'plugins/statusPage/statusPage',
hidden: true,
url: '/status',
autoload: [].concat(
kibana.autoload.styles,
@ -15,4 +16,3 @@ module.exports = function (kibana) {
}
});
};

View file

@ -19,6 +19,7 @@ class UiApp {
this.hidden = this.spec.hidden;
this.autoloadOverrides = this.spec.autoload;
this.templateName = this.spec.templateName || 'uiApp';
this.url = this.spec.url || '/app/' + this.id;
// once this resolves, no reason to run it again
this.getModules = _.once(this.getModules);
@ -39,7 +40,7 @@ class UiApp {
}
toJSON() {
return _.pick(this, ['id', 'title', 'description', 'icon', 'main']);
return _.pick(this, ['id', 'title', 'description', 'icon', 'main', 'url']);
}
}

View file

@ -40,15 +40,6 @@ module.exports = async (kbnServer, server, config) => {
server.setupViews(resolve(__dirname, 'views'));
server.exposeStaticFile('/loading.gif', resolve(__dirname, 'public/loading.gif'));
// serve the app switcher
server.route({
path: '/api/apps',
method: 'GET',
handler: function (req, reply) {
return reply(uiExports.apps);
}
});
server.route({
path: '/app/{id}',
method: 'GET',
@ -68,7 +59,7 @@ module.exports = async (kbnServer, server, config) => {
server.decorate('reply', 'renderApp', function (app) {
let payload = {
app: app,
appCount: uiExports.apps.size,
nav: uiExports.apps,
version: kbnServer.version,
buildNum: config.get('pkg.buildNum'),
buildSha: config.get('pkg.buildSha'),

View file

@ -1,37 +1,71 @@
var _ = require('lodash');
var storage = window.sessionStorage;
const _ = require('lodash');
const reEsc = require('lodash').escapeRegExp;
const { parse, format } = require('url');
function Tab(spec) {
this.id = spec.id;
this.title = spec.title;
this.active = false;
this.resetWhenActive = !!spec.resetWhenActive;
this.lastUrlStoreKey = spec.trackLastPath ? 'lastUrl:' + this.id : null;
this.rootUrl = '/' + this.id;
this.lastUrl = this.lastUrlStoreKey && storage.getItem(this.lastUrlStoreKey);
const urlJoin = (a, b) => {
if (!b) return a;
return `${a}${ a.endsWith('/') ? '' : '/' }${b}`;
};
this.activeIndicatorColor = spec.activeIndicatorColor || null;
if (_.isFunction(this.activeIndicatorColor)) {
// convert to a getter
Object.defineProperty(this, 'activeIndicatorColor', {
get: this.activeIndicatorColor
});
export default class Tab {
constructor(spec = {}) {
this.id = spec.id || '';
this.title = spec.title || '';
this.resetWhenActive = !!spec.resetWhenActive;
this.activeIndicatorColor = spec.activeIndicatorColor || null;
if (_.isFunction(this.activeIndicatorColor)) {
// convert to a getter
Object.defineProperty(this, 'activeIndicatorColor', {
get: this.activeIndicatorColor
});
}
this.active = false;
this.baseUrl = spec.baseUrl || '/';
this.rootUrl = urlJoin(this.baseUrl, this.id);
this.rootRegExp = new RegExp(`^${reEsc(this.rootUrl)}(/|$|\\?|#)`);
this.lastUrlStoreKey = `lastUrl:${this.id}`;
this.lastUrlStore = spec.lastUrlStore;
this.lastUrl = this.lastUrlStore ? this.lastUrlStore.getItem(this.lastUrlStoreKey) : null;
}
href() {
if (this.active) {
return this.resetWhenActive ? this.rootUrl : null;
}
return this.lastUrl || this.rootUrl;
}
updateLastUrlGlobalState(globalState) {
let lastPath = this.getLastPath();
let { pathname, query, hash } = parse(lastPath, true);
query = query || {};
if (!globalState) delete query._g;
else query._g = globalState;
this.setLastUrl(`${this.rootUrl}${format({ pathname, query, hash })}`);
}
getLastPath() {
let { id, rootUrl } = this;
let lastUrl = this.getLastUrl();
if (!lastUrl.startsWith(rootUrl)) {
throw new Error(`Tab "${id}" has invalid root "${rootUrl}" for last url "${lastUrl}"`);
}
return lastUrl.slice(rootUrl.length);
}
setLastUrl(url) {
this.lastUrl = url;
if (this.lastUrlStore) this.lastUrlStore.setItem(this.lastUrlStoreKey, this.lastUrl);
}
getLastUrl() {
return this.lastUrl || this.rootUrl;
}
}
Tab.prototype.persistLastUrl = function (url) {
if (!this.lastUrlStoreKey) return;
this.lastUrl = url;
storage.setItem(this.lastUrlStoreKey, this.lastUrl);
};
Tab.prototype.href = function () {
if (this.active) {
if (this.resetWhenActive) return '#' + this.rootUrl;
return null;
}
return '#' + (this.lastUrl || this.rootUrl);
};
module.exports = Tab;

View file

@ -1,20 +1,12 @@
var _ = require('lodash');
var { startsWith, get, set, omit, wrap, pick } = require('lodash');
var Tab = require('ui/chrome/Tab');
var format = require('url').format;
var parse = _.wrap(require('url').parse, function (parse, path) {
var parsed = parse(path, true);
return {
pathname: parsed.pathname,
query: parsed.query || {},
hash: parsed.hash
};
});
function TabCollection() {
var { parse } = require('url');
function TabCollection(opts = {}) {
var tabs = [];
var specs = null;
var defaults = null;
var defaults = opts.defaults || {};
var activeTab = null;
this.set = function (_specs) {
@ -23,7 +15,7 @@ function TabCollection() {
};
this.setDefaults = function () {
defaults = _.clone(arguments[0]);
defaults = _.defaults({}, arguments[0], defaults);
this._rebuildTabs();
};
@ -42,20 +34,19 @@ function TabCollection() {
return activeTab;
};
this.consumeRouteUpdate = function ($location, persist) {
var url = parse($location.url(), true);
var id = $location.path().split('/')[1] || '';
this.consumeRouteUpdate = function (href, persist) {
tabs.forEach(function (tab) {
var active = tab.active = (tab.id === id);
var lastUrl = active ? url : parse(tab.lastUrl || tab.rootUrl);
lastUrl.query._g = url.query._g;
if (tab.active) activeTab = tab;
if (persist) {
tab.persistLastUrl(format(lastUrl));
tab.active = tab.rootRegExp.test(href);
if (tab.active) {
activeTab = tab;
activeTab.setLastUrl(href);
}
});
if (!persist || !activeTab) return;
let globalState = get(parse(activeTab.getLastPath(), true), 'query._g');
tabs.forEach(tab => tab.updateLastUrlGlobalState(globalState));
};
}

View file

@ -0,0 +1,241 @@
const Tab = require('../Tab');
const expect = require('expect.js');
const TabFakeStore = require('./_TabFakeStore');
describe('Chrome Tab', function () {
describe('construction', function () {
it('accepts id, title, resetWhenActive, trackLastUrl, activeIndicatorColor, baseUrl', function () {
const tab = new Tab({
id: 'foo',
title: 'Foo App',
resetWhenActive: false,
activeIndicatorColor: true,
baseUrl: 'proto:host.domain:999'
});
expect(tab.id).to.equal('foo');
expect(tab.title).to.equal('Foo App');
expect(tab.resetWhenActive).to.equal(false);
expect(tab.activeIndicatorColor).to.equal(true);
expect(tab.rootUrl).to.equal('proto:host.domain:999/foo');
const tab2 = new Tab({
id: 'bar',
title: 'Bar App',
resetWhenActive: true,
activeIndicatorColor: false,
baseUrl: 'proto:host.domain:999/sub/#/'
});
expect(tab2.id).to.equal('bar');
expect(tab2.title).to.equal('Bar App');
expect(tab2.resetWhenActive).to.equal(true);
expect(tab2.activeIndicatorColor).to.equal(null);
expect(tab2.rootUrl).to.equal('proto:host.domain:999/sub/#/bar');
});
it('starts inactive', function () {
const tab = new Tab();
expect(tab.active).to.equal(false);
});
it('uses the id to set the rootUrl', function () {
const id = 'foo';
const tab = new Tab({ id });
expect(tab.id).to.equal(id);
expect(tab.rootUrl).to.equal(`/${id}`);
});
it('creates a regexp for matching the rootUrl', function () {
const tab = new Tab({ id: 'foo' });
expect('/foo').to.match(tab.rootRegExp);
expect('/foo/bar').to.match(tab.rootRegExp);
expect('/foo/bar/max').to.match(tab.rootRegExp);
expect('/foo?bar=baz').to.match(tab.rootRegExp);
expect('/foo/?bar=baz').to.match(tab.rootRegExp);
expect('/foo#?bar=baz').to.match(tab.rootRegExp);
expect('/foobar').to.not.match(tab.rootRegExp);
expect('site.com/foo#?bar=baz').to.not.match(tab.rootRegExp);
expect('http://site.com/foo#?bar=baz').to.not.match(tab.rootRegExp);
});
it('includes the baseUrl in the rootRegExp if specified', function () {
const tab = new Tab({
id: 'foo',
baseUrl: 'http://spiderman.com/kibana'
});
expect('http://spiderman.com/kibana/foo/bar').to.match(tab.rootRegExp);
expect('/foo').to.not.match(tab.rootRegExp);
expect('https://spiderman.com/kibana/foo/bar').to.not.match(tab.rootRegExp);
});
it('accepts a function for activeIndicatorColor', function () {
let i = 0;
const tab = new Tab({
activeIndicatorColor: function () {
return i++;
}
});
expect(tab.activeIndicatorColor).to.equal(0);
expect(tab.activeIndicatorColor).to.equal(1);
expect(tab.activeIndicatorColor).to.equal(2);
expect(tab.activeIndicatorColor).to.equal(3);
});
it('discovers the lastUrl', function () {
const lastUrlStore = new TabFakeStore();
const tab = new Tab({ id: 'foo', lastUrlStore });
expect(tab.lastUrl).to.not.equal('bar');
tab.setLastUrl('bar');
expect(tab.lastUrl).to.equal('bar');
const tab2 = new Tab({ id: 'foo', lastUrlStore });
expect(tab2.lastUrl).to.equal('bar');
});
});
describe('#setLastUrl()', function () {
it('updates the lastUrl and storage value if passed a lastUrlStore', function () {
const lastUrlStore = new TabFakeStore();
const tab = new Tab({ id: 'foo', lastUrlStore });
expect(tab.lastUrl).to.not.equal('foo');
tab.setLastUrl('foo');
expect(tab.lastUrl).to.equal('foo');
expect(lastUrlStore.getItem(tab.lastUrlStoreKey)).to.equal('foo');
});
it('only updates lastUrl if no lastUrlStore', function () {
const tab = new Tab({ id: 'foo' });
expect(tab.lastUrl).to.equal(null);
tab.setLastUrl('foo');
expect(tab.lastUrl).to.equal('foo');
const tab2 = new Tab({ id: 'foo' });
expect(tab2.lastUrl).to.not.equal('foo');
});
});
describe('#href()', function () {
it('returns the rootUrl/id be default', function () {
const tab = new Tab({ id: 'foo' });
expect(tab.href()).to.equal(tab.rootUrl);
});
it('returns the lastUrl if tracking is on', function () {
const tab = new Tab({ id: 'foo' });
tab.setLastUrl('okay');
expect(tab.href()).to.equal('okay');
});
describe('when the tab is active', function () {
it('returns the rootUrl when resetWhenActive: true', function () {
const id = 'foo';
const resetWhenActive = true;
const tab = new Tab({ id, resetWhenActive });
tab.active = true;
expect(tab.href()).to.not.equal('butt');
expect(tab.href()).to.equal(tab.rootUrl);
});
it('or returns null when not', function () {
const tab = new Tab({ id: 'foo', resetWhenActive: false });
tab.active = true;
expect(tab.href()).to.not.equal('butt');
expect(tab.href()).to.equal(null);
});
});
});
describe('#getLastPath()', function () {
it('parses a path out of the lastUrl by removing the baseUrl', function () {
const baseUrl = 'http://local:5601/app/visualize#';
const tab = new Tab({ baseUrl });
tab.setLastUrl('http://local:5601/app/visualize#/index');
expect(tab.getLastPath()).to.equal('/index');
});
it('throws an error if the lastUrl does not extend the root url', function () {
expect(function () {
const baseUrl = 'http://local:5601/app/visualize#';
const tab = new Tab({ baseUrl });
tab.setLastUrl('http://local:5601/');
tab.getLastPath();
}).to.throwError(/invalid.*root/);
});
});
describe('updateLastUrlGlobalState', function () {
const bases = [
'http://local:5601',
'',
'weird.domain/with/subpath?path#',
'weird.domain/with/#hashpath',
];
context('with new state sets _g properly', function () {
const paths = [
[ '/', '/?_g=newState' ],
[ '/?first', '/?first=&_g=newState' ],
[ '/path?first=1&_g=afterHash', '/path?first=1&_g=newState' ],
[ '/?first=1&_g=second', '/?first=1&_g=newState' ],
[ '/?g=first', '/?g=first&_g=newState' ],
[ '/a?first=1&_g=second', '/a?first=1&_g=newState' ],
[ '/?first=1&_g=second', '/?first=1&_g=newState' ],
[ '/?first&g=second', '/?first=&g=second&_g=newState' ],
];
bases.forEach(baseUrl => {
paths.forEach(([pathFrom, pathTo]) => {
const fromUrl = `${baseUrl}${pathFrom}`;
const toUrl = `${baseUrl}${pathTo}`;
it(`${fromUrl} => ${toUrl}`, function () {
const tab = new Tab({ baseUrl });
tab.setLastUrl(fromUrl);
tab.updateLastUrlGlobalState('newState');
expect(tab.getLastUrl()).to.equal(toUrl);
});
});
});
});
context('with new empty state removes _g', function () {
const paths = [
[ '/', '/' ],
[ '/?first', '/?first=' ],
[ '/path?first=1&_g=afterHash', '/path?first=1' ],
[ '/?first=1&_g=second', '/?first=1' ],
[ '/?g=first', '/?g=first' ],
[ '/a?first=1&_g=second', '/a?first=1' ],
[ '/?first=1&_g=second', '/?first=1' ],
[ '/?first&g=second', '/?first=&g=second' ],
];
bases.forEach(baseUrl => {
paths.forEach(([pathFrom, pathTo]) => {
const fromUrl = `${baseUrl}${pathFrom}`;
const toUrl = `${baseUrl}${pathTo}`;
it(`${fromUrl}`, function () {
const tab = new Tab({ baseUrl });
tab.setLastUrl(fromUrl);
tab.updateLastUrlGlobalState();
expect(tab.getLastUrl()).to.equal(toUrl);
});
});
});
});
});
});

View file

@ -0,0 +1,73 @@
const expect = require('expect.js');
const { indexBy, random } = require('lodash');
const TabFakeStore = require('./_TabFakeStore');
const TabCollection = require('../TabCollection');
const Tab = require('../Tab');
describe('Chrome TabCollection', function () {
describe('empty state', function () {
it('has no tabs', function () {
const tabs = new TabCollection();
expect(tabs.get()).to.eql([]);
});
it('has no active tab', function () {
const tabs = new TabCollection();
expect(!tabs.getActive()).to.equal(true);
});
});
describe('#set()', function () {
it('consumes an ordered list of Tab specs', function () {
const tabs = new TabCollection();
tabs.set([
{ id: 'foo' },
{ id: 'bar' }
]);
const ts = tabs.get();
expect(ts.length).to.equal(2);
expect(ts[0].id).to.equal('foo');
expect(ts[1].id).to.equal('bar');
});
});
describe('#setDefaults()', function () {
it('applies the defaults used to create tabs', function () {
const tabs = new TabCollection();
tabs.setDefaults({ id: 'thing' });
tabs.set([ {} ]);
expect(tabs.get()[0].id).to.equal('thing');
});
it('recreates existing tabs with new defaults', function () {
const tabs = new TabCollection();
tabs.set([ {} ]);
expect(!tabs.get()[0].id).to.equal(true);
tabs.setDefaults({ id: 'thing' });
expect(tabs.get()[0].id).to.equal('thing');
});
});
describe('#consumeRouteUpdate()', function () {
it('updates the active tab', function () {
const store = new TabFakeStore();
const baseUrl = `http://localhost:${random(1000, 9999)}`;
const tabs = new TabCollection({ store, defaults: { baseUrl } });
tabs.set([
{ id: 'a' },
{ id: 'b' }
]);
tabs.consumeRouteUpdate(`${baseUrl}/a`);
const {a, b} = indexBy(tabs.get(), 'id');
expect(a.active).to.equal(true);
expect(b.active).to.equal(false);
expect(tabs.getActive()).to.equal(a);
});
});
});

View file

@ -0,0 +1,9 @@
const store = Symbol('store');
export default class TabFakeStore {
constructor() { this[store] = new Map(); }
getItem(k) { return this[store].get(k); }
setItem(k, v) { return this[store].set(k, v); }
getKeys() { return [ ...this[store].keys() ]; }
getValues() { return [ ...this[store].values() ]; }
}

View file

@ -0,0 +1,163 @@
const expect = require('expect.js');
const setup = require('../apps');
const TabFakeStore = require('../../__tests__/_TabFakeStore');
describe('Chrome API :: apps', function () {
describe('#get/setShowAppsLink()', function () {
describe('defaults to false if there are less than two apps', function () {
it('appCount = 0', function () {
const chrome = {};
setup(chrome, { nav: [] });
expect(chrome.getShowAppsLink()).to.equal(false);
});
it('appCount = 1', function () {
const chrome = {};
setup(chrome, { nav: [ { url: '/' } ] });
expect(chrome.getShowAppsLink()).to.equal(false);
});
});
describe('defaults to true if there are two or more apps', function () {
it('appCount = 2', function () {
const chrome = {};
setup(chrome, { nav: [ { url: '/' }, { url: '/2' } ] });
expect(chrome.getShowAppsLink()).to.equal(true);
});
it('appCount = 3', function () {
const chrome = {};
setup(chrome, { nav: [ { url: '/' }, { url: '/2' }, { url: '/3' } ] });
expect(chrome.getShowAppsLink()).to.equal(true);
});
});
it('is chainable', function () {
const chrome = {};
setup(chrome, { nav: [ { url: '/' } ] });
expect(chrome.setShowAppsLink(true)).to.equal(chrome);
});
it('can be changed', function () {
const chrome = {};
setup(chrome, { nav: [ { url: '/' } ] });
expect(chrome.setShowAppsLink(true).getShowAppsLink()).to.equal(true);
expect(chrome.getShowAppsLink()).to.equal(true);
expect(chrome.setShowAppsLink(false).getShowAppsLink()).to.equal(false);
expect(chrome.getShowAppsLink()).to.equal(false);
});
});
describe('#getApp()', function () {
it('returns a clone of the current app', function () {
const chrome = {};
const app = { url: '/' };
setup(chrome, { app });
expect(chrome.getApp()).to.eql(app);
expect(chrome.getApp()).to.not.equal(app);
});
it('returns undefined if no active app', function () {
const chrome = {};
setup(chrome, {});
expect(chrome.getApp()).to.equal(undefined);
});
});
describe('#getAppTitle()', function () {
it('returns the title property of the current app', function () {
const chrome = {};
const app = { url: '/', title: 'foo' };
setup(chrome, { app });
expect(chrome.getAppTitle()).to.eql('foo');
});
it('returns undefined if no active app', function () {
const chrome = {};
setup(chrome, {});
expect(chrome.getAppTitle()).to.equal(undefined);
});
});
describe('#getAppUrl()', function () {
it('returns the resolved url of the current app', function () {
const chrome = {};
const app = { url: '/foo' };
setup(chrome, { app });
const a = document.createElement('a');
a.setAttribute('href', app.url);
expect(chrome.getAppUrl()).to.equal(a.href);
});
it('returns undefined if no active app', function () {
const chrome = {};
setup(chrome, {});
expect(chrome.getAppUrl()).to.equal(undefined);
});
});
describe('#getInjected()', function () {
describe('called without args', function () {
it('returns a clone of all injectedVars', function () {
const chrome = {};
const vars = { name: 'foo' };
setup(chrome, { vars });
expect(chrome.getInjected()).to.eql(vars);
expect(chrome.getInjected()).to.not.equal(vars);
});
});
describe('called with a var name', function () {
it('returns the var at that name', function () {
const chrome = {};
const vars = { name: 'foo' };
setup(chrome, { vars });
expect(chrome.getInjected('name')).to.equal('foo');
});
});
describe('called with a var name and default', function () {
it('returns the default when the var is undefined', function () {
const chrome = {};
const vars = { name: undefined };
setup(chrome, { vars });
expect(chrome.getInjected('name', 'bar')).to.equal('bar');
});
it('returns null when the var is null', function () {
const chrome = {};
const vars = { name: null };
setup(chrome, { vars });
expect(chrome.getInjected('name', 'bar')).to.equal(null);
});
it('returns var if not undefined', function () {
const chrome = {};
const vars = { name: 'kim' };
setup(chrome, { vars });
expect(chrome.getInjected('name', 'bar')).to.equal('kim');
});
});
describe('#get/setLastUrlFor()', function () {
it('reads/writes last url from storage', function () {
const chrome = {};
const store = new TabFakeStore();
setup(chrome, { appUrlStore: store });
expect(chrome.getLastUrlFor('app')).to.equal(undefined);
chrome.setLastUrlFor('app', 'url');
expect(chrome.getLastUrlFor('app')).to.equal('url');
expect(store.getKeys().length).to.equal(1);
expect(store.getValues().shift()).to.equal('url');
});
});
});
});

View file

@ -1,7 +1,11 @@
var modules = require('ui/modules');
var $ = require('jquery');
var _ = require('lodash');
require('../appSwitcher');
var modules = require('ui/modules');
var ConfigTemplate = require('ui/ConfigTemplate');
require('ui/directives/config');
module.exports = function (chrome, internals) {
chrome.setupAngular = function () {
var kibana = modules.get('kibana');
@ -44,7 +48,13 @@ module.exports = function (chrome, internals) {
chrome.setVisible(!Boolean($location.search().embed));
// listen for route changes, propogate to tabs
var onRouteChange = _.bindKey(internals.tabs, 'consumeRouteUpdate', $location, chrome.getVisible());
var onRouteChange = function () {
let { href } = window.location;
let persist = chrome.getVisible();
internals.trackPossibleSubUrl(href);
internals.tabs.consumeRouteUpdate(href, persist);
};
$rootScope.$on('$routeChangeSuccess', onRouteChange);
$rootScope.$on('$routeUpdate', onRouteChange);
onRouteChange();
@ -52,6 +62,9 @@ module.exports = function (chrome, internals) {
// and some local values
$scope.httpActive = $http.pendingRequests;
$scope.notifList = require('ui/notify')._notifs;
$scope.appSwitcherTemplate = new ConfigTemplate({
switcher: '<app-switcher></app-switcher>'
});
return chrome;
}

View file

@ -1,7 +1,14 @@
var _ = require('lodash');
const { clone, get } = require('lodash');
const { resolve } = require('url');
module.exports = function (chrome, internals) {
if (internals.app) {
internals.app.url = resolve(window.location.href, internals.app.url);
}
internals.appUrlStore = internals.appUrlStore || window.sessionStorage;
/**
* ui/chrome apps API
*
@ -16,24 +23,33 @@ module.exports = function (chrome, internals) {
};
chrome.getShowAppsLink = function () {
return internals.showAppsLink == null ? internals.appCount > 1 : internals.showAppsLink;
return internals.showAppsLink == null ? internals.nav.length > 1 : internals.showAppsLink;
};
chrome.getApp = function () {
return _.clone(internals.app);
return clone(internals.app);
};
chrome.getAppTitle = function () {
return internals.app.title;
return get(internals, ['app', 'title']);
};
chrome.getAppId = function () {
return internals.app.id;
chrome.getAppUrl = function () {
return get(internals, ['app', 'url']);
};
chrome.getInjected = function (name, def) {
if (name == null) return _.clone(internals.vars) || {};
return _.get(internals.vars, name, def);
if (name == null) return clone(internals.vars) || {};
return get(internals.vars, name, def);
};
chrome.getLastUrlFor = function (appId) {
return internals.appUrlStore.getItem(`appLastUrl:${appId}`);
};
chrome.setLastUrlFor = function (appId, url) {
internals.appUrlStore.setItem(`appLastUrl:${appId}`, url);
};
};

View file

@ -0,0 +1,34 @@
module.exports = function (chrome, internals) {
const { startsWith } = require('lodash');
chrome.getNavLinks = function () {
return internals.nav;
};
chrome.getLastSubUrlFor = function (url) {
return internals.appUrlStore.getItem(`lastSubUrl:${url}`);
};
internals.trackPossibleSubUrl = function (url) {
for (const link of internals.nav) {
if (startsWith(url, link.url)) {
link.lastSubUrl = url;
internals.appUrlStore.setItem(`lastSubUrl:${link.url}`, url);
}
}
};
internals.nav.forEach(link => {
// convert all link urls to absolute urls
var a = document.createElement('a');
a.setAttribute('href', link.url);
link.url = a.href;
link.lastSubUrl = chrome.getLastSubUrlFor(link.url);
if (link.url === chrome.getAppUrl()) {
link.active = true;
}
});
};

View file

@ -1,7 +1,14 @@
var _ = require('lodash');
var TabCollection = require('../TabCollection');
module.exports = function (chrome, internals) {
internals.tabs = new TabCollection({
defaults: {
baseUrl: `${chrome.getAppUrl()}#/`
}
});
/**
* ui/chrome tabs API
*
@ -26,7 +33,7 @@ module.exports = function (chrome, internals) {
* when the the tab is considered active, should clicking it
* cause a redirect to just the id?
*
* trackLastPath {boolean}
* trackLastUrl {boolean}
* When this tab is active, should the current path be tracked
* and persisted to session storage, then used as the tabs href attribute when the user navigates
* away from the tab?

View file

@ -0,0 +1,221 @@
var sinon = require('auto-release-sinon');
var ngMock = require('ngMock');
var $ = require('jquery');
var expect = require('expect.js');
var constant = require('lodash').constant;
var set = require('lodash').set;
var cloneDeep = require('lodash').cloneDeep;
var indexBy = require('lodash').indexBy;
require('ui/chrome');
require('ui/chrome/appSwitcher');
var DomLocationProvider = require('ui/domLocation');
describe('appSwitcher directive', function () {
var env;
beforeEach(ngMock.module('kibana'));
function setup(href, links) {
return ngMock.inject(function ($window, $rootScope, $compile, Private) {
var domLocation = Private(DomLocationProvider);
$rootScope.chrome = {
getNavLinks: constant(cloneDeep(links)),
};
env = {
$scope: $rootScope,
$el: $compile($('<app-switcher>'))($rootScope),
currentHref: href,
location: domLocation
};
Object.defineProperties(domLocation, {
href: {
get: function () { return env.currentHref; },
set: function (val) { return env.currentHref = val; },
},
reload: {
value: sinon.stub()
}
});
env.$scope.$digest();
});
}
context('when one link is for the active app', function () {
var myLink = {
active: true,
title: 'myLink',
url: 'http://localhost:555/app/myApp',
lastSubUrl: 'http://localhost:555/app/myApp#/lastSubUrl'
};
var notMyLink = {
active: false,
title: 'notMyLink',
url: 'http://localhost:555/app/notMyApp',
lastSubUrl: 'http://localhost:555/app/notMyApp#/lastSubUrl'
};
beforeEach(setup('http://localhost:5555/app/myApp/', [myLink, notMyLink]));
it('links to the inactive apps base url', function () {
var $myLink = env.$el.findTestSubject('appLink').eq(0);
expect($myLink.prop('href')).to.be(myLink.url);
expect($myLink.prop('href')).to.not.be(myLink.lastSubUrl);
});
it('links to the inactive apps last sub url', function () {
var $notMyLink = env.$el.findTestSubject('appLink').eq(1);
expect($notMyLink.prop('href')).to.be(notMyLink.lastSubUrl);
expect($notMyLink.prop('href')).to.not.be(notMyLink.url);
});
});
context('when none of the links are for the active app', function () {
var myLink = {
active: false,
title: 'myLink',
url: 'http://localhost:555/app/myApp',
lastSubUrl: 'http://localhost:555/app/myApp#/lastSubUrl'
};
var notMyLink = {
active: false,
title: 'notMyLink',
url: 'http://localhost:555/app/notMyApp',
lastSubUrl: 'http://localhost:555/app/notMyApp#/lastSubUrl'
};
beforeEach(setup('http://localhost:5555/app/myApp/', [myLink, notMyLink]));
it('links to the lastSubUrl for each', function () {
var $links = env.$el.findTestSubject('appLink');
var $myLink = $links.eq(0);
var $notMyLink = $links.eq(1);
expect($myLink.prop('href')).to.be(myLink.lastSubUrl);
expect($myLink.prop('href')).to.not.be(myLink.url);
expect($notMyLink.prop('href')).to.be(notMyLink.lastSubUrl);
expect($notMyLink.prop('href')).to.not.be(notMyLink.url);
});
});
context('clicking a link with matching href but missing hash', function () {
var url = 'http://localhost:555/app/myApp?query=1';
beforeEach(setup(url + '#/lastSubUrl', [
{ url: url }
]));
it('just prevents propogation (no reload)', function () {
var event = new $.Event('click');
expect(env.location.reload.callCount).to.be(0);
expect(event.isDefaultPrevented()).to.be(false);
expect(event.isPropagationStopped()).to.be(false);
var $link = env.$el.findTestSubject('appLink');
expect($link.prop('href')).to.be(url);
$link.trigger(event);
expect(env.location.reload.callCount).to.be(0);
expect(event.isDefaultPrevented()).to.be(false);
expect(event.isPropagationStopped()).to.be(true);
});
});
context('clicking a link that matches entire url', function () {
var url = 'http://localhost:555/app/myApp#/lastSubUrl';
beforeEach(setup(url, [
{ url: url }
]));
it('calls window.location.reload and prevents propogation', function () {
var event = new $.Event('click');
expect(env.location.reload.callCount).to.be(0);
expect(event.isDefaultPrevented()).to.be(false);
expect(event.isPropagationStopped()).to.be(false);
var $link = env.$el.findTestSubject('appLink');
expect($link.prop('href')).to.be(env.currentHref);
$link.trigger(event);
expect(env.location.reload.callCount).to.be(1);
expect(event.isDefaultPrevented()).to.be(false);
expect(event.isPropagationStopped()).to.be(true);
});
});
context('clicking a link with matching href but changed hash', function () {
var rootUrl = 'http://localhost:555/app/myApp?query=1';
var url = rootUrl + '#/lastSubUrl2';
beforeEach(setup(url + '#/lastSubUrl', [
{ url: url }
]));
it('calls window.location.reload and prevents propogation', function () {
var event = new $.Event('click');
expect(env.location.reload.callCount).to.be(0);
expect(event.isDefaultPrevented()).to.be(false);
expect(event.isPropagationStopped()).to.be(false);
var $link = env.$el.findTestSubject('appLink');
expect($link.prop('href')).to.be(url);
$link.trigger(event);
expect(env.location.reload.callCount).to.be(1);
expect(event.isDefaultPrevented()).to.be(false);
expect(event.isPropagationStopped()).to.be(true);
});
});
context('clicking a link with matching host', function () {
beforeEach(setup('http://localhost:555/someOtherPath', [
{
active: true,
url: 'http://localhost:555/app/myApp'
}
]));
it('allows click through', function () {
var event = new $.Event('click');
expect(env.location.reload.callCount).to.be(0);
expect(event.isPropagationStopped()).to.be(false);
env.$el.findTestSubject('appLink').trigger(event);
expect(env.location.reload.callCount).to.be(0);
expect(event.isPropagationStopped()).to.be(false);
});
});
context('clicking a link with matching host and path', function () {
beforeEach(setup('http://localhost:555/app/myApp?someQuery=true', [
{
active: true,
url: 'http://localhost:555/app/myApp?differentQuery=true'
}
]));
it('allows click through', function () {
var event = new $.Event('click');
expect(env.location.reload.callCount).to.be(0);
expect(event.isPropagationStopped()).to.be(false);
env.$el.findTestSubject('appLink').trigger(event);
expect(env.location.reload.callCount).to.be(0);
expect(event.isPropagationStopped()).to.be(false);
});
});
});

View file

@ -0,0 +1,18 @@
<div class="app-links">
<div
class="app-link"
ng-repeat="link in switcher.getNavLinks() | orderBy:'title'"
ng-class="{ active: link.active }">
<a
ng-click="switcher.ensureNavigation($event, link)"
ng-href="{{ link.active ? link.url : (link.lastSubUrl || link.url) }}"
data-test-subj="appLink">
<div ng-if="link.icon" ng-style="{ 'background-image': 'url(../' + link.icon + ')' }" class="app-icon"></div>
<div ng-if="!link.icon" class="app-icon app-icon-missing">{{ link.title[0] }}</div>
<div class="app-title">{{ link.title }}</div>
</a>
</div>
</div>

View file

@ -0,0 +1,51 @@
var parse = require('url').parse;
var bindKey = require('lodash').bindKey;
require('../appSwitcher/appSwitcher.less');
var DomLocationProvider = require('ui/domLocation');
require('ui/modules')
.get('kibana')
.directive('appSwitcher', function () {
return {
restrict: 'E',
template: require('./appSwitcher.html'),
controllerAs: 'switcher',
controller: function ($scope, Private) {
var domLocation = Private(DomLocationProvider);
// since we render this in an isolate scope we can't "require: ^chrome", but
// rather than remove all helpfull checks we can just check here.
if (!$scope.chrome || !$scope.chrome.getNavLinks) {
throw new TypeError('appSwitcher directive requires the "chrome" config-object');
}
this.getNavLinks = bindKey($scope.chrome, 'getNavLinks');
// links don't cause full-navigation events in certain scenarios
// so we force them when needed
this.ensureNavigation = function (event, app) {
if (event.isDefaultPrevented() || event.altKey || event.metaKey || event.ctrlKey) {
return;
}
var toParsed = parse(event.delegateTarget.href);
var fromParsed = parse(domLocation.href);
var sameProto = toParsed.protocol === fromParsed.protocol;
var sameHost = toParsed.host === fromParsed.host;
var samePath = toParsed.path === fromParsed.path;
if (sameProto && sameHost && samePath) {
toParsed.hash && domLocation.reload();
// event.preventDefault() keeps the browser from seeing the new url as an update
// and even setting window.location does not mimic that behavior, so instead
// we use stopPropagation() to prevent angular from seeing the click and
// starting a digest cycle/attempting to handle it in the router.
event.stopPropagation();
}
};
}
};
});

View file

@ -1,6 +1,7 @@
@import (reference) "~ui/styles/variables";
@app-icon-size: 48px;
@app-icon-padding: 10px;
.app-links {
text-align: justify;
@ -8,9 +9,11 @@
.app-link {
display: inline-block;
vertical-align: top;
width: @app-icon-size;
margin: 0px 10px;
text-align: left;
width: @app-icon-size + (@app-icon-padding * 2);
margin: 0px 10px;
padding: @app-icon-padding;
border-radius: @border-radius-base;
.app-icon {
display: block;
@ -43,6 +46,13 @@
text-decoration: underline;
}
&.active {
background: @gray-lighter;
.app-title {
text-decoration: underline;
}
}
}
}

View file

@ -47,9 +47,9 @@
</a>
</li>
<li class="to-body" ng-class="{active: appTemplate.is('switcher')}" ng-if="chrome.getShowAppsLink()">
<a ng-click="appTemplate.toggle('switcher')">
<i class="fa fa-th" alt="Go to app switcher"></i>
<li class="to-body" ng-class="{ active: appSwitcherTemplate.is('switcher') }" ng-if="chrome.getShowAppsLink()">
<a ng-click="appSwitcherTemplate.toggle('switcher')">
<i class="fa fa-th" alt="Show app switcher"></i>
</a>
</li>
</ul>
@ -98,9 +98,9 @@
</nav>
<config
config-template="appTemplate"
config-object="switcher"
config-close="appTemplate.close">
config-template="appSwitcherTemplate"
config-object="chrome"
config-close="appSwitcherTemplate.close">
</config>
<config

View file

@ -11,20 +11,15 @@ require('ui/promises');
var metadata = require('ui/metadata');
var TabCollection = require('ui/chrome/TabCollection');
var chrome = {
navBackground: '#222222',
logo: null,
smallLogo: null
};
var internals = _.assign(
var chrome = {};
var internals = _.defaults(
_.cloneDeep(metadata),
{
tabs: new TabCollection(),
rootController: null,
rootTemplate: null,
showAppsLink: null,
brand: null,
nav: [],
applicationClasses: []
}
);
@ -35,6 +30,7 @@ $('<link>').attr({
}).appendTo('head');
require('./api/apps')(chrome, internals);
require('./api/nav')(chrome, internals);
require('./api/angular')(chrome, internals);
require('./api/controls')(chrome, internals);
require('./api/tabs')(chrome, internals);

View file

@ -1,12 +1,10 @@
define(function (require) {
require('plugins/appSwitcher/appSwitcher.less');
var _ = require('lodash');
var ConfigTemplate = require('ui/ConfigTemplate');
require('ui/modules')
.get('kibana')
.directive('chromeContext', function (timefilter, globalState, $http) {
.directive('chromeContext', function (timefilter, globalState) {
var listenForUpdates = _.once(function ($scope) {
$scope.$listen(timefilter, 'update', function (newVal, oldVal) {
@ -27,17 +25,6 @@ define(function (require) {
interval: require('ui/chrome/config/interval.html')
});
$scope.switcher = {loading: true};
$http.get('/api/apps')
.then(function (resp) {
$scope.switcher.loading = false;
$scope.switcher.apps = resp.data;
});
$scope.appTemplate = new ConfigTemplate({
switcher: require('plugins/appSwitcher/appSwitcher.html')
});
$scope.toggleRefresh = function () {
timefilter.refreshInterval.pause = !timefilter.refreshInterval.pause;
};

View file

@ -54,7 +54,7 @@ define(function () {
value: 30,
description: 'Requests in discover are split into segments to prevent massive requests from being sent to ' +
'elasticsearch. This setting attempts to prevent the list of segments from getting too long, which might ' +
'cause requests to take much longer to process.'
'cause requests to take much longer to process'
},
'fields:popularLimit': {
value: 10,
@ -74,7 +74,7 @@ define(function () {
'12 is the max. ' +
'<a href="http://www.elastic.co/guide/en/elasticsearch/reference/current/' +
'search-aggregations-bucket-geohashgrid-aggregation.html#_cell_dimensions_at_the_equator" target="_blank">' +
'Explanation of cell dimensions.</a>',
'Explanation of cell dimensions</a>',
},
'visualization:tileMap:WMSdefaults': {
value: JSON.stringify({
@ -117,12 +117,12 @@ define(function () {
},
'truncate:maxHeight': {
value: 115,
description: 'The maximum height that a cell in a table should occupy. Set to 0 to disable truncation.'
description: 'The maximum height that a cell in a table should occupy. Set to 0 to disable truncation'
},
'indexPattern:fieldMapping:lookBack': {
value: 5,
description: 'For index patterns containing timestamps in their names, look for this many recent matching ' +
'patterns from which to query the field mapping.'
'patterns from which to query the field mapping'
},
'format:defaultTypeMap': {
type: 'json',
@ -136,23 +136,27 @@ define(function () {
'}',
].join('\n'),
description: 'Map of the format name to use by default for each field type. ' +
'"_default_" is used if the field type is not mentioned explicitly.'
'"_default_" is used if the field type is not mentioned explicitly'
},
'format:number:defaultPattern': {
type: 'string',
value: '0,0.[000]'
value: '0,0.[000]',
description: 'Default numeral format for the "number" format'
},
'format:bytes:defaultPattern': {
type: 'string',
value: '0,0.[000]b'
value: '0,0.[000]b',
description: 'Default numeral format for the "bytes" format'
},
'format:percent:defaultPattern': {
type: 'string',
value: '0,0.[000]%'
value: '0,0.[000]%',
description: 'Default numeral format for the "percent" format'
},
'format:currency:defaultPattern': {
type: 'string',
value: '($0,0.[00])'
value: '($0,0.[00])',
description: 'Default numeral format for the "currency" format'
},
'timepicker:timeDefaults': {
type: 'json',
@ -162,7 +166,8 @@ define(function () {
' "to": "now",',
' "mode": "quick"',
'}'
].join('\n')
].join('\n'),
description: 'The timefilter selection to use when Kibana is started without one'
},
'timepicker:refreshIntervalDefaults': {
type: 'json',
@ -172,7 +177,8 @@ define(function () {
' "pause": false,',
' "value": 0',
'}'
].join('\n')
].join('\n'),
description: 'The timefilter\'s default refresh interval'
},
'dashboard:defaultDarkTheme': {
value: false,

View file

@ -1,4 +1,3 @@
<div class="spinner large" ng-show="searchSource.activeFetchCount > 0"></div>
<div
ng-if="hits.length"
ng-class="{ loading: searchSource.activeFetchCount > 0 }">

View file

@ -0,0 +1,15 @@
module.exports = function DomLocationProvider($window) {
return {
reload: function (forceFetch) {
$window.location.reload(forceFetch);
},
get href() {
return $window.location.href;
},
set href(val) {
return ($window.location.href = val);
}
};
};

View file

@ -111,6 +111,22 @@ describe('Filter Manager', function () {
filterManager.add('_exists_', 'myField', '-', 'myIndex');
checkAddFilters(0, null, 3);
expect(appState.filters).to.have.length(2);
var scriptedField = {name: 'scriptedField', scripted: true, script: 1};
filterManager.add(scriptedField, 1, '+', 'myIndex');
checkAddFilters(1, [{
meta: {index: 'myIndex', negate: false, field: 'scriptedField'},
script: {
script: '(' + scriptedField.script + ') == value',
lang: scriptedField.lang,
params: {value: 1}
}
}], 4);
expect(appState.filters).to.have.length(3);
filterManager.add(scriptedField, 1, '-', 'myIndex');
checkAddFilters(0, null, 5);
expect(appState.filters).to.have.length(3);
});
it('should enable matching filters being changed', function () {

View file

@ -26,6 +26,10 @@ define(function (require) {
if (filter.query) {
return filter.query.match[fieldName] && filter.query.match[fieldName].query === value;
}
if (filter.script) {
return filter.meta.field === fieldName && filter.script.params.value === value;
}
});
if (existing) {

View file

@ -1,4 +1,3 @@
<div class="spinner large" ng-show="vis.type.requiresSearch && searchSource.activeFetchCount > 0"></div>
<div ng-if="vis.type.requiresSearch && esResp.hits.total === 0"
class="text-center visualize-error visualize-chart">
<div class="item top"></div>

View file

@ -1,2 +1 @@
var $ = window.jQuery = window.$ = module.exports = require('node_modules/jquery/dist/jquery');
require('ui/jquery/findTestSubject')($);
window.jQuery = window.$ = module.exports = require('node_modules/jquery/dist/jquery');