| // 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 _ = require('lodash'); |
| var uuid = require('uuid'); |
| |
| var makeRPC = require('./make-rpc.js'); |
| |
| var arraySet = require('../../../../lib/array-set'); |
| var setMercuryArray = require('../../../../lib/mercury/set-mercury-array'); |
| var PropertyValueEvent = |
| require('../../../../lib/mercury/property-value-event'); |
| |
| var store = require('../../../../lib/store'); |
| |
| var smartService = require('../../../../services/smart/service'); |
| var getMethodData = |
| require('../../../../services/namespace/interface-util').getMethodData; |
| var hashInterface = |
| require('../../../../services/namespace/interface-util').hashInterface; |
| |
| var log = require('../../../../lib/log')( |
| 'components:browse:item-details:method-form'); |
| |
| var h = mercury.h; |
| |
| |
| module.exports = create; |
| module.exports.render = render; |
| |
| // While an unlimited # of items are predicted per input, it's a bad idea to |
| // show them all. Limit to 4 at a time, and rely on filtering to find the rest. |
| var METHOD_INPUT_MAX_ITEMS = 4; |
| |
| /* |
| * Create the base state and events necessary to render a method form. |
| * Call the displayMethodForm event to fill this state with more data. |
| */ |
| function create() { |
| var state = mercury.varhash({ |
| /* |
| * Item name to target RPCs against. |
| * @type {string} |
| */ |
| itemName: mercury.value(''), |
| |
| /* |
| * The item's interface. |
| * @type {interface} |
| */ |
| interface: mercury.value(null), |
| |
| /* |
| * Method name for this method form |
| * @type {string} |
| */ |
| methodName: mercury.value(''), |
| |
| /* |
| * Argument values that can be used to invoke the methodName RPC. |
| * @type {Array<string>} |
| */ |
| args: mercury.array([]), |
| |
| /* |
| * Set of starred invocations saved by the user. There is no size limit. |
| * An array is used to preserve order. |
| * Each value in this array is a JSON-encoded array of argument values. |
| * This only contains keys of starred items; unstarred ones are removed. |
| * @type {Array<string>} |
| */ |
| starred: mercury.array([]), |
| |
| /* |
| * Limited list of invocations recommended to the user. |
| * Each invocation is a JSON-encoded array of argument values. |
| * Order affects rendering priority; some may never be rendered. |
| * @type {Array<string>} |
| */ |
| recommended: mercury.array([]), |
| |
| /* |
| * The autocomplete suggestions for each of this method's inputs. |
| * @type {Array<string>} |
| */ |
| inputSuggestions: mercury.array([]), |
| |
| /* |
| * Whether the method form is in its expanded state or not. |
| * @type {boolean} |
| */ |
| expanded: mercury.value(false) |
| }); |
| |
| var events = mercury.input([ |
| 'displayMethodForm', // the main event used to prepare the form data |
| 'methodStart', // notify parent element of RPC start |
| 'methodEnd', // notify parent element of RPC end result |
| 'runAction', // run the RPC with given arguments |
| 'expandAction', // show/hide method arguments |
| 'starAction', // star/unstar a method invocation |
| 'removeRecommendation', // remove and reload recommended invocations |
| 'removeInputSuggestion', // remove and reload input suggestions |
| 'toast' |
| ]); |
| wireUpEvents(state, events); |
| |
| return { |
| state: state, |
| events: events |
| }; |
| } |
| |
| /* |
| * Event handler that sets and prepares data into this form component. |
| * data needs to include "itemName", "interface", and "methodName". |
| */ |
| function displayMethodForm(state, events, data) { |
| // Set the given data into the state and prepare the method form. |
| state.itemName.set(data.itemName); |
| state.interface.set(data.interface); |
| state.methodName.set(data.methodName); |
| initializeInputArguments(state); |
| |
| // Prepare the remaining state asynchronously. |
| refreshInputSuggestions(state).catch(function(err) { |
| log.error('Could not get input suggestions for', data.methodName, err); |
| }); |
| loadStarredInvocations(state).catch(function(err) { |
| events.toast({ |
| text: 'Could not load stars for ' + data.methodName, |
| type: 'error' |
| }); |
| log.error('Could not load stars for', data.methodName, err); |
| }); |
| refreshRecommendations(state).catch(function(err) { |
| log.error('Could not get recommended invocations for', |
| data.methodName, err); |
| }); |
| } |
| |
| /* |
| * Clear the mercury values related to the input arguments. |
| */ |
| function initializeInputArguments(state) { |
| var param = getMethodData(state.interface(), state.methodName()); |
| var startingArgs = _.range(param.inArgs.length).map(function() { |
| return ''; // Initialize array with empty string values using lodash. |
| }); |
| setMercuryArray(state.args, startingArgs); |
| } |
| |
| /* |
| * Returns a promise that refreshes the suggestions to the input arguments. |
| */ |
| function refreshInputSuggestions(state) { |
| var param = getMethodData(state.interface(), state.methodName()); |
| var input = { |
| interface: state.interface(), |
| methodName: state.methodName(), |
| }; |
| return Promise.all( |
| // For each argname, predict which inputs should be suggested. |
| param.inArgs.map(function(inArg, i) { |
| return smartService.predict( |
| 'learner-method-input', |
| _.assign({argName: inArg.name}, input) |
| ).then(function(inputSuggestion) { |
| state.inputSuggestions.put(i, inputSuggestion); |
| }); |
| }) |
| ); |
| } |
| |
| /* |
| * Returns a promise that loads starred invocations into the state. |
| */ |
| function loadStarredInvocations(state) { |
| return store.getValue(constructStarredInvocationKey(state)).then( |
| function(result) { |
| var invocations = result || []; |
| state.put('starred', mercury.array(invocations)); |
| } |
| ).catch(function(err) { |
| log.error('Unable to load starred invocations from store', err); |
| return Promise.reject(err); |
| }); |
| } |
| |
| /* |
| * Returns a promise that update the store with the invocations from the state. |
| */ |
| function saveStarredInvocations(state) { |
| return store.setValue( |
| constructStarredInvocationKey(state), |
| state.starred() |
| ).catch(function(err) { |
| log.error('Unable to save starred invocations', err); |
| return Promise.reject(err); |
| }); |
| } |
| |
| /* |
| * Given an observed state, produce the storage key. |
| * The keys are of the form STARS|item|interface|method. |
| * The corresponding value is an array of invocations. |
| */ |
| var starsPrefix = 'STARS'; |
| function constructStarredInvocationKey(state) { |
| var parts = [ |
| starsPrefix, |
| state.itemName(), |
| hashInterface(state.interface()), |
| state.methodName() |
| ]; |
| return parts.join('|'); |
| } |
| |
| /* |
| * Returns a promise that refreshes the recommended values in the state. |
| */ |
| function refreshRecommendations(state) { |
| var input = { |
| interface: state.interface(), |
| methodName: state.methodName(), |
| }; |
| return smartService.predict('learner-method-invocation', input).then( |
| function(recommendations) { |
| setMercuryArray(state.recommended, recommendations); |
| } |
| ); |
| } |
| |
| /* |
| * Wire up the events for the method form mercury component. |
| * Note: Some events are left for the parent component to hook up. |
| */ |
| function wireUpEvents(state, events) { |
| events.displayMethodForm(displayMethodForm.bind(null, state, events)); |
| |
| // The run action triggers a start event, RPC call, and end event. |
| events.runAction(function(data) { |
| // This random value allows us to uniquely identify this RPC. |
| var randomID = uuid.v4(); |
| events.methodStart({ |
| runID: randomID |
| }); |
| |
| // Toast that the RPC is being run. |
| events.toast({ |
| text: 'Running ' + getMethodSignature(state(), data.args) |
| }); |
| |
| // Make the RPC, tracking when the method is in-progress or complete. |
| makeRPC(data).then(function success(result) { |
| events.methodEnd({ |
| runID: randomID, |
| args: data.args, |
| result: result |
| }); |
| }, function failure(error) { |
| events.methodEnd({ |
| runID: randomID, |
| error: error |
| }); |
| }).catch(function(err) { |
| log.error('Error handling makeRPC', err); |
| }); |
| }); |
| |
| events.expandAction(function() { |
| state.expanded.set(!state.expanded()); |
| }); |
| events.starAction(function(data) { |
| // Load the user's stars (in case there were other changes). |
| loadStarredInvocations(state).then(function() { |
| // If there is no argsStr given, compute it now. |
| var argsStr = data.argsStr || JSON.stringify(state.args()); |
| |
| // Depending on the star boolean, add/remove a star for the given args. |
| arraySet.set(state.starred, argsStr, data.star); |
| |
| // Save the user's star decision. |
| return saveStarredInvocations(state); |
| }).catch(function(err) { |
| events.toast({ |
| text: 'Error while starring', |
| type: 'error' |
| }); |
| log.error('Error while starring invocation', err); |
| }); |
| }); |
| |
| // This event removes the specified recommendation. |
| // Afterwards, the recommendations are refreshed. |
| events.removeRecommendation(function(args) { |
| var input = { |
| interface: state.interface(), |
| methodName: state.methodName(), |
| value: JSON.stringify(args), |
| reset: true |
| }; |
| |
| smartService.update('learner-method-invocation', input).then(function() { |
| log.debug('Removing method invocation', input); |
| return refreshRecommendations(state); |
| }).then(function() { |
| return events.toast({ |
| text: 'Removed suggestion: ' + getMethodSignature(state(), args), |
| type: 'info' |
| }); |
| }).catch(function(err) { |
| var errText = 'Failed to remove suggestion'; |
| log.error(errText, err); |
| events.toast({ |
| text: errText, |
| type: 'error' |
| }); |
| }); |
| }); |
| |
| // This event removes the specified input suggestion for an argument. |
| // Afterwards, all input suggestions are refreshed. |
| events.removeInputSuggestion(function(data) { |
| var input = { |
| interface: state.interface(), |
| methodName: state.methodName(), |
| argName: data.argName, |
| value: data.arg, |
| reset: true |
| }; |
| |
| smartService.update('learner-method-input', input).then(function() { |
| log.debug('Removing method input', input); |
| return refreshInputSuggestions(state); |
| }).catch(function(err) { |
| var errText = 'Failed to remove suggestion'; |
| log.error(errText, err); |
| events.toast({ |
| text: errText, |
| type: 'error' |
| }); |
| }); |
| }); |
| } |
| |
| /* |
| * The main rendering function for the method form. |
| * Shows the method signature and a run button. |
| * If arguments are necessary, then they can be shown when the form is expanded. |
| */ |
| function render(state, events) { |
| // Display the method name/sig header with expand/collapse/run button. |
| var methodNameHeader = renderMethodHeader(state, events); |
| |
| // Return immediately if we don't need arguments or haven't expanded. |
| if (state.args.length === 0 || !state.expanded) { |
| return makeMethodTooltip(state, h('div.method-input', methodNameHeader)); |
| } |
| |
| // Render the stars first, and if there's extra room, the recommendations. |
| // TODO(alexfandrianto): Show 1 of the pinned items even when not expanded? |
| var recs = renderStarsAndRecommendations(state, events); |
| |
| // Form for filling up the arguments |
| var argForm = []; // contains form elements |
| for (var i = 0; i < state.args.length; i++) { |
| argForm.push(renderMethodInput(state, events, i)); |
| } |
| |
| // Setup the STAR and RUN buttons. |
| var starButton = renderStarUserInputButton(state, events); |
| var runButton = renderRPCRunButton(state, events); |
| |
| var footer = h('div.method-input-expanded', [argForm, runButton, starButton]); |
| return makeMethodTooltip(state, |
| h('div.method-input', [methodNameHeader, recs, footer])); |
| } |
| |
| /* |
| * Wrap the method form with a tooltip. |
| */ |
| function makeMethodTooltip(state, child) { |
| // The tooltip contains the documentation for the method name. |
| var methodSig = getMethodData(state.interface, state.methodName); |
| var tooltip = methodSig.doc || '<no description>'; |
| |
| // If the method takes input, add documentation about the input arguments. |
| if (methodSig.inArgs.length > 0) { |
| tooltip += '\n\nParameters'; |
| methodSig.inArgs.forEach(function(inArg) { |
| tooltip += '\n'; |
| tooltip += '- ' + inArg.name + ': ' + inArg.type.toString(); |
| if (inArg.doc) { |
| tooltip += ' ' + inArg.doc; |
| } |
| }); |
| } |
| |
| // If the method returns output, add documentation about the output arguments. |
| if (methodSig.outArgs.length > 0) { |
| tooltip += '\n\nOutput'; |
| methodSig.outArgs.forEach(function(outArg) { |
| tooltip += '\n'; |
| tooltip += '- ' + outArg.name + ': ' + outArg.type.toString(); |
| if (outArg.doc) { |
| tooltip += ' ' + outArg.doc; |
| } |
| }); |
| } |
| |
| // If the method has tags, show the tag types and values. |
| if (methodSig.tags.length > 0) { |
| tooltip += '\n\nTags'; |
| methodSig.tags.forEach(function(tag) { |
| tooltip += '\n'; |
| tooltip += '- ' + tag.val + ': ' + tag._type.name; |
| }); |
| } |
| |
| return h('core-tooltip.tooltip.method-tooltip', { |
| 'label': tooltip, |
| 'position': 'top' |
| }, child); |
| } |
| |
| /* |
| * Draw the method header: A method signature and either a run or expand button. |
| */ |
| function renderMethodHeader(state, events) { |
| if (state.args.length === 0) { |
| return renderInvocation(state, events); |
| } |
| var labelText = getMethodSignature(state); |
| var label = makeMethodLabel(labelText); |
| |
| var expand = h('a.drill', { |
| 'href': 'javascript:;', |
| 'title': state.expanded ? 'Hide form' : 'Show form', |
| 'ev-click': mercury.event(events.expandAction) |
| }, h('core-icon.action-icon', { |
| attributes: { |
| 'icon': state.expanded ? 'expand-more' : 'chevron-right' |
| } |
| })); |
| |
| return h('div.item.card', [label, expand]); |
| } |
| |
| /* |
| * Extracts a pretty-printed version of the method signature. |
| * If the method has no input args, print the name |
| * If the method does have input args, also show parentheses |
| * @args is an optional list whose elements are also printed out. |
| */ |
| function getMethodSignature(state, args) { |
| var methodName = state.methodName; |
| var param = getMethodData(state.interface, methodName); |
| var text = methodName; |
| var hasArgs = (param.inArgs.length > 0); |
| if (hasArgs) { |
| text += '('; |
| } |
| if (args !== undefined) { |
| for (var i = 0; i < param.inArgs.length; i++) { |
| if (i > 0) { |
| text += ', '; |
| } |
| text += args[i]; |
| } |
| } else if (hasArgs) { |
| text += '...'; |
| } |
| if (hasArgs) { |
| text += ')'; |
| } |
| if (param.inStream || param.outStream) { |
| text += ' - streaming'; |
| } |
| return text; |
| } |
| |
| |
| var SOFTCAP_INVOCATIONS = 3; |
| /* |
| * Renders the starred invocations followed by the recommended ones. Stars are |
| * shown with no limit. Recommendations are only shown if there is extra room. |
| */ |
| function renderStarsAndRecommendations(state, events) { |
| var s = state.starred.map( |
| function addStarred(invocation) { |
| return renderInvocation(state, events, invocation); |
| } |
| ); |
| |
| // Add the remaining recommenations, as long as they are not duplicates and |
| // don't exceed the soft cap on the # of invocations shown. |
| var remainingRecommendations = Math.min( |
| SOFTCAP_INVOCATIONS - s.length, |
| state.recommended.length |
| ); |
| var count = 0; |
| state.recommended.forEach(function(rec) { |
| if (count < remainingRecommendations && state.starred.indexOf(rec) === -1) { |
| s.push(renderInvocation(state, events, rec, true)); |
| count++; |
| } |
| }); |
| |
| return s; |
| } |
| |
| /* |
| * Render the invocation, showing the method signature and a run button. |
| * argsStr is optional and is used for starred and recommended invocations. |
| * When given, then the card is smaller and has a star icon. |
| */ |
| function renderInvocation(state, events, argsStr, recommended) { |
| var noArgs = argsStr === undefined; |
| var args = noArgs ? [] : JSON.parse(argsStr); |
| var labelText = getMethodSignature(state, args); |
| var label = makeMethodLabel(labelText); |
| |
| var runButton = h('a.drill', { |
| 'href': 'javascript:;', |
| 'title': 'Run ' + state.methodName, |
| 'ev-click': getRunEvent(state, events, args) |
| }, renderPlayIcon()); |
| |
| if (noArgs) { |
| return h('div.item.card', [label, runButton]); |
| } |
| |
| var starred = state.starred.indexOf(argsStr) !== -1; |
| var starButton = h('a.drill.star', { |
| 'href': 'javascript:;', |
| 'title': starred ? 'Forget Method Call' : 'Save Method Call', |
| 'ev-click': mercury.event(events.starAction, { |
| argsStr: argsStr, |
| star: !starred |
| }) |
| }, renderStarIcon(starred)); |
| |
| var negFeedback; |
| if (recommended) { |
| negFeedback = h('div.action-bar', h('paper-fab', { |
| attributes: { |
| 'aria-label': 'Remove suggestion', |
| 'title': 'Remove suggestion', |
| 'icon': 'clear', |
| 'mini': true |
| }, |
| 'ev-click': events.removeRecommendation.bind(null, args) |
| })); |
| } |
| |
| return h('div.item.card.invocation', |
| [starButton, label, runButton, negFeedback] |
| ); |
| } |
| |
| /* |
| * Render a method label using labelText. |
| */ |
| function makeMethodLabel(labelText) { |
| return h('div.label', labelText); |
| } |
| |
| /* |
| * Draws a single method argument input using the paper-autocomplete element. |
| * Includes a placeholder and suggestions from the internal state. |
| */ |
| function renderMethodInput(state, events, index) { |
| var methodName = state.methodName; |
| var inArg = getMethodData(state.interface, state.methodName).inArgs[index]; |
| var argName = inArg.name; |
| var argTypeStr = inArg.type.name || inArg.type.toString(); |
| var inputSuggestions = state.inputSuggestions[index]; |
| var args = state.args; |
| |
| // The children are the suggestions for this paper-autocomplete input. |
| var children = inputSuggestions.map(function(suggestion) { |
| return h('paper-item', { |
| attributes: { |
| // Attach as an attribute for later retrieval |
| 'input-suggestion': suggestion |
| } |
| }, suggestion); |
| }); |
| |
| // Event used to update state when the value is changed. |
| var changeEvent = new PropertyValueEvent(function(data) { |
| log.debug(methodName, argName, 'value changed.', data); |
| args[index] = data; |
| }, 'value'); |
| |
| // TODO(alexfandrianto): It may be nice to link feedback between the |
| // method-input and method-invocation learners. |
| // Event used to remove an input suggestion. |
| var removeEvent = function(e) { |
| events.removeInputSuggestion({ |
| argName: argName, |
| arg: e.target.getAttribute('input-suggestion') |
| }); |
| }; |
| |
| // TODO(alexfandrianto): Note that Mercury and Polymer create a bug together. |
| // Polymer normally captures internal events and stops them from propagating. |
| // Unfortunately, Mercury reads and replays events using capturing mode. |
| // That means spurious 'change' and 'input' events may appear occasionally. |
| var elem = h('paper-autocomplete.method-input-item.autocomplete', { |
| 'key': state.itemName, // Enforce element refresh when switching items |
| attributes: { |
| 'label': argName + ' (' + argTypeStr + ')', |
| 'value': args[index], |
| 'spellcheck': 'false', |
| 'maxItems': METHOD_INPUT_MAX_ITEMS |
| }, |
| 'ev-change': changeEvent, |
| 'ev-delete-item': removeEvent |
| }, children); |
| |
| return elem; |
| } |
| |
| /* |
| * Draws the STAR button with the star event, which saves the user's input. |
| */ |
| function renderStarUserInputButton(state, events) { |
| var starButton = h( |
| 'paper-button.secondary', |
| { |
| 'href': 'javascript:;', |
| attributes: { |
| 'raised': 'true' |
| }, |
| 'ev-click': mercury.event(events.starAction, { |
| star: true |
| }) |
| }, |
| [ |
| renderStarIcon(false), |
| h('span', 'Save') |
| ] |
| ); |
| return starButton; |
| } |
| |
| /* |
| * Draws the RUN button with the RPC run event with the user's input as args. |
| */ |
| function renderRPCRunButton(state, events) { |
| var runButton = h( |
| 'paper-button', |
| { |
| 'href': 'javascript:;', |
| attributes: { |
| 'raised': 'true' |
| }, |
| 'ev-click': getRunEvent(state, events, state.args) |
| }, |
| [ |
| renderPlayIcon(), |
| h('span', 'Run') |
| ] |
| ); |
| return runButton; |
| } |
| |
| /* |
| * The generic run event for making RPCs. In general, the arguments are taken |
| * from state.args, a starred invocation's arguments, or a recommendation's. |
| */ |
| function getRunEvent(state, events, args) { |
| return mercury.event(events.runAction, { |
| name: state.itemName, |
| methodName: state.methodName, |
| args: args |
| }); |
| } |
| |
| /* |
| * Render a star icon. |
| */ |
| function renderStarIcon(starred) { |
| return h('core-icon.action-icon' + (starred ? '.starred' : ''), { |
| attributes: { |
| 'icon': starred ? 'star' : 'star-outline', |
| 'alt': starred ? 'saved' : 'recommended' |
| } |
| }); |
| } |
| |
| /* |
| * Render a play icon. |
| */ |
| function renderPlayIcon() { |
| return h('core-icon..action-icon', { |
| attributes: { |
| 'icon': 'av:play-circle-outline', |
| 'alt': 'run' |
| } |
| }); |
| } |