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

Try: Extensibility: Define anchor behavior as filtered blocks support #3318

Merged
merged 3 commits into from
Nov 14, 2017

Conversation

aduth
Copy link
Member

@aduth aduth commented Nov 2, 2017

Closes #2474

This pull request seeks to explore an extensibility pattern for block settings, initially targeting one of Gutenberg's own extension points: block anchors. Other candidates include className and supportHTML property, with the idea being that this could develop into a general pattern for plugin authors to extend block behaviors.

Implementation notes:

These changes make use of @wordpress/[email protected] to add a new hooks registry to the blocks default export, available at wp.blocks.addFilter. The behavior here of adding a hooks registry specific to blocks (vs. a global registry) is not settled, but enabled as part of changes introduced to the hooks module in WordPress/packages#40.

With these changes, a registerBlockType filter is fired before any block completes registration. Any filters may inspect and mutate the settings before the block is registered. The anchor supports behavior uses this opportunity to:

  • Add a new anchor attribute
  • Include a new <InspectorControls /> in the edit form for managing the anchor value
  • Overriding serialization to inject the id attribute at save time

As implemented, this is not a faithful adaptation. The main advantage is that extended behavior is now better isolated, vs. a previous ad-hoc solution which touched block parsing, factory, serialization, block API, and editor UI. This should prove to be more maintainable and scalable, keeping generalized behavior unaware to the existence of extensions.

There are a few compromises or open questions:

  • Previously the anchor inspector control would display a ClipboardButton to copy a link to the post at the specified anchor. If these are meant to be generic block behaviors, we need to decide if it makes sense for the anchor extension to be aware of post state, and how it should access these values.
  • If there is not a single hooks registry but instead module-specific hooks, we need to decide how to draw the line between block and editor hooks, and if, taking anchor as an example, it is reasonable to expect that core or plugin extensions should need to hook to separate module hook registries.
  • Overriding edit and save behavior is made challenging by the facts that (a) edit can be defined as a function or a Component class, (b) save is optional and falls back to the edit implementation, (c) save can return either a string or an element. For each of these, the complexity of the logic is managed in core behaviors, but exposing these as extensibility points means that the plugin authors must accommodate for these themselves. To the previous point, it might be that we want separate actions or filters to occur in overriding the specific edit and save behaviors (rather than having the extender override the save and edit properties)

To-Do:

  • Restore anchor tests

Testing instructions:

Verify that when inserting a Heading block, you have the option via Inspector controls to add an anchor. After adding an anchor, saving the post, and refreshing the page, you should note that the anchor value is preserved, and that the id attribute exists in the saved markup.

@aduth aduth added [Feature] Block API API that allows to express the block paradigm. [Feature] Extensibility The ability to extend blocks or the editing experience labels Nov 2, 2017
@codecov
Copy link

codecov bot commented Nov 2, 2017

Codecov Report

Merging #3318 into master will increase coverage by 0.14%.
The diff coverage is 82.35%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master    #3318      +/-   ##
==========================================
+ Coverage   33.92%   34.07%   +0.14%     
==========================================
  Files         254      257       +3     
  Lines        6729     6753      +24     
  Branches     1219     1226       +7     
==========================================
+ Hits         2283     2301      +18     
- Misses       3751     3754       +3     
- Partials      695      698       +3
Impacted Files Coverage Δ
blocks/api/parser.js 98.14% <ø> (-0.07%) ⬇️
blocks/library/heading/index.js 28.57% <ø> (ø) ⬆️
blocks/api/factory.js 87.17% <ø> (-0.63%) ⬇️
editor/modes/visual-editor/block.js 0% <0%> (ø) ⬆️
...or/components/block-inspector/advanced-controls.js 0% <0%> (-4.77%) ⬇️
blocks/block-edit/index.js 100% <100%> (ø)
blocks/api/serializer.js 98.18% <100%> (-0.07%) ⬇️
blocks/hooks/index.js 100% <100%> (ø)
blocks/api/registration.js 100% <100%> (ø) ⬆️
blocks/hooks/anchor.js 80% <80%> (ø)
... and 5 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update e0b29b5...7f3a223. Read the comment docs.

Copy link
Contributor

@youknowriad youknowriad left a comment

Choose a reason for hiding this comment

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

I like how this is shaping up.

To your point

Overriding edit and save behavior is made challenging

Do you think it would be a good idea to provide "helpers" as decorators.

   const newSettings = flow( [
    addInspectorControl( props => control ),
    addToolbarControl( props => control ),
    addContainer( { children } => <div>{ children }</div> ),
    extendSaveContainer( props => extraContainerProps )
   ] )( settings )

