Skip to content
Merged
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
4 changes: 4 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions packages/workflow/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@

- Widen React peer dependency ranges to `^18 || ^19` to support both React 18 and React 19 environments ([#80024](https://github.com/WordPress/gutenberg/pull/80024)).

### Bug Fixes

- Show a fallback message when an ability throws a value without a string message, instead of failing silently or showing `[object Object]` ([#84361](https://github.com/WordPress/gutenberg/pull/84361)).
- Reset the palette when it closes with the shortcut, and drop results from runs that finish after it closes ([#84361](https://github.com/WordPress/gutenberg/pull/84361)).

### Internal

- Import `withIgnoreIMEEvents` from `@wordpress/keycodes` instead of unlocking it from `@wordpress/components`. Adds a `@wordpress/keycodes` dependency ([#81343](https://github.com/WordPress/gutenberg/pull/81343)).
Expand Down
4 changes: 4 additions & 0 deletions packages/workflow/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,16 @@
"@wordpress/core-abilities": "file:../core-abilities",
"@wordpress/data": "file:../data",
"@wordpress/element": "file:../element",
"@wordpress/html-entities": "file:../html-entities",
"@wordpress/i18n": "file:../i18n",
"@wordpress/icons": "file:../icons",
"@wordpress/keyboard-shortcuts": "file:../keyboard-shortcuts",
"@wordpress/keycodes": "file:../keycodes",
"@wordpress/ui": "file:../ui"
},
"devDependencies": {
"vitest": "^5.0.3"
},
"peerDependencies": {
"@types/react": "^18 || ^19",
"react": "^18 || ^19",
Expand Down
120 changes: 59 additions & 61 deletions packages/workflow/src/components/workflow-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { executeAbility, store as abilitiesStore } from '@wordpress/abilities';
import type { Ability } from '@wordpress/abilities';
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
import type { AbilityRunResult } from './types';
import { getErrorMessage } from '../utils/get-error-message';
import './workflow-menu.scss';

/**
Expand All @@ -26,43 +27,7 @@ const inputLabel = __( 'Run abilities and workflows' );
*/
export function WorkflowMenu() {
const { registerShortcut } = useDispatch( keyboardShortcutsStore );
const [ search, setSearch ] = useState( '' );
const [ isOpen, setIsOpen ] = useState( false );
const [ abilityOutput, setAbilityOutput ] =
useState< AbilityRunResult | null >( null );
const [ isExecuting, setIsExecuting ] = useState( false );
const containerRef = useRef< HTMLDivElement >( null );
const inputRef = useRef< HTMLInputElement >( null );

const abilities = useSelect( ( select ) => {
const allAbilities = select( abilitiesStore ).getAbilities();
return allAbilities || EMPTY_ARRAY;
}, [] );

const filteredAbilities = useMemo( () => {
if ( ! search ) {
return abilities;
}
const searchLower = search.toLowerCase();
return abilities.filter(
( ability ) =>
ability.label?.toLowerCase().includes( searchLower ) ||
ability.name?.toLowerCase().includes( searchLower )
);
}, [ abilities, search ] );

// Focus container when output is shown so it can receive keyboard events
useEffect( () => {
if ( abilityOutput && containerRef.current ) {
containerRef.current.focus();
}
}, [ abilityOutput ] );

useEffect( () => {
if ( isOpen && ! abilityOutput ) {
inputRef.current?.focus();
}
}, [ isOpen, abilityOutput ] );

useEffect( () => {
registerShortcut( {
Expand All @@ -85,7 +50,7 @@ export function WorkflowMenu() {
}

event.preventDefault();
setIsOpen( ! isOpen );
setIsOpen( ( open ) => ! open );
} )
);

Expand All @@ -97,12 +62,54 @@ export function WorkflowMenu() {
}
}, [ isOpen ] );

const closeAndReset = () => {
setSearch( '' );
setIsOpen( false );
setAbilityOutput( null );
setIsExecuting( false );
};
if ( ! isOpen ) {
return null;
}

return <WorkflowPalette onClose={ () => setIsOpen( false ) } />;
}

/*
* Unmounted on close, so its state resets and a run that finishes after
* closing is dropped.
*/
function WorkflowPalette( { onClose }: { onClose: () => void } ) {
const [ search, setSearch ] = useState( '' );
const [ abilityOutput, setAbilityOutput ] =
useState< AbilityRunResult | null >( null );
const [ isExecuting, setIsExecuting ] = useState( false );
const containerRef = useRef< HTMLDivElement >( null );
const inputRef = useRef< HTMLInputElement >( null );

const abilities = useSelect( ( select ) => {
const allAbilities = select( abilitiesStore ).getAbilities();
return allAbilities || EMPTY_ARRAY;
}, [] );

const filteredAbilities = useMemo( () => {
if ( ! search ) {
return abilities;
}
const searchLower = search.toLowerCase();
return abilities.filter(
( ability ) =>
ability.label.toLowerCase().includes( searchLower ) ||
ability.name.toLowerCase().includes( searchLower )
);
}, [ abilities, search ] );

// Focus container when output is shown so it can receive keyboard events
useEffect( () => {
if ( abilityOutput && containerRef.current ) {
containerRef.current.focus();
}
}, [ abilityOutput ] );

useEffect( () => {
if ( ! abilityOutput ) {
inputRef.current?.focus();
}
}, [ abilityOutput ] );

const goBack = () => {
setAbilityOutput( null );
Expand All @@ -112,24 +119,19 @@ export function WorkflowMenu() {

const handleExecuteAbility = async ( ability: Ability ) => {
setIsExecuting( true );
const details = {
name: ability.name,
label: ability.label || ability.name,
description: ability.description || '',
};
try {
const result = await executeAbility( ability.name );
setAbilityOutput( {
name: ability.name,
label: ability?.label || ability.name,
description: ability?.description || '',
success: true,
data: result,
} );
setAbilityOutput( { ...details, success: true, data: result } );
Comment thread
manzoorwanijk marked this conversation as resolved.
} catch ( error ) {
setAbilityOutput( {
name: ability.name,
label: ability?.label || ability.name,
description: ability?.description || '',
...details,
success: false,
error:
( error as { message?: string } ).message ||
String( error ),
error: getErrorMessage( error ),
} );
} finally {
setIsExecuting( false );
Expand All @@ -150,18 +152,14 @@ export function WorkflowMenu() {
}
};

if ( ! isOpen ) {
return null;
}

const items = isExecuting ? EMPTY_ARRAY : filteredAbilities;
const showEmpty = ! isExecuting && !! search && ! filteredAbilities.length;

return (
<Modal
className="workflows-workflow-menu"
overlayClassName="workflows-workflow-menu__overlay"
onRequestClose={ abilityOutput ? goBack : closeAndReset }
onRequestClose={ abilityOutput ? goBack : onClose }
__experimentalHideHeader
contentLabel={ __( 'Workflow palette' ) }
>
Expand Down
32 changes: 32 additions & 0 deletions packages/workflow/src/utils/get-error-message.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { decodeEntities } from '@wordpress/html-entities';
import { __ } from '@wordpress/i18n';

/**
* Turns whatever an ability threw into a message the user can read.
*
* @param error The value the ability threw.
* @return The message to show the user.
*/
export function getErrorMessage( error: unknown ): string {
let message = '';
if ( typeof error === 'object' && error !== null ) {
if ( 'message' in error && typeof error.message === 'string' ) {
message = error.message;
}
// An error with an empty message still names its type, e.g. `TypeError`.
if ( ! message && 'name' in error && typeof error.name === 'string' ) {
message = error.name;
}
} else if ( error !== null && error !== undefined ) {
message = String( error );
}

if ( ! message ) {
return __(
'This ability couldn’t run. Check the browser console for details.'
);
}

// REST errors can carry HTML entities, such as `&#8217;`.
return decodeEntities( message );
}
48 changes: 48 additions & 0 deletions packages/workflow/src/utils/test/get-error-message.jsdom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { getErrorMessage } from '../get-error-message';

const FALLBACK =
'This ability couldn’t run. Check the browser console for details.';

describe( 'getErrorMessage', () => {
it.each( [
[ 'undefined', undefined ],
[ 'null', null ],
[ 'an empty string', '' ],
[ 'a plain object without a message', { code: 'oops' } ],
] )( 'returns the fallback for %s', ( _, error ) => {
expect( getErrorMessage( error ) ).toBe( FALLBACK );
} );

it( 'returns a thrown string', () => {
expect( getErrorMessage( 'Something broke' ) ).toBe(
'Something broke'
);
} );

it( 'stringifies other primitives', () => {
expect( getErrorMessage( 404 ) ).toBe( '404' );
} );

it( 'returns the message of an error', () => {
expect( getErrorMessage( new Error( 'Not allowed' ) ) ).toBe(
'Not allowed'
);
} );

it( 'returns the message of a plain object', () => {
expect( getErrorMessage( { message: 'REST failure' } ) ).toBe(
'REST failure'
);
} );

it( 'returns the name of an error with an empty message', () => {
expect( getErrorMessage( new TypeError() ) ).toBe( 'TypeError' );
} );

it( 'decodes HTML entities', () => {
expect(
getErrorMessage( { message: 'You don&#8217;t have access.' } )
).toBe( 'You don’t have access.' );
} );
} );
20 changes: 20 additions & 0 deletions packages/workflow/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@wordpress/monorepo-tools/tsconfig/base.json",
"compilerOptions": {
"types": [ "style-imports" ]
},
"references": [
{ "path": "../abilities/tsconfig.build.json" },
{ "path": "../components/tsconfig.build.json" },
{ "path": "../core-abilities" },
{ "path": "../data" },
{ "path": "../element/tsconfig.build.json" },
{ "path": "../html-entities/tsconfig.build.json" },
{ "path": "../i18n/tsconfig.build.json" },
{ "path": "../icons/tsconfig.build.json" },
{ "path": "../keyboard-shortcuts" },
{ "path": "../keycodes/tsconfig.build.json" },
{ "path": "../ui/tsconfig.build.json" }
]
}
17 changes: 3 additions & 14 deletions packages/workflow/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,19 +1,8 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "@wordpress/monorepo-tools/tsconfig/base.json",
"extends": "@wordpress/monorepo-tools/tsconfig/dev.base.json",
"compilerOptions": {
"types": [ "style-imports" ]
"types": [ "style-imports", "gutenberg-vitest-test-env" ]
},
"references": [
{ "path": "../abilities/tsconfig.build.json" },
{ "path": "../components/tsconfig.build.json" },
{ "path": "../core-abilities" },
{ "path": "../data" },
{ "path": "../element/tsconfig.build.json" },
{ "path": "../i18n/tsconfig.build.json" },
{ "path": "../icons/tsconfig.build.json" },
{ "path": "../keyboard-shortcuts" },
{ "path": "../keycodes/tsconfig.build.json" },
{ "path": "../ui/tsconfig.build.json" }
]
"references": [ { "path": "./tsconfig.build.json" } ]
}
2 changes: 1 addition & 1 deletion tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@
{ "path": "packages/widget-primitives/tsconfig.build.json" },
{ "path": "packages/wordcount/tsconfig.build.json" },
{ "path": "packages/worker-threads/tsconfig.build.json" },
{ "path": "packages/workflow" },
{ "path": "packages/workflow/tsconfig.build.json" },
{ "path": "packages/wp-build" }
],
"files": []
Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
{ "path": "packages/widget-primitives" },
{ "path": "packages/wordcount" },
{ "path": "packages/worker-threads" },
{ "path": "packages/workflow" },
{ "path": "routes/connectors-home" },
{ "path": "routes/connectors-home/tsconfig.test.json" },
{ "path": "routes/dashboard" },
Expand Down
Loading