Skip to content

✨🏗 Introduces new amp-story bookend API#14613

Merged
newmuis merged 10 commits intoampproject:masterfrom
Enriqe:bookend-components
Apr 27, 2018
Merged

✨🏗 Introduces new amp-story bookend API#14613
newmuis merged 10 commits intoampproject:masterfrom
Enriqe:bookend-components

Conversation

@Enriqe
Copy link
Copy Markdown
Contributor

@Enriqe Enriqe commented Apr 13, 2018

In preparation for #12167 (building new components for the bookend), we decided on changing how the API looks for building the bookend.

This PR introduces the minimum code necessary to be able to build a bookend using the new API, while keeping it backwards compatible with the old API.

It introduces a delegator class that dispatches the components to their corresponding builder classes. This in preparation for the new components that will be introduced in the future. With this delegator pattern, it will be simple to add a new component: just create a new class like /bookend/components/article.js that extends from AbstractBookendComponent, and add it to the componentsMap. The delegator class will take care of the rest.

Note: When old version is deprecated, backward compatibility code must be deleted for cleanup. (#14591).

@Enriqe Enriqe changed the title 🏗 Paving road for new amp-story API 🏗 Paving road for new amp-story bookend API Apr 13, 2018
@Enriqe
Copy link
Copy Markdown
Contributor Author

Enriqe commented Apr 13, 2018

Sending out the first version of the PR. After it looks solid I will write tests.

@Enriqe
Copy link
Copy Markdown
Contributor Author

Enriqe commented Apr 13, 2018

Also, I didn't change how the share-providers component is build to keep the change gradual. If we think this is the right approach, I will start moving the share-providers component to use this design.

Copy link
Copy Markdown
Member

@alanorozco alanorozco left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM pending other reviewers and a couple of comments.


this.requestService_.loadBookendConfig().then(config => {
const providers = config && config['share-providers'];
// TODO(#14591): Remove when old version is deprecated.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be useful to clarify what the "old version" refers to.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, here and elsewhere in the PR

this.requestService_.loadBookendConfig().then(config => {
const providers = config && config['share-providers'];
// TODO(#14591): Remove when old version is deprecated.
const providers = config && (config[SHARE_PROVIDERS] ||
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we do this type of line alignment in our codebase.

"app_id": "254325784911610"
},
"whatsapp"
]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: +1 space

"bookend-version": "v2.0",
"components": [
{
"type": "share-providers",
Copy link
Copy Markdown
Contributor

@newmuis newmuis Apr 16, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we put this inline as a component, we need to figure out how to communicate:

(1) Don't communicate; allow reordering: I would assume this to be the default; wherever publishers put the share-providers component, it shows up. This is problematic because we generally want to ensure that it comes first, that there is at most one, and that if unspecified, we add it as first anyway.
(2) Don't communicate; disallow reordering: We take the first share-providers instance we find and make it the first component. If they specify more than one, we silently drop the rest (or somehow merge them).
(3) Console error: If they specify a share-providers component as anything other than the first component, we throw a console error and disregard that component entirely. This provides flexibility, guarantees there is only one, and guarantees that it comes first, but is potentially confusing to publishers (e.g. if they don't look at the console)
(4) Separate entity: We can move the share-providers out of the components object (as it was before), and we insert it as the first thing at runtime. This guarantees there is only one and that it is first, but is relatively unflexible.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I'm leaning more towards moving it out of the components object, as it was before. It seems that with any approach that we take, we would enforce the position of the share-providers at some level.

Having it outside of the component object seems to align with these semantics.


// TODO(#14591): Clean when older version is deprecated.
const BOOKEND_VERSION_2 = 'v2.0';
const BOOKEND_VERSION = 'bookend-version';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: one of these refers to a key and one refers to a value, but the names don't make that clear

.buildFromJson(response['components']),
};
} else {
this.config_ = {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe console.warn('Version 1 of the amp-story bookend is deprecated. Use version 2: [link]')

As a side note, we should publish docs somewhere so that [link] can link to something 😄 That part can come in a separate PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, will do this in a follow-up PR after this 👍


this.requestService_.loadBookendConfig().then(config => {
const providers = config && config['share-providers'];
// TODO(#14591): Remove when old version is deprecated.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, here and elsewhere in the PR

}

/**
* TODO(#14591)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the TODO?

*/
// TODO(alanorozco): Set story metadata in share config
setProviders_(providers) {
// TODO(#14591): Check if using new API and convert if so.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same, re: "new" API

* image: (string|undefinded)
* }}
*/
export let BookendComponentDef;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should explicitly typedef the different components. Like:

/**
 * @typedef {{
 *   type: string,
 *   title: string,
 *   url: string,
 *   image: (string|undefined)
 * }}
 */
let BookendArticleComponentDef;
/**
 * @typedef {{
 *   type: string,
 *   text: !Array<string>
 * }}
 */
let BookendTextComponentDef;

Then, union the different components for this:

/** @typedef {!BookendArticleComponentDef|!BookendTextComponentDef|...} */
let BookendComponentDef;

* type: string,
* share-providers: (Array<*>|undefined),
* title: (string|undefinded),
* url: (string|undefinded),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: s/undefinded/undefined/

@@ -0,0 +1,20 @@
{
"bookend-version": "v2.0",
"share-providers": [
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@newmuis not sure what you think of this interface. It looks cleaner to me to just write the providers name, and in the case of facebook make it an object with the app_id as the value. Downside is that maybe it's not as verbose as writing {"provider": "facebook", "app_id": "1235"}. WDYT?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are other options that one may or may not need to set, apart from the app id;. This format loses the ability to specify more than one parameter per provider.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. Will make the change.

Copy link
Copy Markdown
Contributor

@gmajoulet gmajoulet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work, just some nits!

@@ -0,0 +1,20 @@
{
"bookend-version": "v2.0",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we keep this consistent with the amp-story version number? The current version would be 0.1, and this one 1.0.
Might be confusing for external developers that amp-story 0.1 uses the bookend v1, and amp-story 1.0 uses the bookend v2.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I was thinking about it. Lets do it!

* @private
*/
renderComponents_(components) {
this.vsync_.mutate(() => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mutate phase of a vsync is meant for write operations.
Please move the getInnerContainer into the measure phase to make sure read/write operations are properly batched by the vsync tasks implementation to avoid layout thrashing.

this.vsync_.run({
  measure: state => {
    state.innerContainer = this.getInnerContainer_();
  },
  mutate: state => {
    state.innerContainer.appendChild(...);
  },
}, {})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

drive-by: we are trying to move away from using vsync. Same idea but preferred method is baseElement.measureMutateElement()

* @param {!Object<string, (!JsonObject|boolean)>} providers
* TODO(#14591): Remove when bookend API v1 is deprecated.
* If using the bookend API v2, converts the contents to match with the bookend API v1.
* @param {*} providers
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: @param {!Array<!Object|String>}

* TODO(#14591): Remove when bookend API v1 is deprecated.
* If using the bookend API v2, converts the contents to match with the bookend API v1.
* @param {*} providers
* @return {!Array<!JsonObject|string>} providers
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's actually returning an object, the return might look like

@return {!Object<string, boolean|!Object<string, string>>}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Woops, I think I got the param and return mixed up. Thanks for looking.

* @return {!Array<!JsonObject|string>} providers
*/
parseToClassicApi_(providers) {
const providerObjs = {};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this name hints at a list of objects, when it's actually an object (or a Map?)
What do you think if naming this providersMap or anything else more accurate?

*/
parseToClassicApi_(providers) {
const providerObjs = {};
providers.map(currentProvider => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Please use a forEach here, as you're not using the returned array generated by the map function


return this.bookend_.loadConfig(false /** applyConfig */).then(
config => {
// TODO(#14591): Remove when bookend API v1 is deprecated.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: please be more specific on which part needs to be removed


/** @override */
static build(articleJson) {
if (!articleJson['title'] || !articleJson['url']) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you'll have to do this for every bookend component, what do you think of a validate method in the AbstractBookendComponent?

static build(titleJson) {
const title = {
type: 'article-set-title',
heading: titleJson.title,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should you validate that this string exists too?

}

/** @override */
static buildTemplate(articleData) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to migrate away from using simple templates and instead use the static HTML template helper where possible: #14659


/**
* @typedef {{
* bookend-vesion: string,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: version

@Enriqe Enriqe force-pushed the bookend-components branch 3 times, most recently from be5964e to c042222 Compare April 20, 2018 18:22
@Enriqe Enriqe force-pushed the bookend-components branch from c042222 to 3ad8924 Compare April 26, 2018 00:35
@Enriqe Enriqe changed the title 🏗 Paving road for new amp-story bookend API ✨🏗 Introduces new amp-story bookend API Apr 26, 2018
@Enriqe Enriqe self-assigned this Apr 26, 2018
addAttributesToElement(template, dict({'href': articleData.url}));

if (articleData.image) {
const ampImg = htmlFor(doc)
Copy link
Copy Markdown
Contributor

@newmuis newmuis Apr 26, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: use html instead of htmlFor(doc)

Copy link
Copy Markdown
Contributor

@gmajoulet gmajoulet left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work!!

@Enriqe Enriqe force-pushed the bookend-components branch 2 times, most recently from 7cbd539 to 8a7cc75 Compare April 26, 2018 20:07
@Enriqe Enriqe force-pushed the bookend-components branch from 8a7cc75 to 6d4bd17 Compare April 26, 2018 21:00
@Enriqe
Copy link
Copy Markdown
Contributor Author

Enriqe commented Apr 27, 2018

Thanks! 🙌 It's now ready to merge.

@newmuis newmuis merged commit a4634f2 into ampproject:master Apr 27, 2018
@Enriqe Enriqe deleted the bookend-components branch April 27, 2018 15:59
* @return {!Array<BookendComponentDef>}
*/
static buildFromJson(components) {
return components
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Simpler:

return components.reduce((acc, component) => {
  const klass = componentClassFor(component);
  if (!klass || !klass.isValid(component)) {
    return;
  }
  acc.push(klass.build(component));
  return acc;
}, []);

* Map used to dispatch the components to their specific builder classes.
* @const {!Object<string, ./components/abstract.AbstractBookendComponent>}
*/
export const componentsMap = {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a loosely-enforced rule of not allocating complex objects on module import due to parsing lifecycle. This can be mitigated by replacing componentsMap with a simple factory function containing a switch clause.

Using said function in examples below.

* limitations under the License.
*/

/**
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be replaced with an interface.


/** @override */
static isValid(articleJson) {
if (!articleJson['title'] || !articleJson['url']) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could save a few bytes from the binary by returning the assertions:

return user().assert(false, 'Articles must contain `title` and `url` fields, skipping invalid.');

const html = htmlFor(doc);
const template =
html`
<a class="i-amphtml-story-bookend-article"
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Awesome, thanks for using static-template!

Small note, the binaries resulting from usages of htmlFor are a bit bloated. If you want, you can add a comment including the tracking bugs #14657, #14658

.buildFromJson(response['components']),
'share-providers': response['share-providers'],
});
} else {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to return from the special case and set the "meat" of the method below:

if (response[BOOKEND_VERSION_KEY] === BOOKEND_VERSION_1) {
  // ...
  return;
}
dev().warn(TAG, `Version ${BOOKEND_VERSION_0} of the amp-story`);
// ...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But then the rest of the function won't be ran after the break, right? We still want to run whatever's after the if-else.

if (response[BOOKEND_VERSION_KEY] === BOOKEND_VERSION_1) {
  // ...  
  return;
}

// This won't run anymore:

if (applyConfig) {
    this.setConfig_(this.config_);
}

return this.config_;

* @param {!Array<!../bookend/bookend-component.BookendComponentDef>} components
* @private
*/
renderComponents_(components) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline mutate + assert-clause. You also don't need to put a DOM query in a measure context. You can also build outside the mutate, to unload the mutate batch.

dev().assertElement(this.bookendEl_, 'Error rendering amp-story-bookend.');
const fragment = BookendComponent.buildTemplates(components, this.win_.document));
const container =  this.getInnerContainer_();
this.resources_.mutate(() => container.appendChild(fragment));

Copy link
Copy Markdown
Contributor Author

@Enriqe Enriqe Apr 27, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jridgewell @calebcordry recommended me to stay away from vsync, and I don't think resources has a mutate property. Maybe with the mutateElement one? 🤔

/** @override */
static isValid(articleJson) {
if (!articleJson['title'] || !articleJson['url']) {
user().error(TAG,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can save a few bytes by returning the assertion directly:

return user().assert(false, 'Articles must contain...');

const providersMap = {};

providers.forEach(currentProvider => {
if (isObject(currentProvider) &&
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not entirely sure what the purpose of this is. Could it be made generic? Could the different cases be explained by virtue of naming vars or comments?

Also, simpler:

if (!isObject(currentProvider)) {
  providersMap[currentProvider] = true
  return;
}
if (currentProvider['provider'] == 'facebook') {
  providersMap['facebook'] = ({'app_id': currentProvider['app-id']});
  return;
}
providersMap[currentProvider['provider']] = true;

// TODO(alanorozco): Set story metadata in share config
setProviders_(providers) {
// TODO(#14591): Check if using bookend API v1.0 and convert it to be v0.1 compatible if so.
if (Array.isArray(providers)) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use isArray from src/types

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants