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

DataViews: Register the permanentlyDelete action like any third-party action. #62647

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/dataviews/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ export interface ActionModal< Item extends AnyItem >
}

export interface ActionButton< Item extends AnyItem >
extends ActionBase< AnyItem > {
extends ActionBase< Item > {
/**
* The callback to execute when the action is triggered.
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/edit-site/src/components/editor/style.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@


.edit-site-editor__editor-interface {
opacity: 1;
transition: opacity 0.1s ease-out;
Expand Down
93 changes: 2 additions & 91 deletions packages/editor/src/components/post-actions/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { unlock } from '../../lock-unlock';
import isTemplateRevertable from '../../store/utils/is-template-revertable';
import { exportPatternAsJSONAction } from './export-pattern-action';
import { CreateTemplatePartModalContents } from '../create-template-part-modal';
import useDefaultActions from '../../dataviews/actions';

// Patterns.
const { PATTERN_TYPES, CreatePatternModalContents, useDuplicatePatternProps } =
Expand Down Expand Up @@ -307,96 +308,6 @@ const trashPostAction = {
},
};

const permanentlyDeletePostAction = {
id: 'permanently-delete',
label: __( 'Permanently delete' ),
supportsBulk: true,
isEligible( { status } ) {
return status === 'trash';
},
async callback( posts, { registry } ) {
const { createSuccessNotice, createErrorNotice } =
registry.dispatch( noticesStore );
const { deleteEntityRecord } = registry.dispatch( coreStore );
const promiseResult = await Promise.allSettled(
posts.map( ( post ) => {
return deleteEntityRecord(
'postType',
post.type,
post.id,
{ force: true },
{ throwOnError: true }
);
} )
);
// If all the promises were fulfilled with success.
if ( promiseResult.every( ( { status } ) => status === 'fulfilled' ) ) {
let successMessage;
if ( promiseResult.length === 1 ) {
successMessage = sprintf(
/* translators: The posts's title. */
__( '"%s" permanently deleted.' ),
getItemTitle( posts[ 0 ] )
);
} else {
successMessage = __( 'The posts were permanently deleted.' );
}
createSuccessNotice( successMessage, {
type: 'snackbar',
id: 'permanently-delete-post-action',
} );
} else {
// If there was at lease one failure.
let errorMessage;
// If we were trying to permanently delete a single post.
if ( promiseResult.length === 1 ) {
if ( promiseResult[ 0 ].reason?.message ) {
errorMessage = promiseResult[ 0 ].reason.message;
} else {
errorMessage = __(
'An error occurred while permanently deleting the post.'
);
}
// If we were trying to permanently delete multiple posts
} else {
const errorMessages = new Set();
const failedPromises = promiseResult.filter(
( { status } ) => status === 'rejected'
);
for ( const failedPromise of failedPromises ) {
if ( failedPromise.reason?.message ) {
errorMessages.add( failedPromise.reason.message );
}
}
if ( errorMessages.size === 0 ) {
errorMessage = __(
'An error occurred while permanently deleting the posts.'
);
} else if ( errorMessages.size === 1 ) {
errorMessage = sprintf(
/* translators: %s: an error message */
__(
'An error occurred while permanently deleting the posts: %s'
),
[ ...errorMessages ][ 0 ]
);
} else {
errorMessage = sprintf(
/* translators: %s: a list of comma separated error messages */
__(
'Some errors occurred while permanently deleting the posts: %s'
),
[ ...errorMessages ].join( ',' )
);
}
}
createErrorNotice( errorMessage, {
type: 'snackbar',
} );
}
},
};

