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

[Navigation screen] Replace component state and refs with Redux state #23033

Merged
merged 30 commits into from
Jun 22, 2020

Conversation

adamziel
Copy link
Contributor

@adamziel adamziel commented Jun 9, 2020

Description

This PR replaces the component state like const [blocks, setBlocks] = useState(); and const menuItemRef = useRef() in favor of the redux state and data layer.

How has this been tested?

  1. Enable the experimental navigation screen
  2. Go to /wp-admin/admin.php?page=gutenberg-navigation and interact with it in every way you can think of: create a new menu, add menu items, edit existing ones, remove some, save the menu, rinse and repeat without refreshing the page.
  3. Confirm everything worked as expected.

Types of changes

New feature

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code follows the accessibility standards.
  • My code has proper inline documentation.
  • I've included developer documentation if appropriate.
  • I've updated all React Native files affected by any refactorings/renamings in this PR.

@adamziel adamziel requested review from noisysocks and draganescu June 9, 2020 15:36
@github-actions
Copy link

github-actions bot commented Jun 9, 2020

Size Change: +1.08 kB (0%)

Total Size: 1.12 MB

Filename Size Change
build/api-fetch/index.js 3.4 kB -1 B
build/autop/index.js 2.83 kB -1 B
build/block-directory/index.js 7.27 kB -1 B
build/block-editor/index.js 107 kB -2 B (0%)
build/block-library/index.js 129 kB -84 B (0%)
build/blocks/index.js 48.1 kB +2 B (0%)
build/components/index.js 196 kB -17 B (0%)
build/compose/index.js 9.61 kB +3 B (0%)
build/core-data/index.js 11.4 kB +1 B
build/data-controls/index.js 1.29 kB +2 B (0%)
build/data/index.js 8.45 kB +3 B (0%)
build/edit-navigation/index.js 9.64 kB +1.38 kB (14%) ⚠️
build/edit-navigation/style-rtl.css 1.02 kB -1 B
build/edit-navigation/style.css 1.02 kB -2 B (0%)
build/edit-post/index.js 303 kB -70 B (0%)
build/edit-site/index.js 16.6 kB -7 B (0%)
build/edit-widgets/index.js 9.34 kB +4 B (0%)
build/editor/index.js 44.7 kB -65 B (0%)
build/format-library/index.js 7.72 kB +3 B (0%)
build/hooks/index.js 2.13 kB +2 B (0%)
build/keycodes/index.js 1.94 kB +1 B
build/list-reusable-blocks/index.js 3.13 kB -1 B
build/media-utils/index.js 5.29 kB -1 B
build/notices/index.js 1.79 kB +2 B (0%)
build/nux/index.js 3.4 kB +1 B
build/rich-text/index.js 14 kB -75 B (0%)
build/server-side-render/index.js 2.68 kB +1 B
ℹ️ View Unchanged
Filename Size Change
build/a11y/index.js 1.14 kB 0 B
build/annotations/index.js 3.62 kB 0 B
build/blob/index.js 620 B 0 B
build/block-directory/style-rtl.css 937 B 0 B
build/block-directory/style.css 937 B 0 B
build/block-editor/style-rtl.css 10.7 kB 0 B
build/block-editor/style.css 10.7 kB 0 B
build/block-library/editor-rtl.css 7.59 kB 0 B
build/block-library/editor.css 7.59 kB 0 B
build/block-library/style-rtl.css 8.02 kB 0 B
build/block-library/style.css 8.02 kB 0 B
build/block-library/theme-rtl.css 730 B 0 B
build/block-library/theme.css 732 B 0 B
build/block-serialization-default-parser/index.js 1.88 kB 0 B
build/block-serialization-spec-parser/index.js 3.1 kB 0 B
build/components/style-rtl.css 15.9 kB 0 B
build/components/style.css 15.9 kB 0 B
build/date/index.js 5.47 kB 0 B
build/deprecated/index.js 772 B 0 B
build/dom-ready/index.js 568 B 0 B
build/dom/index.js 3.17 kB 0 B
build/edit-post/style-rtl.css 5.5 kB 0 B
build/edit-post/style.css 5.5 kB 0 B
build/edit-site/style-rtl.css 3.03 kB 0 B
build/edit-site/style.css 3.03 kB 0 B
build/edit-widgets/style-rtl.css 2.43 kB 0 B
build/edit-widgets/style.css 2.43 kB 0 B
build/editor/editor-styles-rtl.css 468 B 0 B
build/editor/editor-styles.css 469 B 0 B
build/editor/style-rtl.css 3.81 kB 0 B
build/editor/style.css 3.81 kB 0 B
build/element/index.js 4.65 kB 0 B
build/escape-html/index.js 733 B 0 B
build/format-library/style-rtl.css 547 B 0 B
build/format-library/style.css 548 B 0 B
build/html-entities/index.js 622 B 0 B
build/i18n/index.js 3.56 kB 0 B
build/is-shallow-equal/index.js 710 B 0 B
build/keyboard-shortcuts/index.js 2.51 kB 0 B
build/list-reusable-blocks/style-rtl.css 450 B 0 B
build/list-reusable-blocks/style.css 451 B 0 B
build/nux/style-rtl.css 663 B 0 B
build/nux/style.css 660 B 0 B
build/plugins/index.js 2.56 kB 0 B
build/primitives/index.js 1.5 kB 0 B
build/priority-queue/index.js 789 B 0 B
build/redux-routine/index.js 2.85 kB 0 B
build/shortcode/index.js 1.7 kB 0 B
build/token-list/index.js 1.28 kB 0 B
build/url/index.js 4.06 kB 0 B
build/viewport/index.js 1.85 kB 0 B
build/warning/index.js 1.14 kB 0 B
build/wordcount/index.js 1.17 kB 0 B

