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

Allow nested command loaders within the command center #49658

Closed
wants to merge 1 commit into from
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
178 changes: 129 additions & 49 deletions packages/commands/src/components/command-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,19 @@ import { Command } from 'cmdk';
import { useSelect } from '@wordpress/data';
import { useState, useEffect, useRef, useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Modal, TextHighlight } from '@wordpress/components';
import { Modal, TextHighlight, Button } from '@wordpress/components';
import { Icon, chevronLeft } from '@wordpress/icons';

/**
* Internal dependencies
*/
import { store as commandsStore } from '../store';

function CommandMenuLoader( { name, search, hook, setLoader, close } ) {
function CommandMenuLoader( { name, search, hook, setIsLoading, close } ) {
const { isLoading, commands = [] } = hook( { search } ) ?? {};
useEffect( () => {
setLoader( name, isLoading );
}, [ setLoader, name, isLoading ] );
setIsLoading( name, isLoading );
}, [ setIsLoading, name, isLoading ] );

return (
<>
Expand Down Expand Up @@ -48,33 +49,45 @@ function CommandMenuLoader( { name, search, hook, setLoader, close } ) {
);
}

export function CommandMenuLoaderWrapper( { hook, search, setLoader, close } ) {
export function CommandMenuLoaderWrapper( {
loader,
search,
setIsLoading,
close,
} ) {
// The "hook" prop is actually a custom React hook
// so to avoid breaking the rules of hooks
// the CommandMenuLoaderWrapper component need to be
// remounted on each hook prop change
// We use the key state to make sure we do that properly.
const currentLoader = useRef( hook );
const currentLoader = useRef( loader.hook );
const [ key, setKey ] = useState( 0 );
useEffect( () => {
if ( currentLoader.current !== hook ) {
currentLoader.current = hook;
if ( currentLoader.current !== loader.hook ) {
currentLoader.current = loader.hook;
setKey( ( prevKey ) => prevKey + 1 );
}
}, [ hook ] );
}, [ loader.hook ] );

return (
<CommandMenuLoader
key={ key }
name={ loader.name }
hook={ currentLoader.current }
search={ search }
setLoader={ setLoader }
setIsLoading={ setIsLoading }
close={ close }
/>
);
}

