✨🏗 Introduces new amp-story bookend API#14613
Conversation
|
Sending out the first version of the PR. After it looks solid I will write tests. |
|
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. |
alanorozco
left a comment
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
It would be useful to clarify what the "old version" refers to.
There was a problem hiding this comment.
+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] || |
There was a problem hiding this comment.
I don't think we do this type of line alignment in our codebase.
examples/amp-story/bookendv2.json
Outdated
| "app_id": "254325784911610" | ||
| }, | ||
| "whatsapp" | ||
| ] |
examples/amp-story/bookendv2.json
Outdated
| "bookend-version": "v2.0", | ||
| "components": [ | ||
| { | ||
| "type": "share-providers", |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
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_ = { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
+1, here and elsewhere in the PR
| } | ||
|
|
||
| /** | ||
| * TODO(#14591) |
| */ | ||
| // TODO(alanorozco): Set story metadata in share config | ||
| setProviders_(providers) { | ||
| // TODO(#14591): Check if using new API and convert if so. |
| * image: (string|undefinded) | ||
| * }} | ||
| */ | ||
| export let BookendComponentDef; |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
nit: s/undefinded/undefined/
| @@ -0,0 +1,20 @@ | |||
| { | |||
| "bookend-version": "v2.0", | |||
| "share-providers": [ | |||
There was a problem hiding this comment.
@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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Got it. Will make the change.
gmajoulet
left a comment
There was a problem hiding this comment.
Great work, just some nits!
examples/amp-story/bookendv2.json
Outdated
| @@ -0,0 +1,20 @@ | |||
| { | |||
| "bookend-version": "v2.0", | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Yeah I was thinking about it. Lets do it!
| * @private | ||
| */ | ||
| renderComponents_(components) { | ||
| this.vsync_.mutate(() => { |
There was a problem hiding this comment.
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(...);
},
}, {})
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
It's actually returning an object, the return might look like
@return {!Object<string, boolean|!Object<string, string>>}
There was a problem hiding this comment.
Woops, I think I got the param and return mixed up. Thanks for looking.
| * @return {!Array<!JsonObject|string>} providers | ||
| */ | ||
| parseToClassicApi_(providers) { | ||
| const providerObjs = {}; |
There was a problem hiding this comment.
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 => { |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
Nit: please be more specific on which part needs to be removed
|
|
||
| /** @override */ | ||
| static build(articleJson) { | ||
| if (!articleJson['title'] || !articleJson['url']) { |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
Should you validate that this string exists too?
| } | ||
|
|
||
| /** @override */ | ||
| static buildTemplate(articleData) { |
There was a problem hiding this comment.
We want to migrate away from using simple templates and instead use the static HTML template helper where possible: #14659
|
|
||
| /** | ||
| * @typedef {{ | ||
| * bookend-vesion: string, |
be5964e to
c042222
Compare
… separate entity. uses individual typedef for new components.
c042222 to
3ad8924
Compare
| addAttributesToElement(template, dict({'href': articleData.url})); | ||
|
|
||
| if (articleData.image) { | ||
| const ampImg = htmlFor(doc) |
There was a problem hiding this comment.
nit: use html instead of htmlFor(doc)
7cbd539 to
8a7cc75
Compare
8a7cc75 to
6d4bd17
Compare
|
Thanks! 🙌 It's now ready to merge. |
| * @return {!Array<BookendComponentDef>} | ||
| */ | ||
| static buildFromJson(components) { | ||
| return components |
There was a problem hiding this comment.
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 = { |
There was a problem hiding this comment.
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. | ||
| */ | ||
|
|
||
| /** |
There was a problem hiding this comment.
This should be replaced with an interface.
|
|
||
| /** @override */ | ||
| static isValid(articleJson) { | ||
| if (!articleJson['title'] || !articleJson['url']) { |
There was a problem hiding this comment.
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" |
| .buildFromJson(response['components']), | ||
| 'share-providers': response['share-providers'], | ||
| }); | ||
| } else { |
There was a problem hiding this comment.
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`);
// ...There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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));There was a problem hiding this comment.
@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, |
There was a problem hiding this comment.
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) && |
There was a problem hiding this comment.
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)) { |
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).