Skip to content

Commit

Permalink
Add variable references in action method arg values (#6723)
Browse files Browse the repository at this point in the history
* implement var dereferencing in action arg values

* fix type error

* add unit tests for applyActionInfoArgs()

* fix lint error

* PR comments

* fix lint errors
  • Loading branch information
William Chou authored Dec 22, 2016
1 parent ad68815 commit 22290b7
Show file tree
Hide file tree
Showing 2 changed files with 231 additions and 50 deletions.
161 changes: 131 additions & 30 deletions src/service/action-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,18 +40,25 @@ const ELEMENTS_ACTIONS_MAP_ = {
'AMP': ['setState'],
};

/**
* A map of method argument keys to functions that generate the argument values
* given a local scope object. The function allows argument values to reference
* data in the event that generated the action.
* @typedef {Object<string,function(!Object):string>}
*/
let ActionInfoArgsDef;

/**
* @typedef {{
* event: string,
* target: string,
* method: string,
* args: ?JSONType,
* args: ?ActionInfoArgsDef,
* str: string
* }}
*/
let ActionInfoDef;


/**
* The structure that contains all details of the action method invocation.
* @struct
Expand Down Expand Up @@ -240,26 +247,29 @@ export class ActionService {

const actionInfo = action.actionInfo;

// Replace any variables in args with data in `event`.
const args = applyActionInfoArgs(actionInfo.args, event);

// Global target, e.g. `AMP`.
const globalTarget = this.globalTargets_[actionInfo.target];
if (globalTarget) {
const invocation = new ActionInvocation(
this.root_,
actionInfo.method,
actionInfo.args,
args,
action.node,
event);
globalTarget(invocation);
return;
}

const target = this.root_.getElementById(action.actionInfo.target);
const target = this.root_.getElementById(actionInfo.target);
if (!target) {
this.actionInfoError_('target not found', action.actionInfo, target);
this.actionInfoError_('target not found', actionInfo, target);
return;
}
this.invoke_(target, action.actionInfo.method, action.actionInfo.args,
action.node, event, action.actionInfo);
this.invoke_(target, actionInfo.method, args,
action.node, event, actionInfo);
}

/**
Expand Down Expand Up @@ -395,24 +405,26 @@ export function parseActionMap(s, context) {
if (tok.type == TokenType.EOF ||
tok.type == TokenType.SEPARATOR && tok.value == ';') {
// Expected, ignore.
} else if (tok.type == TokenType.LITERAL) {
} else if (tok.type == TokenType.LITERAL || tok.type == TokenType.ID) {

// Format: event:target.method

// Event: "event:"
const event = tok.value;

// Target: ":target."
assertToken(toks.next(), TokenType.SEPARATOR, ':');
const target = assertToken(toks.next(), TokenType.LITERAL).value;
assertToken(toks.next(), [TokenType.SEPARATOR], ':');
const target = assertToken(
toks.next(), [TokenType.LITERAL, TokenType.ID]).value;

// Method: ".method". Method is optional.
let method = DEFAULT_METHOD_;
let args = null;
peek = toks.peek();
if (peek.type == TokenType.SEPARATOR && peek.value == '.') {
toks.next(); // Skip '.'
method = assertToken(toks.next(), TokenType.LITERAL).value || method;
method = assertToken(
toks.next(), [TokenType.LITERAL, TokenType.ID]).value || method;

// Optionally, there may be arguments: "(key = value, key = value)".
peek = toks.peek();
Expand All @@ -425,13 +437,26 @@ export function parseActionMap(s, context) {
if (tok.type == TokenType.SEPARATOR &&
(tok.value == ',' || tok.value == ')')) {
// Expected: ignore.
} else if (tok.type == TokenType.LITERAL) {
} else if (tok.type == TokenType.LITERAL ||
tok.type == TokenType.ID) {
// Key: "key = "
const argKey = tok.value;
assertToken(toks.next(), TokenType.SEPARATOR, '=');
const argValue =
assertToken(toks.next(/* convertValue */ true),
TokenType.LITERAL).value;
assertToken(toks.next(), [TokenType.SEPARATOR], '=');
// Value is either a literal or a variable: "foo.bar.baz"
tok = assertToken(toks.next(/* convertValue */ true),
[TokenType.LITERAL, TokenType.ID]);
const argValueTokens = [tok];
// Variables have one or more dereferences: ".identifier"
if (tok.type == TokenType.ID) {
for (peek = toks.peek();
peek.type == TokenType.SEPARATOR && peek.value == '.';
peek = toks.peek()) {
tok = toks.next(); // Skip '.'.
tok = assertToken(toks.next(false), [TokenType.ID]);
argValueTokens.push(tok);
}
}
const argValue = getActionInfoArgValue(argValueTokens);
if (!args) {
args = map();
}
Expand Down Expand Up @@ -471,6 +496,67 @@ export function parseActionMap(s, context) {
return actionMap;
}