compressed-size-action

menuItems
);
const saveMenuItems = () => eventuallySaveMenuItems( blocks, menuItemsRef );
const postId = useStubPost( query );
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Instead of keeping blocks in state, this PR experiments with creating a "stub" post that serves as container for all our blocks but is never meant to be saved.

Comment on lines 56 to 61
const [
blocks,
onInput,
onChange,
saveMenuItems,
] = useNavigationBlockEditor( query, postId );
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Instead of having custom block/onInput/onChange handlers, this uses the same ones as the regular post editor.

Comment on lines 13 to 16
halt() {
this.halted = true;
this.queue = [];
this.listeners = [];
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Remember how new menu items are created whenever a new block is added? Changing an active menu should stop any further processing of these if some are in progress. My idea in this PR is to simply "halt" a promise queue which means no further actions will be processed an no listeners will be notified - eventually the entire thing will be just garbage collected.

Comment on lines 91 to 109
function useStoreSavedMenuItem( query ) {
const { assignMenuItemIdToClientId } = useDispatch(
'core/edit-navigation'
);
const { receiveEntityRecords } = useDispatch( 'core' );
const select = useSelect( ( s ) => s );
return useCallback(
( clientId, menuItem ) => {
assignMenuItemIdToClientId( query, menuItem.id, clientId );
receiveEntityRecords(
'root',
'menuItem',
[ ...select( 'core' ).getMenuItems( query ), menuItem ],
query,
false
);
},
[ query ]
);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am not too happy about this part. Previously menuItems were kept in react ref. Now the core data layer is the primary source of truth. This means that creating a temporary menu item should be reflected in redux state. The only way to do it ATM is receiveEntityRecords which accepts an entire page of results. Maybe we should add an action like appendEntityRecord to avoid this select madness here?

Comment on lines 44 to 49
const menuItemsByClientId = mapMenuItemsByClientId(
select( 'core' ).getMenuItems( query ),
select( 'core/edit-navigation' ).getMenuItemIdToClientIdMapping(
query
)
);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

saveBlocks is not called synchronously when the save button is pressed. Instead, we wait for all draft menu items to be created first, and only then saveBlocks is called. This means that we need a fresh version of menuItemId -> clientId mapping - if we simply selected it in line 42 above then it would be outdated by the time this function is called. Ditto for getMenuItems. I am not too happy about this solution, but the only alternative I can think of that would involve a "valid" use of useSelect calls is keeping a state variable like const [shouldSave, setShouldSave] = useState( false );. Then, when all the async operations are finished, we'd re-render the component using setShouldSave( true );, and react to this change in an effect which would then have access to the most recent version of select()-ed data.

Comment on lines 29 to 39
const post = createStubPost( query.menus, navigationBlock );
const entityStored = receiveEntityRecords(
'root',
'postType',
post,
{ id: post.id },
false
);
entityStored.then( () => {
setPostId( post.id );
} );
Copy link
Contributor Author

@adamziel adamziel Jun 12, 2020

Choose a reason for hiding this comment

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

I am pretty sure this isn't the correct way to store this stub post - useEntityBlockEditor still sends a HTTP request in an attempt to fetch the data. Should this even be a post? Maybe a dedicated entity kind would work better?

Comment on lines +18 to +26
/**
* Block editor data store configuration.
*
* @see https://github.com/WordPress/gutenberg/blob/master/packages/data/README.md#registerStore
*
* @type {Object}
*/
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I didn't pay too much attention to the comments yet, let it be - I will clean it all up later :-)

@adamziel adamziel changed the title First attempt on using the data layer for the navigation screen [Try][Navigation screen] Replace component state and refs with Redux state Jun 12, 2020
@draganescu
Copy link
Contributor

cc @noisysocks

@noisysocks
Copy link
Member

I am thinking we need to implement something that demonstrates the value of this refactor in order to gauge whether the extra bit of complexity is worth it or not. Could you maybe add a rough pass at undo/redo to this PR to see how it looks? That's what I imagine the primary value of using @wordpress/data is.

@talldan
Copy link
Contributor

talldan commented Jun 16, 2020

Fixing #22625 could be a good first implementation of an edit-navigation store as well—moving the selected menu id from state to store would solve that.

@noisysocks
Copy link
Member

The functions in wp.data.select( '...' ) and wp.data.dispatch( '...' ) are callable by third parties and I generally like to think of them as official APIs for all WordPress developers to use.

With that in mind, it's weird to me that this PR has wp.data.select( 'core/edit-navigation' ).getMenuItemIdToClientIdMapping(), wp.data.dispatch( 'core/edit-navigation' ).setMenuItemsToClientIdMapping(), and wp.data.dispatch( 'core/edit-navigation' ).assignMenuItemIdToClientId(). These functions are awfully specific. They expose the concept of mapping menu item IDs to block client IDs which is an implementation detail that I don't think will be useful outside of our needs.

Could we move the code that's in useNavigationBlockEditor into selectors and actions that define a concrete API for working with block-based menus? How these selectors and actions convert blocks to and from menu items would then be an implementation detail. I don't think (but am not 100%!) that there's anything in useNavigationBlockEditor that can't be done using actions which use select() and apiFetch() controls.

To illustrate, here's what I imagine you'd be able to do in the DevTools console:

// Hits GET /wp/v2/menu-items, munges the data into Link blocks, and returns
// everything as a fake post.
const post = wp.data.select( 'edit-navigation' ).getNavigationPost( /* menuId: */ 123 ); 
console.log( post.id ); // navigation-post-123
console.log( post.blocks ); // [ { name: 'core/navigation', ... } ]

// Hits POST /wp/v2/menu-items once for every Link block that doesn't have an
// associated menu item. (IDK what a good name for this is.)
wp.data.dispatch( 'edit-navigation' ).createMissingMenuItems( post );

// Hits the Customiser endpoint and saves everything.
wp.data.dispatch( 'edit-navigation' ).saveNavigationPost( post );

// If we put our fake post into the 'core' registry then we can do things like
// undo and redo.
wp.data.dispatch( 'core' ).receiveEntityRecords( 'postType', 'post', [ post ] );
wp.data.dispatch( 'core' ).editEntityRecord(
	'postType',
	'post',
	'navigation-post-123',
	{ blocks: [
		...post.blocks,
		{ name: 'core/paragraph', content: 'This will be undone!', ... }
	] }
);
wp.data.dispatch( 'core' ).undo();
wp.data.dispatch( 'core' ).redo();

Does that make sense? Thoughts?

@adamziel
Copy link
Contributor Author

adamziel commented Jun 16, 2020

The functions in wp.data.select( '...' ) and wp.data.dispatch( '...' ) are callable by third parties and I generally like to think of them as official APIs for all WordPress developers to use.

TIL - that's a good mental model, thank you! I think exposing wp.data.dispatch( 'edit-navigation' ).saveNavigationPost( post ); is a brilliant idea - let's explore that more.

They expose the concept of mapping menu item IDs to block client IDs which is an implementation detail that I don't think will be useful outside of our needs.

@noisysocks while I agree the name getMenuItemIdToClientIdMapping() is awful, we need to store that information somewhere though. At the moment we use a ref which works fine but doesn't really allow us to expose an API like wp.data.select( 'core/edit-navigation' ).getMenuItem( clientId ) which is what you mentioned in the following comment: #22022 (comment)

My understanding is that in order to expose a getMenuItem() selector, the clientId<->menuId mapping has to be stored in redux state. This in turn means that we need a way to perform reads and writes.

An alternative approach would be storing menuItemId in block attributes - this should remove the need for any mapping-related actions and selectors while still making it possible to expose a getMenuItem( clientId ) selector - what do you think?

@talldan
Copy link
Contributor

talldan commented Jun 16, 2020

@adamziel I think you're on the right path storing this in state, but a lot the comments were probably about making the public facing API friendlier rather than using an alternate solution like block attributes.

The block editor store has a lot of relevance in that there are reducers that encapsulate things like mappings (block order, parent/child relationships), but they're not necessarily exposed to the user directly through actions. For example insertBlock will update the order, and parents. Similarly a lot of the commonly used selectors are a bit more higher-level than the reducers they read from, though with selectors there does tend to be a lot more composition.

It might be that we have an updateMenuItem action. Separately a reducer for storing the mapping between blocks and menu items might have a case for that action (naming is less important as the reducer isn't public). getMenuItemIdForBlock or getClientIdForMenuItem also sound like reasonable selectors.

In terms of saveNavigationPost, not sure if we'd want to go for something like a generator action like the editor store has for savePost:
https://github.com/WordPress/gutenberg/blob/master/packages/editor/src/store/actions.js#L231

That's another way to encapsulate the various working parts, but lets have more conversation on that.

@draganescu
Copy link
Contributor

Fixing #22625 could be a good first implementation of an edit-navigation store as well—moving the selected menu id from state to store would solve that.

@talldan @adamziel #22428 fixes that, because when we delete an entity we don't need to use state anymore. I think @noisysocks 's suggestion with demo-ing undo/redo in the navigation screen is a better demonstration of this PR's powers!

@adamziel
Copy link
Contributor Author

adamziel commented Jun 16, 2020

@noisysocks I explored getting rid of most hooks in favor of the actions, reducers, and such. Interestingly enough it ended up being quite similar to the API you proposed. I played with undo/redo and it seems to work 🎉

Even better - there is no more weirdness with passing select to nested callbacks in react hooks.

We also now have a selector called getMenuItemForClientId() which does exactly what the name says. Implementing an inverse selector should be pretty easy too.

@talldan I also ended up moving the mapping to post.meta - if we treat the post as main model for the editor then it makes sense to keep that data there.

There are a few minor bugs that I noticed, but let's have more discussion about the approach before I go ahead and start fixing them.

}
}

function serializeProcessing( callback ) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This wrapper guarantees serial execution of data processing actions. saveNavigationPost() needs to wait for all the missing items to be created, and running multiple createMissingMenuItems() concurrently could result in sending more requests than required.

Comment on lines +79 to +116
yield dispatch(
'core/notices',
'createErrorNotice',
__( 'There was an error.' ),
{
type: 'snackbar',
}
);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It would be nice to extract these notices outside of the save action, but that would require some way to wait for serialProcessing() to finish. I think it could wait until another iteration.

);

