Skip to content

Commit

Permalink
Allow nested command loaders within the command center
Browse files Browse the repository at this point in the history
  • Loading branch information
youknowriad committed Apr 7, 2023
1 parent e9b4e69 commit b7440a9
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 55 deletions.
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

0 comments on commit b7440a9

Please sign in to comment.