/**
* Returns a function that generates a method argument value for a given token.
* The function takes a single object argument `data`.
* If the token is an identifier `foo`, the function returns `data[foo]`.
* Otherwise, the function returns the token value.
* @param {Array<!TokenDef>} tokens
* @return {?function(!Object):string}
* @private
*/
function getActionInfoArgValue(tokens) {
if (tokens.length == 0) {
return null;
}
if (tokens.length == 1) {
return () => tokens[0].value;
} else {
return data => {
let current = data;
// Traverse properties of `data` per token values.
for (let i = 0; i < tokens.length; i++) {
const value = tokens[i].value;
if (current && current.hasOwnProperty(value)) {
current = current[value];
} else {
return null;
}
}
// Only allow dereferencing of primitives.
const type = typeof current;
if (type === 'string' || type === 'number' || type === 'boolean') {
return current;
} else {
return null;
}
};
}
}

/**
* Generates method arg values for each key in the given ActionInfoArgsDef
* with the data in the given event.
* @param {?ActionInfoArgsDef} args
* @param {?Event} event
* @return {?JSONType}
* @private Visible for testing only.
*/
export function applyActionInfoArgs(args, event) {
if (!args) {
return args;
}
const data = {};
if (event && event.detail) {
data['event'] = event.detail;
}
const applied = map();
Object.keys(args).forEach(key => {
applied[key] = args[key].call(null, data);
});
return applied;
}

/**
* @param {string} s
* @param {!Element} context
Expand All @@ -488,24 +574,23 @@ function assertActionForParser(s, context, condition, opt_message) {
/**
* @param {string} s
* @param {!Element} context
* @param {!{type: TokenType, value: *}} tok
* @param {TokenType} type
* @param {!TokenDef} tok
* @param {Array<TokenType>} types
* @param {*=} opt_value
* @return {!{type: TokenType, value: *}}
* @return {!TokenDef}
* @private
*/
function assertTokenForParser(s, context, tok, type, opt_value) {
function assertTokenForParser(s, context, tok, types, opt_value) {
if (opt_value !== undefined) {
assertActionForParser(s, context,
tok.type == type && tok.value == opt_value,
types.indexOf(tok.type) >= 0 && tok.value == opt_value,
`; expected [${opt_value}]`);
} else {
assertActionForParser(s, context, tok.type == type);
assertActionForParser(s, context, types.indexOf(tok.type) >= 0);
}
return tok;
}


/**
* @enum {number}
*/
Expand All @@ -514,8 +599,14 @@ const TokenType = {
EOF: 1,
SEPARATOR: 2,
LITERAL: 3,
ID: 4,
};

/**
* @typedef {{type: TokenType, value: *}}
*/
let TokenDef;

/** @private @const {string} */
const WHITESPACE_SET = ' \t\n\r\f\v\u00A0\u2028\u2029';

Expand All @@ -528,7 +619,6 @@ const STRING_SET = '"\'';
/** @private @const {string} */
const SPECIAL_SET = WHITESPACE_SET + SEPARATOR_SET + STRING_SET;


/** @private */
class ParserTokenizer {
/**
Expand All @@ -545,7 +635,7 @@ class ParserTokenizer {
/**
* Returns the next token and advances the position.
* @param {boolean=} opt_convertValues
* @return {!{type: TokenType, value: *}}
* @return {!TokenDef}
*/
next(opt_convertValues) {
const tok = this.next_(opt_convertValues || false);
Expand All @@ -556,7 +646,7 @@ class ParserTokenizer {
/**
* Returns the next token but keeps the current position.
* @param {boolean=} opt_convertValues
* @return {!{type: TokenType, value: *}}
* @return {!TokenDef}
*/
peek(opt_convertValues) {
return this.next_(opt_convertValues || false);
Expand Down Expand Up @@ -632,18 +722,29 @@ class ParserTokenizer {
return {type: TokenType.LITERAL, value, index: newIndex};
}

// A key
// Advance until next special character.
let end = newIndex + 1;
for (; end < this.str_.length; end++) {
if (SPECIAL_SET.indexOf(this.str_.charAt(end)) != -1) {
break;
}
}
const s = this.str_.substring(newIndex, end);
const value = convertValues && (s == 'true' || s == 'false') ?
s == 'true' : s;
newIndex = end - 1;
return {type: TokenType.LITERAL, value, index: newIndex};

// Boolean literal.
if (convertValues && (s == 'true' || s == 'false')) {
const value = (s == 'true');
return {type: TokenType.LITERAL, value, index: newIndex};
}

// Identifier.
if (!isNum(s.charAt(0))) {
return {type: TokenType.ID, value: s, index: newIndex};
}

// Key.
return {type: TokenType.LITERAL, value: s, index: newIndex};
}
}

Expand Down
Loading

0 comments on commit 22290b7

Please sign in to comment.