try {
yield* batchSave( menuId, menuItemsByClientId, post.blocks[ 0 ] );
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This try catch needs more attention to make sure all the errors are handled.

@talldan
Copy link
Contributor

talldan commented Jun 17, 2020

@draganescu You might be thinking of a different issue. #22625 is related to the menuId, setMenuId state. It doesn't look like #22428 touches that code.

@noisysocks
Copy link
Member

@talldan I also ended up moving the mapping to post.meta - if we treat the post as main model for the editor then it makes sense to keep that data there.

That's interesting! What are the pros and cons for doing this versus storing the mapping in the edit-navigation store?

Copy link
Member

@noisysocks noisysocks left a comment

Choose a reason for hiding this comment

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

Heck yeah! Nice work. I'm feeling pretty good about this. It's exactly what I'd pictured, only with very clever Adam-isms like serializeProcessing() 😄

A few more notes in addition to the ones I wrote inline:

  • This PR makes the terminology we use in edit-navigation a little all over the place. For example, we call the entity a "menu" in some function names (MenuEditor) but then a "navigation" in others (useNavigationBlockEditor()). We need to settle on a few key terms. I think what probably makes sense is: the navigation screen edits a navigation post, and the navigation store saves a navigation post as a collection of menus and menu items.

    • Semi-relatedly: is there anything we can do to make it clearer that a navigation post is not a "real" post that can be persisted and accessed via /wp/v2/posts?
  • I think, as much as possible, let's make sure that consumers of wp.data.select( 'core/edit-navigation' ) and wp.data.dispatch( 'core/edit-navigation') don't need to care about menu items. They're an internal detail. For example, let's not export actions like startProcessingMenuItems().

  • Since it's now easier to do, should we add undo/redo to the Navigation screen? Even just having the keyboard shortcuts would be useful.

Let me know when you're ready for me to review this with a finer comb.

</div>
);
}

