blob: 4cf3b0195cf40b368e1c7c88069803eb12bf13c5 [file] [log] [blame]
// Copyright 2015 The Vanadium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
var mercury = require('mercury');
var insertCss = require('insert-css');
var PropertyValueEvent = require('../../lib/mercury/property-value-event');
var exists = require('../../lib/exists');
var namespaceService = require('../../services/namespace/service');
var smartService = require('../../services/smart/service');
var stateService = require('../../services/state/service');
var browseRoute = require('../../routes/browse');
var bookmarksRoute = require('../../routes/bookmarks');
var recommendationsRoute = require('../../routes/recommendations');
var ItemDetails = require('./item-details/index');
var Views = require('./views/index');
var Bookmarks = require('./bookmarks/index');
var Recommendations = require('./recommendations/index');
var browseNamespace = require('./browse-namespace');
var getNamespaceSuggestions = require('./get-namespace-suggestions');
var log = require('../../lib/log')('components:browse');
var css = require('./index.css');
var h = mercury.h;
module.exports = create;
module.exports.render = render;
module.exports.renderHeader = renderHeader;
// While there could be any number of children at the current namespace, only
// show up to 5 suggestions at a time. Rely on the filter to find the rest.
var NAMESPACE_AUTOCOMPLETE_MAX_ITEMS = 5;
/*
* Browse component provides user interfaces for browsing the Vanadium namespace
*/
function create() {
loadLearners();
var selectedItemDetails = new ItemDetails();
var bookmarks = new Bookmarks();
var recommendations = new Recommendations();
var views = new Views();
var state = mercury.varhash({
/*
* Vanadium namespace being displayed and queried
* Note: This value is persisted between namespace browser sessions.
* @type {string}
*/
namespace: mercury.value(''),
/*
* Glob query applied to the Vanadium namespace
* @type {string}
*/
globQuery: mercury.value(''),
/*
* List of direct descendants of the namespace input prefix.
* Used to make suggestions when interacting with the namespace input.
* TODO(alexfandrianto): Currently uses obj.mountedName to access the name
* of the descendant instead of storing the name directly. Works around
* namespaceService's glob, which updates its returned result over time.
* @type {Array<Object>}
*/
namespaceSuggestions: mercury.array([]),
/*
* The namespace input prefix is the last namespace value that triggered
* a glob for direct descendants. Initially, it is null. Upon update of the
* namespace input prefix, new children will be globbed.
* @type {string | null}
*/
namespacePrefix: mercury.value(null),
/*
* State of the bookmarks component
*/
bookmarks: bookmarks.state,
/*
* State of the recommendation component
*/
recommendations: recommendations.state,
/*
* State of the views component
*/
views: views.state,
/*
* State of the selected item-details component
*/
selectedItemDetails: selectedItemDetails.state,
/*
* Name of currently selected item
*/
selectedItemName: mercury.value(''),
/*
* Whether loading items has finished.
* @type {Boolean}
*/
isFinishedLoadingItems: mercury.value(false),
/*
* Specifies what sub page is currently displayed.
* One of: views, bookmarks, recommendations
*/
subPage: mercury.value('views'),
/*
* Whether the side panel is collapsed or expanded
* @type {Boolean}
*/
sidePanelCollapsed: mercury.value(false),
/*
* Width of the side panel
* Note: This value is persisted between namespace browser sessions.
* @type {String}
*/
sidePanelWidth: mercury.value('50%')
});
var events = mercury.input([
/*
* Indicates a request to browse the Vanadium namespace
* Data of form:
* {
* namespace: '/namespace-root:8881/name/space',
* globQuery: '*',
* }
* is expected as data for the event
*/
'browseNamespace',
/*
* View components events.
*/
'views',
/*
* Indicates a request to obtain the direct descendants of the given name.
*/
'getNamespaceSuggestions',
/*
* Selects an item.
* Data of form:
* {
* name: 'object/name'
* }
*/
'selectItem',
/*
* Events for the ItemDetails component
*/
'selectedItemDetails',
/*
* Displays an error
* Data of should be an Error object.
*/
'error',
/*
* Displays a toast
* Data of form:
* {
text: 'Saved',
type: 'error',
action: function undo(){ // },
actionText: 'UNDO'
* }
*/
'toast',
/*
* Event for toggling the expand/collapse state of the sidebar details panel
*/
'toggleSidePanel',
/*
* Drag to resize the side details panel
*/
'slideSidePanel'
]);
wireUpEvents(state, events);
events.selectedItemDetails = selectedItemDetails.events;
events.views = views.events;
selectedItemDetails.events.toast = events.toast;
return {
state: state,
events: events
};
}
/*
* Loads the learners into the smart service upon creation of this component.
* TODO(aghassemi), TODO(alexfandrianto) Move this into service layers, similar
* to how `learner-shortcut` is now loaded in the recommendations service.
*/
function loadLearners() {
smartService.loadOrCreate(
'learner-method-input',
smartService.constants.LEARNER_METHOD_INPUT, {
minThreshold: 0.2,
maxValues: -1
}
).catch(function(err) {
log.error(err);
});
smartService.loadOrCreate(
'learner-method-invocation',
smartService.constants.LEARNER_METHOD_INVOCATION, {
minThreshold: 0.25,
maxValues: 1
}
).catch(function(err) {
log.error(err);
});
}
/*
* Renders the top bar, where the user can specify a namespace root.
*/
function renderHeader(browseState, browseEvents, navEvents) {
return h('div.header-content', [
renderNamespaceBox(browseState, browseEvents, navEvents)
]);
}
function renderSidePanelToggle(browseState, browseEvents) {
var cssClass = '.core-header.side-panel-toggle';
if (browseState.sidePanelCollapsed) {
cssClass += '.collapsed';
}
return h('paper-fab' + cssClass, {
attributes: {
'mini': true,
'title': browseState.sidePanelCollapsed ?
'Show side panel' : 'Hide side panel',
'icon': browseState.sidePanelCollapsed ?
'chevron-left' : 'chevron-right',
},
'ev-click': mercury.event(browseEvents.toggleSidePanel, {
collapsed: !browseState.sidePanelCollapsed
})
});
}
/*
* Renders the main body.
* A toolbar is rendered on top of the mainView and sideView showing the current
* position in the namespace as well as a globquery searchbox.
* The mainView contains the shortcuts and names at this point in the namespace.
* The sideView displays the detail information of the selected name.
*/
function render(browseState, browseEvents, navEvents) {
insertCss(css);
var expandCollapse = renderSidePanelToggle(browseState, browseEvents);
var sideView = [
expandCollapse,
ItemDetails.render(
browseState.selectedItemDetails,
browseEvents.selectedItemDetails,
browseState,
navEvents
)
];
var mainView;
switch (browseState.subPage) {
case 'views':
mainView = Views.render(browseState.views, browseEvents.views,
browseState, browseEvents, navEvents);
break;
case 'bookmarks':
mainView = Bookmarks.render(browseState.bookmarks,
browseState, browseEvents, navEvents);
break;
case 'recommendations':
mainView = Recommendations.render(browseState.recommendations,
browseState, browseEvents, navEvents);
break;
default:
log.error('Unsupported subPage ' + browseState.subPage);
}
// add progressbar and wrap in a container
var progressbar;
if (!browseState.isFinishedLoadingItems) {
progressbar = h('core-tooltip.progress-tooltip', {
attributes: {
'label': 'Loading items...',
'position': 'bottom'
}
}, h('paper-progress.delayed', {
attributes: {
'indeterminate': true,
'aria-label': 'Loading items'
}
}));
}
mainView = h('div.browse-main-wrapper', [
progressbar,
mainView
]);
var view = [
h('core-toolbar.browse-toolbar.core-narrow', [
renderToolbar(browseState, navEvents)
]),
h('core-drawer-panel', {
attributes: {
'id': 'sidebarDrawer',
'rightDrawer': true,
'drawerWidth': browseState.sidePanelCollapsed ?
'0%' : browseState.sidePanelWidth,
'responsiveWidth': '0px'
}
}, [
h('core-header-panel.browse-main-panel', {
attributes: {
'main': true
}
}, [
mainView
]),
h('core-header-panel.browse-details-sidebar', {
attributes: {
'drawer': true
}
}, [
h('div.resize-handle', {
'ev-mousedown': function(e) {
browseEvents.slideSidePanel({
rawEvent: e,
collapsed: browseState.sidePanelCollapsed
});
}
}),
sideView
])
])
];
return h('core-drawer-panel', {
attributes: {
'drawerWidth': '0px'
}
}, [
h('core-header-panel', {
attributes: {
'main': true
}
}, [
view
])
]);
}
/*
* Renders the addressbar for entering namespace
*/
function renderNamespaceBox(browseState, browseEvents, navEvents) {
// Trigger an actual navigation event when value of the inputs change
var changeEvent = new PropertyValueEvent(function(val) {
var namespace = browseState.namespace;
if (exists(val)) {
namespace = val;
}
navEvents.navigate({
path: browseRoute.createUrl(browseState, {
namespace: namespace
})
});
}, 'value', true);
// Change the namespace suggestions if the user types into the namespace box.
// Ideally, this would be the input event handler. See inputEvent below.
var trueInputEvent = new PropertyValueEvent(function(val) {
browseEvents.getNamespaceSuggestions(val);
}, 'value', false);
// TODO(alexfandrianto): A workaround for Mercury/Polymer. The
// paper-autocomplete's input value updates after Mercury captures the event.
// If we defer handling the event, then the input has time to update itself to
// the correct, new value.
var inputEvent = function(ev) {
setTimeout(trueInputEvent.handleEvent.bind(trueInputEvent, ev), 0);
};
// The focus event also retrieves namespace suggestions.
var focusEvent = inputEvent;
var children = browseState.namespaceSuggestions.map(
function renderChildItem(child) {
return h('paper-item', child.mountedName);
}
);
return h('div.namespace-box',
h('div', {
attributes: {
'layout': 'true',
'horizontal': 'true'
}
}, [
h('core-tooltip.icontooltip', {
attributes: {
'label': 'Reload'
},
'position': 'bottom'
},
h('paper-icon-button.icon', {
attributes: {
'icon': 'refresh',
'label': 'Reload'
},
'ev-click': mercury.send(navEvents.reload)
})
),
h('core-tooltip.nstooltip', {
attributes: {
'label': 'Enter a name to browse, e.g. house/living-room'
},
'position': 'bottom'
},
h('paper-autocomplete.autocomplete', {
attributes: {
'name': 'namespace',
'value': browseState.namespace,
'delimiter': '/',
'flex': 'true',
'spellcheck': 'false',
'maxItems': NAMESPACE_AUTOCOMPLETE_MAX_ITEMS
},
'ev-focus': focusEvent,
'ev-input': inputEvent,
'ev-change': changeEvent
}, children)
)
])
);
}
function createActionIcon(tooltip, icon, href, isSelected) {
var view = h('core-tooltip', {
'label': tooltip,
'position': 'bottom'
},
h('a', {
attributes: {
'href': href
}
}, h('paper-icon-button.icon' + (isSelected ? '.selected' : ''), {
attributes: {
'icon': icon
}
}))
);
return view;
}
/*
* Renders the view switchers for different views and bookmarks, recommendations
*/
function renderToolbar(browseState, navEvents) {
var selectedActionKey = browseState.subPage;
if (browseState.subPage === 'views') {
selectedActionKey = browseState.views.viewType;
}
var switchGroup = h('div.icon-group', [
createActionIcon('Tree view', 'list',
browseRoute.createUrl(browseState, {
viewType: 'tree'
}), selectedActionKey === 'tree'
),
createActionIcon('Radial view', 'image:filter-tilt-shift',
browseRoute.createUrl(browseState, {
viewType: 'visualize'
}), selectedActionKey === 'visualize'
),
createActionIcon('Grid view', 'apps',
browseRoute.createUrl(browseState, {
viewType: 'grid'
}), selectedActionKey === 'grid'
)
]);
var breadcrumbs = renderBreadcrumbs(browseState, navEvents);
var ruler = h('div.vertical-ruler');
var bookmarkGroup = h('div.icon-group', [
createActionIcon('Bookmarks', 'bookmark-outline',
bookmarksRoute.createUrl(),
selectedActionKey === 'bookmarks'
),
createActionIcon('Recent', 'schedule',
recommendationsRoute.createUrl(),
selectedActionKey === 'recommendations')
]);
var searchGroup = renderSearch(browseState, navEvents);
var view = h('div.browse-toolbar-layout', {
attributes: {
'layout': 'true',
'horizontal': 'true'
}
}, [
switchGroup,
ruler,
breadcrumbs,
ruler,
bookmarkGroup,
ruler,
searchGroup
]);
return view;
}
/*
* Renders the globquery searchbox, used to filter the globbed names.
*/
function renderSearch(browseState, navEvents) {
// Trigger an actual navigation event when value of the inputs change
var changeEvent = new PropertyValueEvent(function(val) {
navEvents.navigate({
path: browseRoute.createUrl(browseState, {
globQuery: val,
// TODO(aghassemi): We only support grid view for search, we could
// potentially support other views such as tree too but it's tricky.
viewType: 'grid'
})
});
}, 'value', true);
var clearSearch;
if (browseState.globQuery) {
clearSearch = h('paper-icon-button.icon.clear-search', {
attributes: {
'icon': 'clear',
'label': 'Clear search'
},
'ev-click': mercury.event(navEvents.navigate, {
path: browseRoute.createUrl(browseState)
})
});
}
return h('div.search-box',
h('core-tooltip.tooltip', {
attributes: {
'label': 'Enter Glob query for searching, e.g., */*/a*'
},
'position': 'bottom'
},
h('div', {
attributes: {
'layout': 'true',
'horizontal': 'true'
}
}, [
h('core-icon.icon', {
attributes: {
'icon': 'search'
}
}),
h('paper-input.input', {
attributes: {
'flex': 'true',
'spellcheck': 'false',
'label': 'Glob Search'
},
'name': 'globQuery',
'value': browseState.globQuery,
'ev-change': changeEvent
}),
clearSearch
])
)
);
}
/*
* Renders the current name being browsed, split into parts.
* Starts at the top of the name and goes all the way to the selected item.
* Each name part is a link to a parent.
*/
function renderBreadcrumbs(browseState, navEvents) {
var name = browseState.selectedItemName || browseState.namespace;
var isRooted = namespaceService.util.isRooted(name);
var namespaceParts = namespaceService.util.parseName(name);
var parentParts = namespaceService.util.parseName(browseState.namespace);
var breadCrumbs = [];
if (!isRooted) {
// Add a relative root (empty namespace)
var rootUrl = browseRoute.createUrl(browseState, {
namespace: ''
});
breadCrumbs.push(h('li.breadcrumb-item.relative-name' +
(parentParts.length ? '.breadcrumb-item-prefix' : ''), [
//TODO(aghassemi) refactor link generation code
h('a', {
'href': rootUrl,
'ev-click': mercury.event(navEvents.navigate, {
path: rootUrl
})
}, '<Home>')
]));
}
parentParts.pop(); // remove last part (current view root)
for (var i = 0; i < namespaceParts.length; i++) {
var namePart = namespaceParts[i].trim();
var fullName = (isRooted ? '/' : '') +
namespaceService.util.join(namespaceParts.slice(0, i + 1));
var url = browseRoute.createUrl(browseState, {
namespace: fullName
});
var isPartOfParent = parentParts.indexOf(namePart) > -1;
var cssClass = 'breadcrumb-item';
if (isPartOfParent) {
cssClass += '.breadcrumb-item-prefix';
}
var listItem = h('li.' + cssClass, [
h('a', {
'href': url,
'ev-click': mercury.event(navEvents.navigate, {
path: url
})
}, namePart)
]);
breadCrumbs.push(listItem);
}
var bc = h('ul.breadcrumbs', breadCrumbs);
return h('div.breadcrumbs-wrapper', bc);
}
// Wire up events that we know how to handle
function wireUpEvents(state, events) {
events.browseNamespace(browseNamespace.bind(null, state, events));
events.getNamespaceSuggestions(getNamespaceSuggestions.bind(null, state));
events.selectItem(function(data) {
state.selectedItemName.set(data.name);
events.selectedItemDetails.displayItemDetails(data);
});
events.toggleSidePanel(function(data) { // hide side panel
state.sidePanelCollapsed.set(data.collapsed);
var drawer = document.querySelector('#sidebarDrawer');
if (!drawer) {
return;
}
//Fire a window resize event when animation ends so components can adjust
//based on the new view port size
// TODO(aghassemi): specific to webkit
drawer.addEventListener('webkitTransitionEnd', fireResizeEvent);
});
events.slideSidePanel(function(data) { // resize side panel
// ignore if not primary button, or if side panel is collapsed
if (data.rawEvent.button !== 0 || data.collapsed) {
return;
}
var dragX = data.rawEvent.clientX; // initial position of drag target
var drawer = document.querySelector('#sidebarDrawer');
var oldP = +drawer.getAttribute('drawerWidth').replace('%', '');
var oldW = drawer.offsetWidth; // width of both panels in pixels
drawer.querySelector('::shadow core-selector').
classList.remove('transition');
window.addEventListener('mousemove', slideMove);
window.addEventListener('mouseup', slideEnd);
function slideMove(e) { // move
var dx = e.clientX - dragX;
var newP = Math.min(Math.max(oldP - (dx * 100 / oldW), 10), 90);
drawer.setAttribute('drawerWidth', newP.toFixed(2) + '%');
e.preventDefault(); // avoid selecting text
}
function slideEnd(e) { // release
window.removeEventListener('mouseup', slideEnd);
window.removeEventListener('mousemove', slideMove);
drawer.querySelector('::shadow core-selector').
classList.add('transition');
var drawerWidth = drawer.getAttribute('drawerWidth');
// async call to persist the drawer width
stateService.saveSidePanelWidth(drawerWidth);
state.sidePanelWidth.set(drawerWidth);
state.sidePanelCollapsed.set(false);
fireResizeEvent(null);
} // end slideEnd
}); // end events.slideSidePanel
function fireResizeEvent(e) { // resize on end animation
var evt = document.createEvent('UIEvents');
evt.initUIEvent('resize', true, false, window, 0);
window.dispatchEvent(evt);
if (e !== null) { // not if called by slideEnd
// TODO(aghassemi): specific to webkit
document.querySelector('#sidebarDrawer').
removeEventListener('webkitTransitionEnd', fireResizeEvent);
}
}
}