export function CommandMenuGroup( { group, search, setLoader, close } ) {
export function CommandMenuGroup( {
group,
search,
setIsLoading,
close,
selectLoader,
} ) {
const { commands, loaders } = useSelect(
( select ) => {
const { getCommands, getCommandLoaders } = select( commandsStore );
Expand All @@ -91,7 +104,7 @@ export function CommandMenuGroup( { group, search, setLoader, close } ) {
{ commands.map( ( command ) => (
<Command.Item
key={ command.name }
value={ command.name }
value={ command.label }
onSelect={ () => command.callback( { close } ) }
>
<span className="commands-command-menu__item">
Expand All @@ -102,29 +115,69 @@ export function CommandMenuGroup( { group, search, setLoader, close } ) {
</span>
</Command.Item>
) ) }
{ loaders.map( ( loader ) => (
<CommandMenuLoaderWrapper
key={ loader.name }
hook={ loader.hook }
search={ search }
setLoader={ setLoader }
close={ close }
/>
) ) }
{ loaders.map( ( loader ) =>
loader.isNested && ! search ? (
<Command.Item
key={ loader.name }
value={ loader.placeholder }
onSelect={ () => selectLoader( loader.name ) }
>
{ loader.placeholder }
</Command.Item>
) : (
<CommandMenuLoaderWrapper
key={ loader.name }
loader={ loader }
search={ search }
setIsLoading={ setIsLoading }
close={ close }
/>
)
) }
</Command.Group>
);
}

export function CommandMenu() {
const [ search, setSearch ] = useState( '' );
const [ open, setOpen ] = useState( false );
function RootCommandMenu( { search, close, setIsLoading, selectLoader } ) {
const { groups } = useSelect( ( select ) => {
const { getGroups } = select( commandsStore );
return {
groups: getGroups(),
};
}, [] );
const [ loaders, setLoaders ] = useState( {} );

return (
<Command.List>
{ groups.map( ( group ) => (
<CommandMenuGroup
key={ group }
group={ group }
search={ search }
setIsLoading={ setIsLoading }
close={ close }
selectLoader={ selectLoader }
/>
) ) }
</Command.List>
);
}

export function CommandMenu() {
const [ selectedLoader, selectLoader ] = useState( null );
const [ search, setSearch ] = useState( '' );
const [ open, setOpen ] = useState( false );
const [ loadings, setLoadings ] = useState( {} );
const { loader } = useSelect(
( select ) => {
const { getCommandLoader } = select( commandsStore );
return {
loader: selectedLoader
? getCommandLoader( selectedLoader )
: null,
};
},
[ selectedLoader ]
);

// Toggle the menu when Meta-K is pressed
useEffect( () => {
Expand All @@ -139,23 +192,28 @@ export function CommandMenu() {
return () => document.removeEventListener( 'keydown', toggleOnMetaK );
}, [] );

const setLoader = useCallback(
const goBack = useCallback( () => {
selectLoader( null );
}, [ selectLoader ] );
const setIsLoading = useCallback(
( name, value ) =>
setLoaders( ( current ) => ( {
setLoadings( ( current ) => ( {
...current,
[ name ]: value,
} ) ),
[]
);
const close = () => {
const close = useCallback( () => {
setSearch( '' );
setOpen( false );
};
selectLoader( null );
}, [ setSearch, setOpen, selectLoader ] );

if ( ! open ) {
return false;
}
const isLoading = Object.values( loaders ).some( Boolean );

const isLoading = Object.values( loadings ).some( Boolean );

return (
<Modal
Expand All @@ -167,33 +225,55 @@ export function CommandMenu() {
<div className="commands-command-menu__container">
<Command label={ __( 'Global Command Menu' ) }>
<div className="commands-command-menu__header">
{ !! loader && (
<Button onClick={ goBack }>
<Icon icon={ chevronLeft } size={ 24 } />
</Button>
) }
<Command.Input
// The input should be focused when the modal is opened.
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
value={ search }
onValueChange={ setSearch }
placeholder={ __(
'Search for content and templates, or try commands like "Add…"'
) }
placeholder={
loader
? loader.placeholder
: __(
'Search for content and templates, or try commands like "Add…"'
)
}
onKeyDown={ ( event ) => {
if (
event.key === 'Backspace' &&
search === ''
) {
goBack();
}
} }
/>
</div>
<Command.List>
{ ! isLoading && (
<Command.Empty>
{ __( 'No results found.' ) }
</Command.Empty>
) }
{ groups.map( ( group ) => (
<CommandMenuGroup
key={ group }
group={ group }
search={ search }
setLoader={ setLoader }
close={ close }
/>
) ) }
</Command.List>
{ ! isLoading && (
<Command.Empty>
{ __( 'No results found.' ) }
</Command.Empty>
) }
{ ! loader && (
<RootCommandMenu
search={ search }
setIsLoading={ setIsLoading }
close={ close }
selectLoader={ selectLoader }
/>
) }
{ loader && (
<CommandMenuLoaderWrapper
loader={ loader }
search={ search }
setIsLoading={ setIsLoading }
close={ close }
/>
) }
</Command>
</div>
</Modal>
Expand Down
20 changes: 18 additions & 2 deletions packages/commands/src/hooks/use-command-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,33 @@ import { store as commandsStore } from '../store';
*
* @param {import('../store/actions').WPCommandLoaderConfig} loader command loader config.
*/
export default function useCommandLoader( { name, group, hook } ) {
export default function useCommandLoader( {
name,
group,
hook,
isNested,
placeholder,
} ) {
const { registerCommandLoader, unregisterCommandLoader } =
useDispatch( commandsStore );
useEffect( () => {
registerCommandLoader( {
name,
group,
hook,
isNested,
placeholder,
} );
return () => {
unregisterCommandLoader( name, group );
};
}, [ name, group, hook, registerCommandLoader, unregisterCommandLoader ] );
}, [
name,
group,
hook,
isNested,
placeholder,
registerCommandLoader,
unregisterCommandLoader,
] );
}
18 changes: 14 additions & 4 deletions packages/commands/src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,11 @@
*
* @typedef {Object} WPCommandLoaderConfig
*
* @property {string} name Command loader name.
* @property {string=} group Command loader group.
* @property {WPCommandLoaderHook} hook Command loader hook.
* @property {string} name Command loader name.
* @property {string=} group Command loader group.
* @property {WPCommandLoaderHook} hook Command loader hook.
* @property {boolean=} isNested Whether the command loader is nested.
* @property {string=} placeholder Command loader placeholder.
*/

/**
Expand Down Expand Up @@ -65,12 +67,20 @@ export function unregisterCommand( name, group ) {
*
* @return {Object} action.
*/
export function registerCommandLoader( { name, group = '', hook } ) {
export function registerCommandLoader( {
name,
group = '',
hook,
isNested = false,
placeholder,
} ) {
return {
type: 'REGISTER_COMMAND_LOADER',
name,
group,
hook,
isNested,
placeholder,
};
}

Expand Down
3 changes: 3 additions & 0 deletions packages/commands/src/store/reducer.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ function commandLoaders( state = {}, action ) {
[ action.name ]: {
name: action.name,
hook: action.hook,
group: action.group,
isNested: action.isNested,
placeholder: action.placeholder,
},
},
};
Expand Down
13 changes: 13 additions & 0 deletions packages/commands/src/store/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,16 @@ export const getCommandLoaders = createSelector(
( state, group ) => Object.values( state.commandLoaders[ group ] ?? {} ),
( state, group ) => [ state.commandLoaders[ group ] ]
);

export function getCommandLoader( state, loaderName ) {
const group = Object.keys( state.commandLoaders ).find(
( currentGroup ) =>
!! state.commandLoaders[ currentGroup ][ loaderName ]
);

if ( ! group ) {
return null;
}

return state.commandLoaders[ group ][ loaderName ];
}
Loading