const NavigationBlockEditorProvider = ( {
Copy link
Member

Choose a reason for hiding this comment

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

The name ending with Provider implies that this component only provides context when in fact it renders HTML as well. Maybe rename this component or, since the bulk of the magic happens in hooks and actions, consider putting everything back into MenuEditor?

'receiveEntityRecords',
'root',
'menuItem',
[ ...menuItems, menuItem ],
Copy link
Member

Choose a reason for hiding this comment

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

Why is this [ ...menuItems, menuItem ] and not menuItem? I thought that receiveEntityRecords( items ) will append items to whatever items are already in the store.

Copy link
Contributor Author

@adamziel adamziel Jun 17, 2020

Choose a reason for hiding this comment

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

We want to:

  1. Append another entry to the store
  2. Append entry ID to stored query results

Using just menuItem does 1) but breaks 2) as instead of appending it replaces the first stored ID. Using [ ...menuItems, menuItem ] both appends the new entry to queriedData.items AND appends entry ID to stored query results to queriedData.queries["menus=123"]. Ideally the entity store would be offer some sort of "append" action.

Comment on lines 204 to 208
yield dispatch(
'core/edit-navigation',
'startProcessingMenuItems',
menuId
);
Copy link
Member

Choose a reason for hiding this comment

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

These actions don't strike me as particularly useful for third parties. We can emulate "private actions" by dispatching the action object directly here.