name,
attributes: get( window._wpBlocksAttributes, name ),
...settings,
};

return block;
settings = applyFilters( 'registerBlockType', settings, name );
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we revert the arguments to match the registerBlockType function signature? I know it's not convenient for the anchor filter because the name is useless.

Copy link
Member Author

Choose a reason for hiding this comment

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

It's awkward, I agree, but the behavior of filtering is such that we need the second argument to be the original value we expect to be filtered; in this case, the settings. All additional arguments are considered "extra", and in this case the name could prove valuable and is not otherwise made available.

See: https://developer.wordpress.org/reference/functions/apply_filters/

}

// Extend attributes with anchor determined by ID on the first node.
assign( settings.attributes, {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we avoid to edit the function parameter, Should we make this pure instead?

Copy link
Member Author

Choose a reason for hiding this comment

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

This sounds like something that would be difficult to enforce, and would have questionable value if not enforced consistently. If it could be enforced, I could potentially see some value in strict reference comparisons on before/after a filter is applied (similar to what we see in how Redux reducer values are used).

As implemented here, we get some performance gains from mutating the object directly rather than first creating a shallow clone. I think it's reasonable to consider the filtering as an extension of the internal behavior of registerBlockType, in which we have no qualms to mutate the settings object directly.

I could go either way personally, but the immediate value isn't obvious to me.


Anchors let you link directly to a specific block on a page. This property adds a field to define an id for the block and a button to copy the direct link.
- `supportAnchor` (default `false`): Anchors let you link directly to a specific block on a page. This property adds a field to define an id for the block and a button to copy the direct link.
Copy link
Contributor

Choose a reason for hiding this comment

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

should this be anchor instead of supportAnchor?

};

return settings;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

What your thoughts on unit testing this, is it possible, simple enough?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I should plan to write some tests here.

@youknowriad
Copy link
Contributor

Btw, I'm working on #2541 locally and the work being done here to simplify the parser and the serializer would really help when generalized to all the advanced attributes (custom className and also the generated classNamee).

@aduth
Copy link
Member Author

aduth commented Nov 3, 2017

Do you think it would be a good idea to provide "helpers" as decorators.

That's a neat idea to help abstract some of the more complex common behaviors!

@aduth aduth force-pushed the try/blocks-extensibility branch from 0a38901 to 6c22b41 Compare November 8, 2017 15:13
@aduth
Copy link
Member Author

aduth commented Nov 8, 2017

I've rebased and taken a revised approach to try to avoid concerns around block implementers needing to handle all the conditions which can occur in extending a block (e.g. save might be a function or a component, and a function returning an element or a markup string). The idea is that by applying filters more granularly (i.e. at serialization time, in addition to registration time), a plugin extender can be confident that a filter would only be fired if all prerequisites are already met (e.g. the getSaveContent.extraProps only applies if we know that save is implemented as a component returning an element).

There's not too many downsides, but a few potential concerns around:

  • How many hooks (entry points) we expose and how consistently we do so
  • Potential performance impact of firing potentially unhandled filters and actions
  • Naming and use-cases for filters (naming conventions, etc)
    • See: registerBlock (always firing with registered block settings), BlockEdit (overriding the element return value of a component), getSaveContent.extraProps (a conditionally-met "nested" filter)
  • Testing filters is not always straight-forward, because filterHandler( originalValue ) does not necessarily have the same result as applyFilters( 'handled-filter', originalValue ). Also, there is risk of memory leaks if hooks are not properly disposed.

@@ -55,7 +56,7 @@ export function getSaveContent( blockType, attributes ) {
return element;
}

const extraProps = {};
const extraProps = applyFilters( 'getSaveContent.extraProps', {}, blockType, attributes );
Copy link
Member

Choose a reason for hiding this comment

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

This one seems to be a very specific. Maybe it should be simply called addAdvancedAttributes instead of getSaveContent.extraProps to avoid dot notation in the name.

Copy link
Contributor

Choose a reason for hiding this comment

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

Actually, I think getSaveContent.extraProps convey the correct meaning. I don't care much about the dot notation

}