const restorePostAction = {
id: 'restore',
label: __( 'Restore' ),
Expand Down Expand Up @@ -1007,6 +918,7 @@ export const duplicateTemplatePartAction = {
};

export function usePostActions( { postType, onActionPerformed, context } ) {
useDefaultActions();
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 think this is probably not the right place to use this hook.

Because ideally, this only run ones and third-parties should be able to unregister the core actions even before they are actually rendered. So I'm not sure what the best path forward here:

  • Move this call to the top level component in both post and site editors? Not ideal to have to duplicate this call.
  • Avoid using a hook and just call this top level, when loading the module.
  • Use a "filter" instead of suggesting to third-parties to use the unregister functions?

@gziolo maybe you have better ideas here since you worked on similar things for blocks registrations..

Copy link
Member

Choose a reason for hiding this comment

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

I think the "Move this call to the top level component in both post and site editors?" is the better approach. Could we put on some top-level editor component used by both edit site and edit post so we avoid the duplication?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The site editor is not just an "Editor", it's the equivalent of the "whole admin". The editor can be unmounted there.

Copy link
Member

Choose a reason for hiding this comment

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

Good point in that case I would still opt for "Move this call to the top level component in both post and site editors?" and would duplicate the call on edit post and edit site, it is just one line of code.

Copy link
Member

Choose a reason for hiding this comment

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

Regarding block registration, we put some similar code in all top-level components of the usual apps: editor, post editor, widgets editor, etc.

Site editor:

dispatch( blocksStore ).reapplyBlockTypeFilters();
const coreBlocks = __experimentalGetCoreBlocks().filter(
( { name } ) => name !== 'core/freeform'
);
registerCoreBlocks( coreBlocks );

Post editor:

dispatch( blocksStore ).reapplyBlockTypeFilters();
// Check if the block list view should be open by default.
// If `distractionFree` mode is enabled, the block list view should not be open.
// This behavior is disabled for small viewports.
if (
isMediumOrBigger &&
select( preferencesStore ).get( 'core', 'showListViewByDefault' ) &&
! select( preferencesStore ).get( 'core', 'distractionFree' )
) {
dispatch( editorStore ).setIsListViewOpened( true );
}
registerCoreBlocks();

There are some legacy implications, so it might be more complex than necessary there. However, the biggest challenge with filters is that they might get applied before or after the top-level component gets mounted, or even before or after the individual store action implementing a filter gets executed. In effect, we decided in the past to account for it. The comment in the code might better illustrate that:

/**
* Signals that all block types should be computed again.
* It uses stored unprocessed block types and all the most recent list of registered filters.
*
* It addresses the issue where third party block filters get registered after third party blocks. A sample sequence:
* 1. Filter A.
* 2. Block B.
* 3. Block C.
* 4. Filter D.
* 5. Filter E.
* 6. Block F.
* 7. Filter G.
* In this scenario some filters would not get applied for all blocks because they are registered too late.
*/

As with everything, it all comes with defining the overall strategy knowing that 3rd party depending on the defined script dependencies might load before or after the code that defines the default data view action handlers. The biggest challenge with unregistering the action handlers is also the fact that you need to ensure that the script gets executed after it gets registered, which is sometimes problematic when some other code has the opposite requirement. I'm mostly illustrating the considerations that we have been witnessing in the bug reports over time with the extensibility model we iterated upon for block registration. I'm happy to discuss the implications of the approach you prefer to take here and help to think about potential edge cases.

const { defaultActions, postTypeObject } = useSelect(
( select ) => {
const { getPostType } = select( coreStore );
Expand Down Expand Up @@ -1049,7 +961,6 @@ export function usePostActions( { postType, onActionPerformed, context } ) {
isTemplateOrTemplatePart || isPattern
? deletePostAction
: trashPostAction,
! isTemplateOrTemplatePart && permanentlyDeletePostAction,
...defaultActions,
].filter( Boolean );
// Filter actions based on provided context. If not provided
Expand Down
120 changes: 120 additions & 0 deletions packages/editor/src/dataviews/actions/delete-permanently.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* WordPress dependencies
*/
import { store as coreStore } from '@wordpress/core-data';
import { store as noticesStore } from '@wordpress/notices';
import { __, sprintf } from '@wordpress/i18n';
import type { Action } from '@wordpress/dataviews';

/**
* Internal dependencies
*/
import { getItemTitle } from './utils';
import type { Post } from '../types';
import {
TEMPLATE_POST_TYPE,
TEMPLATE_PART_POST_TYPE,
} from '../../store/constants';

type CoreDataError = {
reason: { message?: string };
};

const permanentlyDeletePostAction: Action< Post > = {
Copy link
Member

Choose a reason for hiding this comment

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

Hi @youknowriad, in order to know if the user has the right permissions to delete or trash a post, we need to call some selectors. Currently, we have regressions and are showing trash action even when we should not. I'm fixing that for Trash action at #62589 (which we should include for 6.6). We also need to do the same to delete permanently but we could not use a selector to check permissions in the API I guess we need to fix that at the API level.

Copy link
Member

Choose a reason for hiding this comment

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

Also, something where we would check permissions to delete before registering the action would not work, the user may be able to delete some items (e.g.: the items they created or draft items) but not other items.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The "callback" can receive the "registry" object, we might want to do the same for the isElligible.

Copy link
Member

Choose a reason for hiding this comment

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

Hi @youknowriad, I think simply passing the registry to isElligible is not a solution and will not work as expected. In the beginning, while checks are being done canUser will return false, and if isEligible is false then canUser may say true but isEligible is not re-executed because there is nothing that causes a React rerender. This is not a theoretical issue it was happening on #62589 and that was the reason I used the hacky solution of getCachedResolvers around useMemo to trigger the rerender.

I see two possible ways forward:

  • Support hook-based actions on the API.
  • Make is eligible something async and then it is up to the component users of isEligible to make sure the component rerender after isEligible finishes the computation.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

What if we just call isEligible within useSelect and pass "select". I'm not sure if I like it but it's a solution as well.

Copy link
Member

Choose a reason for hiding this comment

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

What if we just call isEligible within useSelect and pass "select". I'm not sure if I like it but it's a solution as well.

It is also a possible solution, it is going to be called a lot e.g: in a list with 50 times we will have 50 useSelects calling is eligible running on each store change. That may be ok. Another downside is that it is not very flexible it forces people to use WordPress data e.g.: I can not check if something is eligible on my own API without using resolvers etc.

id: 'permanently-delete',
label: __( 'Permanently delete' ),
supportsBulk: true,
isEligible( { status, type }: Post ) {
const isTemplateOrTemplatePart = [
TEMPLATE_POST_TYPE,
TEMPLATE_PART_POST_TYPE,
].includes( type );

return ! isTemplateOrTemplatePart && status === 'trash';
},
async callback( posts, { registry } ) {
const { createSuccessNotice, createErrorNotice } =
registry.dispatch( noticesStore );
const { deleteEntityRecord } = registry.dispatch( coreStore );
const promiseResult = await Promise.allSettled(
posts.map( ( post ) => {
return deleteEntityRecord(
'postType',
post.type,
post.id,
{ force: true },
{ throwOnError: true }
);
} )
);
// If all the promises were fulfilled with success.
if ( promiseResult.every( ( { status } ) => status === 'fulfilled' ) ) {
let successMessage;
if ( promiseResult.length === 1 ) {
successMessage = sprintf(
/* translators: The posts's title. */
__( '"%s" permanently deleted.' ),
getItemTitle( posts[ 0 ] )
);
} else {
successMessage = __( 'The posts were permanently deleted.' );
}
createSuccessNotice( successMessage, {
type: 'snackbar',
id: 'permanently-delete-post-action',
} );
} else {
// If there was at lease one failure.
let errorMessage;
// If we were trying to permanently delete a single post.
if ( promiseResult.length === 1 ) {
const result = promiseResult[ 0 ] as CoreDataError;
if ( result.reason?.message ) {
errorMessage = result.reason.message;
} else {
errorMessage = __(
'An error occurred while permanently deleting the post.'
);
}
// If we were trying to permanently delete multiple posts
} else {
const errorMessages = new Set();
const failedPromises = promiseResult.filter(
( { status } ) => status === 'rejected'
);
for ( const failedPromise of failedPromises ) {
const result = failedPromise as CoreDataError;
youknowriad marked this conversation as resolved.
Show resolved Hide resolved
if ( result.reason?.message ) {
errorMessages.add( result.reason.message );
}
}
if ( errorMessages.size === 0 ) {
errorMessage = __(
'An error occurred while permanently deleting the posts.'
);
} else if ( errorMessages.size === 1 ) {
errorMessage = sprintf(
/* translators: %s: an error message */
__(
'An error occurred while permanently deleting the posts: %s'
),
[ ...errorMessages ][ 0 ]
);
} else {
errorMessage = sprintf(
/* translators: %s: a list of comma separated error messages */
__(
'Some errors occurred while permanently deleting the posts: %s'
),
[ ...errorMessages ].join( ',' )
);
}
}
createErrorNotice( errorMessage, {
type: 'snackbar',
} );
}
},
};

export default permanentlyDeletePostAction;
26 changes: 26 additions & 0 deletions packages/editor/src/dataviews/actions/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* WordPress dependencies
*/
import { useEffect } from '@wordpress/element';
import { useDispatch } from '@wordpress/data';

/**
* Internal dependencies
*/
import deletePermanently from './delete-permanently';
import { unlock } from '../../lock-unlock';
import { store as editorStore } from '../../store';

export default function useDefaultActions() {
const { registerEntityAction, unregisterEntityAction } = unlock(
useDispatch( editorStore )
);

useEffect( () => {
registerEntityAction( 'postType', '*', deletePermanently );

return () => {
unregisterEntityAction( 'postType', '*', deletePermanently.id );
};
}, [ registerEntityAction, unregisterEntityAction ] );
}
16 changes: 16 additions & 0 deletions packages/editor/src/dataviews/actions/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* WordPress dependencies
*/
import { decodeEntities } from '@wordpress/html-entities';

/**
* Internal dependencies
*/
import type { Post } from '../types';

export function getItemTitle( item: Post ) {
if ( typeof item.title === 'string' ) {
return decodeEntities( item.title );
}
return decodeEntities( item.title?.rendered || '' );
}
19 changes: 13 additions & 6 deletions packages/editor/src/dataviews/store/private-selectors.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
/**
* WordPress dependencies
*/
import type { Action } from '@wordpress/dataviews';
import { createSelector } from '@wordpress/data';

/**
* Internal dependencies
*/
import type { State } from './reducer';

const EMPTY_ARRAY: Action< any >[] = [];

export function getEntityActions( state: State, kind: string, name: string ) {
return state.actions[ kind ]?.[ name ] ?? EMPTY_ARRAY;
}
export const getEntityActions = createSelector(
( state: State, kind: string, name: string ) => {
return [
...( state.actions[ kind ]?.[ name ] ?? [] ),
...( state.actions[ kind ]?.[ '*' ] ?? [] ),
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 implemented the * to register actions for all post types.

];
},
( state: State, kind: string, name: string ) => [
state.actions[ kind ]?.[ name ],
state.actions[ kind ]?.[ '*' ],
]
);
17 changes: 17 additions & 0 deletions packages/editor/src/dataviews/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
/**
* WordPress dependencies
*/
import type { AnyItem } from '@wordpress/dataviews';

type PostStatus =
| 'published'
| 'draft'
| 'pending'
| 'private'
| 'future'
| 'auto-draft'
| 'trash';

export interface Post extends AnyItem {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ideally these "Model" types should be in core-data but it's fine for now here. I think ultimately the whole "dataviews" folder here will move to "core-data".

status: PostStatus;
}
Loading