return {
	type: 'START_PROCESSING_MENU_ITEMS',
	menuId,
};

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good idea!

'core/edit-navigation',
'enqueueAfterProcessing',
menuId,
callback
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 be storing non-serialisable things like functions in a Redux store? I imagine it may break tooling such as the Redux DevTools.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We already store non-serializable things in other stores, e.g. EquivalentKeyMap instances. It's not a perfect solution, but it's good enough for starters. If any serialization issue comes up, it's entirely okay to just skip the processing part of the state tree.


const store = registerStore( MODULE_KEY, {
...storeConfig,
persist: [ 'preferences' ],
Copy link
Member

Choose a reason for hiding this comment

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

There is no preferences key in the reducer.

export const buildNavigationPostId = ( menuId ) =>
`navigation-post-${ menuId }`;

export function uuidv4() {
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 use the uuid package instead? We already are using it in some packages e.g. @wordpress/blocks.

@adamziel adamziel force-pushed the try/use-store-for-edit-navigation-screen branch from 0552b25 to 592e0f5 Compare June 17, 2020 15:25
@adamziel
Copy link
Contributor Author

adamziel commented Jun 17, 2020

I addressed all your feedback @noisysocks, this is ready for another round. Unfortunately it still attempts to request the post from the API - I will debug it tomorrow.

Semi-relatedly: is there anything we can do to make it clearer that a navigation post is not a "real" post that can be persisted and accessed via /wp/v2/posts?

Probably not much other than naming. UnsavableNavigationPost? StubNavigationPost? InMemoryNavigationPost?

Comment on lines +39 to +63
export function getMenuItemToClientIdMapping( postId ) {
return {
type: 'GET_MENU_ITEM_TO_CLIENT_ID_MAPPING',
postId,
};
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I ended up moving this mapping back to state - keeping it in the post turned out to be troublesome (e.g. every update created additional undo actions). I'm not sure if there is any other way to have a "private selector" other than custom control - I'm open to refactoring this bit.

};
}

export function getNavigationPost( menuId ) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

getNavigationPost seems like the argument is a post id. Would getNavigationPostByMenuId be clearer?

Copy link
Member

Choose a reason for hiding this comment

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

It should match the name of the selector. I'm not against renaming the selector to something like getNavigationPostForMenu( menuId ), though, if you want! 🙂

@adamziel adamziel force-pushed the try/use-store-for-edit-navigation-screen branch from ecbdf16 to 27eac24 Compare June 22, 2020 07:06
@adamziel adamziel merged commit d3015f4 into master Jun 22, 2020
@adamziel adamziel deleted the try/use-store-for-edit-navigation-screen branch June 22, 2020 08:44
@github-actions github-actions bot added this to the Gutenberg 8.4 milestone Jun 22, 2020
This was referenced Jun 24, 2020
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants