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

Add mutations data and helper functions to useEntityRecord #39595

Merged
merged 12 commits into from
Aug 8, 2022
51 changes: 51 additions & 0 deletions packages/core-data/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,57 @@ In the above example, when `PageTitleDisplay` is rendered into an
application, the page and the resolution details will be retrieved from
the store state using `getEntityRecord()`, or resolved if missing.

```js
import { useState } from '@wordpress/data';
import { useDispatch } from '@wordpress/data';
import { __ } from '@wordpress/i18n';
import { TextControl } from '@wordpress/components';
import { store as noticeStore } from '@wordpress/notices';
import { useEntityRecord } from '@wordpress/core-data';

function PageRenameForm( { id } ) {
const page = useEntityRecord( 'postType', 'page', id );
const [ title, setTitle ] = useState( () => page.record.title.rendered );
Copy link
Member

Choose a reason for hiding this comment

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

One thing that still isn't clear to me. Why do we need useState here rather than using const title = page.editedRecord.title and const setTitle = ( value ) => page.edit( { title: value } )?

Copy link
Contributor Author

@adamziel adamziel Aug 8, 2022

Choose a reason for hiding this comment

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

const setTitle = ( value ) => page.edit( { title: value } ) would make undo work character by character :( I considered documenting that behavior in this code snippet and ended up leaving it out. There's already a lot of concepts in there.

Copy link
Contributor Author

@adamziel adamziel Aug 8, 2022

Choose a reason for hiding this comment

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

I'd love to go ahead and merge this PR since there are no conflicts and the checks are finally green :D we can tweak the docs in a follow-up if needed.

Copy link
Member

Choose a reason for hiding this comment

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

I approved the PR. I'm wondering how the undo/redo works with setAttributes from the block edit API. I don't see there an issue with storing all atomic operations when using onChange with TextControl.

Copy link
Member

Choose a reason for hiding this comment

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

I figured out why it works in a special way for block attributes. There is this util that checks whether the changes get applied to the same block's attribute:

/**
* Returns true if, given the currently dispatching action and the previously
* dispatched action, the two actions are updating the same block attribute, or
* false otherwise.
*
* @param {Object} action Currently dispatching action.
* @param {Object} lastAction Previously dispatched action.
*
* @return {boolean} Whether actions are updating the same block attribute.
*/
export function isUpdatingSameBlockAttribute( action, lastAction ) {
return (
action.type === 'UPDATE_BLOCK_ATTRIBUTES' &&
lastAction !== undefined &&
lastAction.type === 'UPDATE_BLOCK_ATTRIBUTES' &&
isEqual( action.clientIds, lastAction.clientIds ) &&
hasSameKeys( action.attributes, lastAction.attributes )
);
}

It's used by the higher-order reducer that ensures that only a single entry gets added to the under/redo state for subsequent changes to the same attribute:

/**
* Higher-order reducer intended to augment the blocks reducer, assigning an
* `isPersistentChange` property value corresponding to whether a change in
* state can be considered as persistent. All changes are considered persistent
* except when updating the same block attribute as in the previous action.
*
* @param {Function} reducer Original reducer function.
*
* @return {Function} Enhanced reducer function.
*/
function withPersistentBlockChange( reducer ) {
let lastAction;
let markNextChangeAsNotPersistent = false;
return ( state, action ) => {
let nextState = reducer( state, action );
const isExplicitPersistentChange =
action.type === 'MARK_LAST_CHANGE_AS_PERSISTENT' ||
markNextChangeAsNotPersistent;
// Defer to previous state value (or default) unless changing or
// explicitly marking as persistent.
if ( state === nextState && ! isExplicitPersistentChange ) {
markNextChangeAsNotPersistent =
action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT';
const nextIsPersistentChange = state?.isPersistentChange ?? true;
if ( state.isPersistentChange === nextIsPersistentChange ) {
return state;
}
return {
...nextState,
isPersistentChange: nextIsPersistentChange,
};
}
nextState = {
...nextState,
isPersistentChange: isExplicitPersistentChange
? ! markNextChangeAsNotPersistent
: ! isUpdatingSameBlockAttribute( action, lastAction ),
};
// In comparing against the previous action, consider only those which
// would have qualified as one which would have been ignored or not
// have resulted in a changed state.
lastAction = action;
markNextChangeAsNotPersistent =
action.type === 'MARK_NEXT_CHANGE_AS_NOT_PERSISTENT';
return nextState;
};
}

Maybe we should figure out how to mirror the same behavior for entities to unify the experience and bring more value to using editedRecord and the edit method from this hook.

const { createSuccessNotice, createErrorNotice } =
useDispatch( noticeStore );

if ( page.isResolving ) {
return 'Loading...';
}

async function onRename( event ) {
event.preventDefault();
page.edit( { title } );
try {
await page.save();
createSuccessNotice( __( 'Page renamed.' ), {
type: 'snackbar',
} );
} catch ( error ) {
createErrorNotice( error.message, { type: 'snackbar' } );
}
}

return (
<form onSubmit={ onRename }>
<TextControl
label={ __( 'Name' ) }
value={ title }
onChange={ setTitle }
/>
<button type="submit">{ __( 'Save' ) }</button>
</form>
);
}

// Rendered in the application:
// <PageRenameForm id={ 1 } />
```