export default function anchor( { addFilter } ) {
addFilter( 'registerBlockType', 'core\anchor-attribute', addAttribute );
Copy link
Member

Choose a reason for hiding this comment

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

Should we distinguish filter names somehow between block lifecycle methods like registerBlockType or getSaveContent and editor components like BlockEdit? Maybe it's enough that a component starts with upper case.

Copy link
Member Author

Choose a reason for hiding this comment

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

To this and the previous point, I agree we need some conventions (also noted in #3318 (comment)). The dot notation was an idea for "filter within a function, but not filtering the function's return value", and the upper-case for components.

const hooks = createHooks();

const {
addAction,
Copy link
Member

Choose a reason for hiding this comment

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

At this moment we use only:

  • addFilter
  • removeAllFilters (accessed directly from @wordpress/hooks)
  • applyFilters

Copy link
Member Author

Choose a reason for hiding this comment

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

I would have liked to simplify this to some pattern for exporting all available keys from createHooks(), but it's not supported by module exports.

https://stackoverflow.com/questions/29844074/es6-export-all-values-from-object

Would you suggest limiting this only to those that are currently used?

Copy link
Member

Choose a reason for hiding this comment

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

Yes, I would take a similar approach to what we do for wp.element, where we cherry-pick only what is really essential to make things work. I'm in favor of starting with a very limited lits of exposed methods and extend it whenever we have a real-life use case.

Copy link
Member

Choose a reason for hiding this comment

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

It's still not public API, so we can iterate later. Anyway, we should definitely revisit when working on documentation.

@gziolo
Copy link
Member

gziolo commented Nov 13, 2017

I prefer this solution over #1023. API here is much easier to use, although it might be less flexible when compared with the decorator approach. I think we should start with the simpler solution and find out how far we can get with it. I also figured out that addInspectorControl is an example of how we can decorate existing components. So if we decide to take this approach, it will make #1023 obsolete.

If we decide to merge this PR together with #3321 (slot & fill ), the only piece left would be Redux state.

I will test it in-depth tomorrow, but I can already say that it looks solid enough that I anticipate that it's going to be accepted.

@aduth aduth force-pushed the try/blocks-extensibility branch from 6c22b41 to 357aa7d Compare November 14, 2017 02:07
export function hasBlockSupport( nameOrType, feature, defaultSupports ) {
const blockType = 'string' === typeof nameOrType ?
getBlockType( nameOrType ) :
nameOrType;
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: but I tend to prefer functions with an unchanged signature. My preference would be to always use type and call getBlockType on the caller side when needed.

Copy link
Member

@gziolo gziolo Nov 14, 2017

Choose a reason for hiding this comment

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

It also makes documenting easier when you enforce calling getBlockType explicitly. We can always provide two methods to support those 2 different ways of calling:

export function hasBlockTypeSupport( type, feature, defaultSupports ) {}

export function hasBlockSupport( name, feature, defaultSupports ) {
    return hasBlockTypeSupport( getBlockType( name ), feature,  defaultSupports );
}

The only challenge in such case is to find proper names for methods :)

* @param {Object} props Props passed to BlockEdit
* @return {Element} Filtered edit element
*/
export function addInspectorControl( element, props ) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we expose generic versions of those? Maybe, better to wait before that move.

Copy link
Contributor

@youknowriad youknowriad left a comment

Choose a reason for hiding this comment

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

Nice work, let's get this in

@gziolo
Copy link
Member

gziolo commented Nov 14, 2017

@youknowriad @aduth Last thing to confirm before I merge this PR. Are we fine with removing Copy button from anchor?

Before

screen shot 2017-11-14 at 11 11 38

After

screen shot 2017-11-14 at 11 13 26

I also noticed that it's no longer under Advanced section.

@gziolo gziolo force-pushed the try/blocks-extensibility branch from 357aa7d to 7f3a223 Compare November 14, 2017 10:51
@gziolo
Copy link
Member

gziolo commented Nov 14, 2017

I will open a follow-up with a proposal how to put HTML Anchor under Advanced panel. Let's proceed with this one.

Great job @aduth! This approach should make maintaining extensions points for blocks much easier internally in Gutenberg 👏

@gziolo gziolo merged commit cbd8cd0 into master Nov 14, 2017
@gziolo gziolo deleted the try/blocks-extensibility branch November 14, 2017 10:58
@aduth
Copy link
Member Author

aduth commented Nov 14, 2017

@gziolo Yeah, losing the Copy Link button was a noted compromise of this approach. We will need to consider this as part of a general pattern for extensions accessing post state.

@gziolo
Copy link
Member

gziolo commented Nov 14, 2017

I opened #3475 to move back HTML Anchor under Advanced section as noted in the comment above (#3318 (comment)).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Feature] Block API API that allows to express the block paradigm. [Feature] Extensibility The ability to extend blocks or the editing experience
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants