blob: 8ab341be5bb816ad99d65bbe8bb6b966ccf87f87 [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 _ = 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'
}
});
}