In the above example, updating and saving the page title is handled
via the `edit()` and `save()` mutation helpers provided by
`useEntityRecord()`;

_Parameters_

- _kind_ `string`: Kind of the entity, e.g. `root` or a `postType`. See rootEntitiesConfig in ../entities.ts for a list of available kinds.
Expand Down
10 changes: 9 additions & 1 deletion packages/core-data/src/hooks/test/use-entity-record.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ describe( 'useEntityRecord', () => {
);

expect( data ).toEqual( {
records: undefined,
adamziel marked this conversation as resolved.
Show resolved Hide resolved
edit: expect.any( Function ),
editedRecord: {},
hasEdits: false,
record: undefined,
save: expect.any( Function ),
hasResolved: false,
isResolving: false,
status: 'IDLE',
Expand All @@ -66,7 +70,11 @@ describe( 'useEntityRecord', () => {
} );

expect( data ).toEqual( {
edit: expect.any( Function ),
editedRecord: {},
hasEdits: false,
record: { hello: 'world', id: 1 },
save: expect.any( Function ),
hasResolved: true,
isResolving: false,
status: 'SUCCESS',
Expand Down
99 changes: 97 additions & 2 deletions packages/core-data/src/hooks/use-entity-record.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
/**
* WordPress dependencies
*/
import { useDispatch, useSelect } from '@wordpress/data';
import deprecated from '@wordpress/deprecated';
import { useMemo } from '@wordpress/element';

/**
* Internal dependencies
Expand All @@ -14,11 +16,25 @@ export interface EntityRecordResolution< RecordType > {
/** The requested entity record */
record: RecordType | null;

/** The edited entity record */
editedRecord: Partial< RecordType >;

/** Apply local (in-browser) edits to the edited entity record */
edit: ( diff: Partial< RecordType > ) => void;

/** Persist the edits to the server */
save: () => Promise< void >;

/**
* Is the record still being resolved?
*/
isResolving: boolean;

/**
* Does the record have any local edits?
*/
hasEdits: boolean;

/**
* Is the record resolved by now?
*/
Expand Down Expand Up @@ -66,6 +82,58 @@ export interface Options {
* application, the page and the resolution details will be retrieved from
* the store state using `getEntityRecord()`, or resolved if missing.
*
* @example
* ```js
* import { useState } from '@wordpress/data';
* import { useDispatch } from '@wordpress/data';
* import { __ } from '@wordpress/i18n';
* import { TextControl } from '@wordpress/components';
* import { store as noticeStore } from '@wordpress/notices';
* import { useEntityRecord } from '@wordpress/core-data';
*
* function PageRenameForm( { id } ) {
* const page = useEntityRecord( 'postType', 'page', id );
* const [ title, setTitle ] = useState( () => page.record.title.rendered );
* const { createSuccessNotice, createErrorNotice } =
* useDispatch( noticeStore );
*
* if ( page.isResolving ) {
* return 'Loading...';
* }
*
* async function onRename( event ) {
* event.preventDefault();
* page.edit( { title } );
* try {
* await page.save();
* createSuccessNotice( __( 'Page renamed.' ), {
* type: 'snackbar',
* } );
* } catch ( error ) {
* createErrorNotice( error.message, { type: 'snackbar' } );
* }
* }
*
* return (
* <form onSubmit={ onRename }>
* <TextControl
* label={ __( 'Name' ) }
* value={ title }
* onChange={ setTitle }
* />
* <button type="submit">{ __( 'Save' ) }</button>
* </form>
* );
* }
*
* // Rendered in the application:
* // <PageRenameForm id={ 1 } />
* ```
*
* In the above example, updating and saving the page title is handled
* via the `edit()` and `save()` mutation helpers provided by
* `useEntityRecord()`;
*
* @return Entity record data.
* @template RecordType
*/
Expand All @@ -75,7 +143,31 @@ export default function useEntityRecord< RecordType >(
recordId: string | number,
options: Options = { enabled: true }
): EntityRecordResolution< RecordType > {
const { data: record, ...rest } = useQuerySelect(
const { editEntityRecord, saveEditedEntityRecord } =
useDispatch( coreStore );

const mutations = useMemo(
() => ( {
edit: ( record ) =>
editEntityRecord( kind, name, recordId, record ),
save: ( saveOptions: any = {} ) =>
saveEditedEntityRecord( kind, name, recordId, {
throwOnError: true,
...saveOptions,
} ),
} ),
[ recordId ]
);

const { editedRecord, hasEdits } = useSelect(
( select ) => ( {
editedRecord: select( coreStore ).getEditedEntityRecord(),
hasEdits: select( coreStore ).hasEditsForEntityRecord(),
} ),
[ kind, name, recordId ]
);

const { data: record, ...querySelectRest } = useQuerySelect(
( query ) => {
if ( ! options.enabled ) {
return null;
Expand All @@ -87,7 +179,10 @@ export default function useEntityRecord< RecordType >(

return {
record,
...rest,
editedRecord,
hasEdits,
...querySelectRest,
...mutations,
};
}

Expand Down