Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Action handler subsystem #1261

Merged
merged 1 commit into from
Dec 29, 2015
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions build-system/tasks/presubmit-checks.js
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,20 @@ var forbiddenTerms = {
]
},
// Service factories that should only be installed once.
'installActionService': {
message: privateServiceFactory,
whitelist: [
'src/service/action-impl.js',
'src/amp-core-service.js',
],
},
'installActionHandler': {
message: privateServiceFactory,
whitelist: [
'src/service/action-impl.js',
'extensions/amp-access/0.1/amp-access.js',
],
},
'installCidService': {
message: privateServiceFactory,
whitelist: [
Expand Down
13 changes: 13 additions & 0 deletions examples/article-access.amp.html
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@
amp-img {
background-color: #f4f4f4;
}

.login-section {
margin: 16px;
margin-top: 24px;
padding: 16px;
background: #ffc;
}

</style>
<script async custom-element="amp-access" src="https://cdn.ampproject.org/v0/amp-access-0.1.js"></script>
<style>body {opacity: 0}</style><noscript><style>body {opacity: 1}</style></noscript>
Expand Down Expand Up @@ -170,6 +178,11 @@ <h1 itemprop="headline">Lorem Ipsum</h1>
<p class="brand">PublisherName News Reporter<p>
</div>
</div>

<section class="login-section">
<a on="tap:amp-access.login">Login to read more!</a>
</section>

<div class="article-body" itemprop="articleBody">
<p>
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
Expand Down
11 changes: 11 additions & 0 deletions extensions/amp-access/0.1/amp-access.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
* limitations under the License.
*/

import {actionServiceFor} from '../../../src/action';
import {assertHttpsUrl} from '../../../src/url';
import {getService} from '../../../src/service';
import {installStyles} from '../../../src/styles';
Expand Down Expand Up @@ -122,6 +123,16 @@ export class AccessService {

/** @private */
startInternal_() {
actionServiceFor(this.win).installActionHandler(
this.accessElement_, this.handleAction_.bind(this));
}

/**
* @param {!ActionInvocation} invocation
* @private
*/
handleAction_(invocation) {
log.fine(TAG, 'Invocation: ', invocation);
}
}

Expand Down
1 change: 1 addition & 0 deletions src/custom-element.js
Original file line number Diff line number Diff line change
Expand Up @@ -789,6 +789,7 @@ export function createAmpElementProto(win, name, implementationClass) {
const actionQueue = assert(this.actionQueue_);
this.actionQueue_ = null;

// TODO(dvoytenko, #1260): dedupe actions.
actionQueue.forEach(invocation => {
this.executionAction_(invocation, true);
});
Expand Down
69 changes: 59 additions & 10 deletions src/service/action-impl.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,20 @@
* limitations under the License.
*/

import {assert} from '../asserts';
import {getService} from '../service';
import {log} from '../log';
import {timer} from '../timer';

/** @const {string} */
const TAG_ = 'Action';

/** @const {string} */
const ACTION_MAP_ = '__AMP_ACTION_MAP__' + Math.random();

/** @const {string} */
const ACTION_QUEUE_ = '__AMP_ACTION_QUEUE__';

/** @const {string} */
const DEFAULT_METHOD_ = 'activate';

Expand Down Expand Up @@ -120,6 +125,40 @@ export class ActionService {
this.invoke_(target, method, source, event, null);
}

/**
* Installs action handler for the specified element.
* @param {!Element} target
* @param {function(!ActionInvocation)} handler
*/
installActionHandler(target, handler) {
const debugid = target.tagName + '#' + target.id;
assert(target.id && target.id.substring(0, 4) == 'amp-',
'AMP element is expected: %s', debugid);

const currentQueue = target[ACTION_QUEUE_];
if (currentQueue) {
assert(Object.prototype.toString.call(currentQueue) == '[object Array]',
'Expected queue to be an array: %s', debugid);
}

// Override queue with the handler.
target[ACTION_QUEUE_] = {'push': handler};

// Dequeue the current queue.
if (currentQueue) {
timer.delay(() => {
// TODO(dvoytenko, #1260): dedupe actions.
currentQueue.forEach(invocation => {
try {
handler(invocation);
} catch (e) {
log.error(TAG_, 'Action execution failed:', invocation, e);
}
});
}, 1);
}
}

/**
* @param {!Element} source
* @param {string} actionEventType
Expand Down Expand Up @@ -169,22 +208,32 @@ export class ActionService {

// TODO(dvoytenko): implement common method handlers, e.g. "toggleClass"

// Only amp elements are allowed to proceed further.
if (target.tagName.toLowerCase().substring(0, 4) != 'amp-') {
this.actionInfoError_('Target must be an AMP element', actionInfo,
target);
// AMP elements.
if (target.tagName.toLowerCase().substring(0, 4) == 'amp-') {
if (target.enqueAction) {
target.enqueAction(invocation);
} else {
this.actionInfoError_('Unrecognized AMP element "' +
target.tagName.toLowerCase() + '". ' +
'Did you forget to include it via <script custom-element>?',
actionInfo, target);
}
return;
}

if (!target.enqueAction) {
this.actionInfoError_('Unrecognized AMP element "' +
target.tagName.toLowerCase() + '". ' +
'Did you forget to include it via <script custom-element>?',
actionInfo, target);
// Special elements with AMP ID.
if (target.id && target.id.substring(0, 4) == 'amp-') {
if (!target[ACTION_QUEUE_]) {
target[ACTION_QUEUE_] = [];
}
target[ACTION_QUEUE_].push(invocation);
return;
}

target.enqueAction(invocation);
// Unsupported target.
this.actionInfoError_(
'Target must be an AMP element or have an AMP ID',
actionInfo, target);
}

/**
Expand Down
93 changes: 93 additions & 0 deletions test/functional/test-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import {ActionService} from '../../src/service/action-impl';
import * as sinon from 'sinon';


describe('ActionService parseAction', () => {
Expand Down Expand Up @@ -259,3 +260,95 @@ describe('Action method', () => {
expect(inv.source).to.equal(child);
});
});


describe('Action interceptor', () => {

let sandbox;
let clock;
let action;
let target;

beforeEach(() => {
sandbox = sinon.sandbox.create();
clock = sandbox.useFakeTimers();
action = new ActionService(window);
target = document.createElement('target');
target.setAttribute('id', 'amp-test-1');
});

afterEach(() => {
action = null;
clock.restore();
clock = null;
sandbox.restore();
sandbox = null;
});

function getQueue() {
return target['__AMP_ACTION_QUEUE__'];
}


it('should not initialize until called', () => {
expect(getQueue()).to.be.undefined;
});

it('should queue actions', () => {
action.invoke_(target, 'method1', 'source1', 'event1');
action.invoke_(target, 'method2', 'source2', 'event2');

const queue = getQueue();
expect(Array.isArray(queue)).to.be.true;
expect(queue).to.have.length(2);

const inv0 = queue[0];
expect(inv0.target).to.equal(target);
expect(inv0.method).to.equal('method1');
expect(inv0.source).to.equal('source1');
expect(inv0.event).to.equal('event1');

const inv1 = queue[1];
expect(inv1.target).to.equal(target);
expect(inv1.method).to.equal('method2');
expect(inv1.source).to.equal('source2');
expect(inv1.event).to.equal('event2');
});

it('should dequeue actions after handler set', () => {
action.invoke_(target, 'method1', 'source1', 'event1');
action.invoke_(target, 'method2', 'source2', 'event2');

expect(Array.isArray(getQueue())).to.be.true;
expect(getQueue()).to.have.length(2);

const handler = sinon.spy();
action.installActionHandler(target, handler);
expect(Array.isArray(getQueue())).to.be.false;
expect(handler.callCount).to.equal(0);

clock.tick(10);
expect(handler.callCount).to.equal(2);

const inv0 = handler.getCall(0).args[0];
expect(inv0.target).to.equal(target);
expect(inv0.method).to.equal('method1');
expect(inv0.source).to.equal('source1');
expect(inv0.event).to.equal('event1');

const inv1 = handler.getCall(1).args[0];
expect(inv1.target).to.equal(target);
expect(inv1.method).to.equal('method2');
expect(inv1.source).to.equal('source2');
expect(inv1.event).to.equal('event2');

action.invoke_(target, 'method3', 'source3', 'event3');
expect(Array.isArray(getQueue())).to.be.false;
expect(handler.callCount).to.equal(3);
const inv2 = handler.getCall(2).args[0];
expect(inv2.target).to.equal(target);
expect(inv2.method).to.equal('method3');
expect(inv2.source).to.equal('source3');
expect(inv2.event).to.equal('event3');
});
});