| // 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': function() { |
| location.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); |
| } |
| } |
| |
| } |