diff --git a/components/header-bar/src/command-palette/__tests__/command-palette.test.js b/components/header-bar/src/command-palette/__tests__/command-palette.test.js index 7a4d318f9..a72385157 100644 --- a/components/header-bar/src/command-palette/__tests__/command-palette.test.js +++ b/components/header-bar/src/command-palette/__tests__/command-palette.test.js @@ -1,7 +1,24 @@ -import { render } from '@testing-library/react' +import { render as originalRender } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import PropTypes from 'prop-types' import React from 'react' import CommandPalette from '../command-palette.js' +import { CommandPaletteContextProvider } from '../context/command-palette-context.js' + +const CommandPaletteProviderWrapper = ({ children }) => { + return ( + <CommandPaletteContextProvider> + {children} + </CommandPaletteContextProvider> + ) +} + +CommandPaletteProviderWrapper.propTypes = { + children: PropTypes.node, +} + +const render = (ui, options) => + originalRender(ui, { wrapper: CommandPaletteProviderWrapper, ...options }) describe('Command Palette Component', () => { const headerBarIconTest = 'headerbar-apps-icon' @@ -144,7 +161,6 @@ describe('Command Palette Component', () => { expect(searchField).toHaveValue('Command') expect(queryByTestId('headerbar-top-apps-list')).not.toBeInTheDocument() - expect(queryByTestId('headerbar-search-results')).toBeInTheDocument() expect(queryByText(/Results for "Command"/i)).toBeInTheDocument() expect(queryByText(/Test Command/)).toBeInTheDocument() expect(queryByText(/Test App/)).not.toBeInTheDocument() @@ -153,9 +169,8 @@ describe('Command Palette Component', () => { const clearButton = getAllByRole('button')[1] userEvent.click(clearButton) expect(searchField).toHaveValue('') - expect( - queryByTestId('headerbar-search-results') - ).not.toBeInTheDocument() + // back to default view + expect(queryByTestId('headerbar-top-apps-list')).toBeInTheDocument() }) it('renders Browse Apps View', () => { diff --git a/components/header-bar/src/command-palette/command-palette.js b/components/header-bar/src/command-palette/command-palette.js index d71f78578..b07a24238 100755 --- a/components/header-bar/src/command-palette/command-palette.js +++ b/components/header-bar/src/command-palette/command-palette.js @@ -3,72 +3,66 @@ import { IconApps24 } from '@dhis2/ui-icons' import PropTypes from 'prop-types' import React, { useState, useCallback, useRef, useEffect } from 'react' import i18n from '../locales/index.js' -import ActionsMenu from './sections/actions-menu.js' +import { useCommandPaletteContext } from './context/command-palette-context.js' +import { useFilter } from './hooks/use-filter.js' +import { useNavigation } from './hooks/use-navigation.js' import BackButton from './sections/back-button.js' import ModalContainer from './sections/container.js' import Search from './sections/search-field.js' -import { filterItemsArray } from './utils/filterItemsArray.js' -import BrowseApps from './views/browse-apps.js' -import BrowseCommands from './views/browse-commands.js' -import BrowseShortcuts from './views/browse-shortcuts.js' import HomeView from './views/home-view.js' - -const MIN_APPS_NUM = 8 +import { + BrowseApps, + BrowseCommands, + BrowseShortcuts, +} from './views/list-view.js' const CommandPalette = ({ apps, commands, shortcuts }) => { const containerEl = useRef(null) const [show, setShow] = useState(false) - const [filter, setFilter] = useState('') - - const [currentView, setCurrentView] = useState('home') - - const showActions = filter.length <= 0 && currentView === 'home' + const { currentView, filter, setFilter } = useCommandPaletteContext() const handleVisibilityToggle = useCallback(() => setShow(!show), [show]) - const handleFilterChange = useCallback(({ value }) => setFilter(value), []) + const handleFilterChange = useCallback( + ({ value }) => setFilter(value), + [setFilter] + ) - const goToDefaultView = () => { - setFilter('') - setCurrentView('home') - } + const { + filteredApps, + filteredCommands, + filteredShortcuts, + currentViewItemsArray, + } = useFilter({ apps, commands, shortcuts }) - const filteredApps = filterItemsArray(apps, filter) - const filteredCommands = filterItemsArray(commands, filter) - const filteredShortcuts = filterItemsArray(shortcuts, filter) + const { handleKeyDown, goToDefaultView, modalRef } = useNavigation({ + setShow, + itemsArray: currentViewItemsArray, + show, + }) - const handleKeyDown = useCallback( - (event) => { - switch (event.key) { - case 'Escape': - event.preventDefault() - if (currentView === 'home') { - setShow(false) - } else { - goToDefaultView() - } - break - } + useEffect(() => { + const activeItem = document.querySelector('.highlighted') + if (activeItem && typeof activeItem.scrollIntoView === 'function') { + activeItem?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + }) - if ((event.metaKey || event.ctrlKey) && event.key === '/') { - setShow(!show) - } - }, - [currentView, show] - ) + useEffect(() => { + if (modalRef.current) { + modalRef.current?.focus() + } + }) - const handleFocus = (e) => { - // this is about the focus of the element - // on launch: focus entire element - console.log(e.target, 'e.target') - console.log(document.activeElement, 'active element') + const handleFocus = (event) => { + if (event.target === modalRef?.current) { + modalRef.current?.querySelector('input').focus() + } } useEffect(() => { document.addEventListener('keydown', handleKeyDown) - document.addEventListener('focus', handleFocus) return () => { document.removeEventListener('keydown', handleKeyDown) - document.removeEventListener('focus', handleFocus) } }, [handleKeyDown]) @@ -82,20 +76,24 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { </button> {show ? ( <ModalContainer setShow={setShow} show={show}> - <div data-test="headerbar-menu" className="headerbar-menu"> + <div + data-test="headerbar-menu" + className="headerbar-menu" + ref={modalRef} + tabIndex={0} + onFocus={handleFocus} + > <Search value={filter} onChange={handleFilterChange} placeholder={ - currentView === 'home' - ? i18n.t('Search apps, shortcuts, commands') - : currentView === 'apps' + currentView === 'apps' ? i18n.t('Search apps') : currentView === 'commands' ? i18n.t('Search commands') : currentView === 'shortcuts' ? i18n.t('Search shortcuts') - : null + : i18n.t('Search apps, shortcuts, commands') } /> <div className="headerbar-menu-content"> @@ -103,47 +101,28 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { <BackButton onClickHandler={goToDefaultView} /> ) : null} {/* switch views */} - {currentView === 'apps' && ( - <BrowseApps + {currentView === 'home' && ( + <HomeView apps={filteredApps} - filter={filter} + commands={filteredCommands} + shortcuts={filteredShortcuts} /> )} + {currentView === 'apps' && ( + <BrowseApps apps={filteredApps} /> + )} {currentView === 'commands' && ( - <BrowseCommands - commands={filteredCommands} - filter={filter} - type={'commands'} - /> + <BrowseCommands commands={filteredCommands} /> )} {currentView === 'shortcuts' && ( <BrowseShortcuts shortcuts={filteredShortcuts} - filter={filter} - /> - )} - {currentView === 'home' && ( - <HomeView - apps={filteredApps} - commands={filteredCommands} - shortcuts={filteredShortcuts} - filter={filter} - /> - )} - {/* actions sections */} - {showActions && ( - <ActionsMenu - showAppsList={apps?.length > MIN_APPS_NUM} - showCommandsList={commands?.length > 0} - showShortcutsList={shortcuts?.length > 0} - setCurrentView={setCurrentView} /> )} </div> </div> </ModalContainer> ) : null} - <style jsx>{` button { display: block; @@ -173,9 +152,8 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { height: 100%; } .headerbar-menu-content { - display: flex; - flex-direction: column; overflow-y: auto; + max-height: calc(544px - 50px); } `}</style> </div> diff --git a/components/header-bar/src/command-palette/context/command-palette-context.js b/components/header-bar/src/command-palette/context/command-palette-context.js new file mode 100644 index 000000000..a23768037 --- /dev/null +++ b/components/header-bar/src/command-palette/context/command-palette-context.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types' +import React, { createContext, useContext, useState } from 'react' + +const commandPaletteContext = createContext() + +export const CommandPaletteContextProvider = ({ children }) => { + const [filter, setFilter] = useState('') + const [highlightedIndex, setHighlightedIndex] = useState(0) + const [currentView, setCurrentView] = useState('home') + // home view sections + const [activeSection, setActiveSection] = useState('grid') + + return ( + <commandPaletteContext.Provider + value={{ + filter, + setFilter, + highlightedIndex, + setHighlightedIndex, + currentView, + setCurrentView, + activeSection, + setActiveSection, + }} + > + {children} + </commandPaletteContext.Provider> + ) +} +CommandPaletteContextProvider.propTypes = { + children: PropTypes.node, +} + +export const useCommandPaletteContext = () => useContext(commandPaletteContext) diff --git a/components/header-bar/src/command-palette/hooks/use-filter.js b/components/header-bar/src/command-palette/hooks/use-filter.js new file mode 100644 index 000000000..7f65be78e --- /dev/null +++ b/components/header-bar/src/command-palette/hooks/use-filter.js @@ -0,0 +1,30 @@ +import { useMemo } from 'react' +import { useCommandPaletteContext } from '../context/command-palette-context.js' +import { filterItemsArray } from '../utils/filterItemsArray.js' + +export const useFilter = ({ apps, commands, shortcuts }) => { + const { filter, currentView } = useCommandPaletteContext() + + const filteredApps = filterItemsArray(apps, filter) + const filteredCommands = filterItemsArray(commands, filter) + const filteredShortcuts = filterItemsArray(shortcuts, filter) + + const currentViewItemsArray = useMemo(() => { + if (currentView === 'apps') { + return filteredApps + } else if (currentView === 'commands') { + return filteredCommands + } else if (currentView === 'shortcuts') { + return filteredShortcuts + } else { + return filteredApps.concat(filteredCommands, filteredShortcuts) + } + }, [currentView, filteredApps, filteredCommands, filteredShortcuts]) + + return { + filteredApps, + filteredCommands, + filteredShortcuts, + currentViewItemsArray, + } +} diff --git a/components/header-bar/src/command-palette/hooks/use-navigation.js b/components/header-bar/src/command-palette/hooks/use-navigation.js new file mode 100644 index 000000000..d584dc857 --- /dev/null +++ b/components/header-bar/src/command-palette/hooks/use-navigation.js @@ -0,0 +1,219 @@ +import { useCallback, useRef } from 'react' +import { useCommandPaletteContext } from '../context/command-palette-context.js' + +export const GRID_ITEMS_LENGTH = 8 + +export const useNavigation = ({ setShow, itemsArray, show }) => { + const modalRef = useRef(null) + const { + activeSection, + currentView, + filter, + highlightedIndex, + setHighlightedIndex, + setFilter, + setCurrentView, + setActiveSection, + } = useCommandPaletteContext() + + const goToDefaultView = useCallback(() => { + setFilter('') + setCurrentView('home') + setActiveSection('grid') + setHighlightedIndex(0) + }, [setActiveSection, setCurrentView, setFilter, setHighlightedIndex]) + + const handleListViewNavigation = useCallback( + (event) => { + const lastIndex = itemsArray.length - 1 + switch (event.key) { + case 'ArrowDown': + setHighlightedIndex( + highlightedIndex >= lastIndex ? 0 : highlightedIndex + 1 + ) + break + case 'ArrowUp': + setHighlightedIndex( + highlightedIndex > 0 ? highlightedIndex - 1 : lastIndex + ) + break + case 'Escape': + event.preventDefault() + goToDefaultView() + break + default: + break + } + }, + [ + goToDefaultView, + highlightedIndex, + itemsArray.length, + setHighlightedIndex, + ] + ) + + const handleHomeViewNavigation = useCallback( + (event) => { + // grid + const gridRowLength = GRID_ITEMS_LENGTH / 2 + const topRowLastIndex = gridRowLength - 1 + const lastRowFirstIndex = gridRowLength + const lastRowLastIndex = GRID_ITEMS_LENGTH - 1 + + switch (event.key) { + case 'ArrowLeft': + if (activeSection === 'grid') { + // row 1 + if (highlightedIndex <= topRowLastIndex) { + setHighlightedIndex( + highlightedIndex > 0 + ? highlightedIndex - 1 + : topRowLastIndex + ) + } + // row 2 + if (highlightedIndex >= lastRowFirstIndex) { + setHighlightedIndex( + highlightedIndex > lastRowFirstIndex + ? highlightedIndex - 1 + : lastRowLastIndex + ) + } + } + break + case 'ArrowRight': + if (activeSection === 'grid') { + // row 1 + if (highlightedIndex <= topRowLastIndex) { + setHighlightedIndex( + highlightedIndex >= topRowLastIndex + ? 0 + : highlightedIndex + 1 + ) + } + // row 2 + if (highlightedIndex >= lastRowFirstIndex) { + setHighlightedIndex( + highlightedIndex >= lastRowLastIndex + ? lastRowFirstIndex + : highlightedIndex + 1 + ) + } + } + break + case 'ArrowDown': + if (activeSection === 'grid') { + if (highlightedIndex >= lastRowFirstIndex) { + setActiveSection('actions') + setHighlightedIndex(0) + } else { + setHighlightedIndex( + highlightedIndex + gridRowLength + ) + } + } else if (activeSection === 'actions') { + if (highlightedIndex >= 3) { + setActiveSection('grid') + setHighlightedIndex(0) + } else { + setHighlightedIndex(highlightedIndex + 1) + } + } + break + case 'ArrowUp': + if (activeSection === 'grid') { + if (highlightedIndex < lastRowFirstIndex) { + setActiveSection('actions') + setHighlightedIndex(3) + } else { + setHighlightedIndex( + highlightedIndex - gridRowLength + ) + } + } else if (activeSection === 'actions') { + if (highlightedIndex <= 0) { + setActiveSection('grid') + setHighlightedIndex(lastRowFirstIndex) + } else { + setHighlightedIndex(highlightedIndex - 1) + } + } + break + case 'Escape': + event.preventDefault() + setShow(false) + setActiveSection('grid') + setHighlightedIndex(0) + break + default: + break + } + }, + [ + activeSection, + highlightedIndex, + setActiveSection, + setHighlightedIndex, + setShow, + ] + ) + + const handleKeyDown = useCallback( + (event) => { + const modal = modalRef.current + + if (currentView === 'home') { + if (filter.length > 0) { + // search mode + handleListViewNavigation(event) + } else { + handleHomeViewNavigation(event) + } + } else { + setActiveSection(null) + handleListViewNavigation(event) + } + + if ((event.metaKey || event.ctrlKey) && event.key === '/') { + setShow(!show) + goToDefaultView() + } + + if (event.key === 'Enter') { + if (activeSection === 'grid') { + window.open(itemsArray[highlightedIndex]?.['defaultAction']) + } else if (activeSection === 'actions') { + modal + ?.querySelector('.actions-menu') + ?.childNodes?.[highlightedIndex]?.click() + } else { + // open apps, shortcuts link + window.open(itemsArray[highlightedIndex]?.['defaultAction']) + // TODO: execute commands + } + } + }, + [ + activeSection, + currentView, + filter.length, + goToDefaultView, + handleHomeViewNavigation, + handleListViewNavigation, + highlightedIndex, + itemsArray, + setActiveSection, + setShow, + show, + ] + ) + + return { + handleKeyDown, + goToDefaultView, + modalRef, + activeSection, + setActiveSection, + } +} diff --git a/components/header-bar/src/command-palette/sections/actions-menu.js b/components/header-bar/src/command-palette/sections/actions-menu.js deleted file mode 100644 index a96d3863a..000000000 --- a/components/header-bar/src/command-palette/sections/actions-menu.js +++ /dev/null @@ -1,83 +0,0 @@ -import { clearSensitiveCaches, useConfig } from '@dhis2/app-runtime' -import { colors } from '@dhis2/ui-constants' -import { - IconApps16, - IconLogOut16, - IconRedo16, - IconTerminalWindow16, -} from '@dhis2/ui-icons' -import PropTypes from 'prop-types' -import React from 'react' -import { joinPath } from '../../join-path.js' -import i18n from '../../locales/index.js' -import Heading from './heading.js' -import ListItem from './list-item.js' - -const ActionsMenu = ({ - showAppsList, - showCommandsList, - showShortcutsList, - setCurrentView, -}) => { - const { baseUrl } = useConfig() - return ( - <div - role="menu" - className="actions-menu" - data-test="headerbar-actions-menu" - > - <Heading heading={'Actions'} /> - {showAppsList ? ( - <ListItem - title={i18n.t('Browse apps')} - icon={<IconApps16 color={colors.grey700} />} - onClickHandler={() => setCurrentView('apps')} - dataTest="headerbar-browse-apps" - /> - ) : null} - {showCommandsList ? ( - <ListItem - title={i18n.t('Browse commands')} - icon={<IconTerminalWindow16 color={colors.grey700} />} - onClickHandler={() => setCurrentView('commands')} - dataTest="headerbar-browse-commands" - /> - ) : null} - {showShortcutsList ? ( - <ListItem - title={i18n.t('Browse shortcuts')} - icon={<IconRedo16 color={colors.grey700} />} - onClickHandler={() => setCurrentView('shortcuts')} - dataTest="headerbar-browse-shortcuts" - /> - ) : null} - <ListItem - title={i18n.t('Logout')} - icon={<IconLogOut16 color={colors.grey700} />} - onClickHandler={async () => { - await clearSensitiveCaches() - window.location.assign( - joinPath( - baseUrl, - 'dhis-web-commons-security/logout.action' - ) - ) - }} - href={joinPath( - baseUrl, - 'dhis-web-commons-security/logout.action' - )} - dataTest="headerbar-logout" - /> - </div> - ) -} - -ActionsMenu.propTypes = { - setCurrentView: PropTypes.func, - showAppsList: PropTypes.bool, - showCommandsList: PropTypes.bool, - showShortcutsList: PropTypes.bool, -} - -export default ActionsMenu diff --git a/components/header-bar/src/command-palette/sections/app-item.js b/components/header-bar/src/command-palette/sections/app-item.js index 519ea85c8..a258d346a 100644 --- a/components/header-bar/src/command-palette/sections/app-item.js +++ b/components/header-bar/src/command-palette/sections/app-item.js @@ -1,10 +1,16 @@ import { colors, spacers } from '@dhis2/ui-constants' +import cx from 'classnames' import PropTypes from 'prop-types' import React from 'react' -function AppItem({ name, path, img }) { +function AppItem({ name, path, img, highlighted, handleMouseEnter }) { return ( - <a href={path}> + <a + href={path} + className={cx('item', { highlighted })} + onMouseEnter={handleMouseEnter} + tabIndex={-1} + > <img src={img} alt="app" className="app-icon" /> <span className="app-name">{name}</span> <style jsx>{` @@ -20,7 +26,8 @@ function AppItem({ name, path, img }) { color: ${colors.grey900}; transition: all 0.1s ease; } - a:hover { + a:hover, + .highlighted { background: ${colors.grey200}; cursor: pointer; } @@ -28,18 +35,12 @@ function AppItem({ name, path, img }) { background: ${colors.grey300}; } a:focus { - background: ${colors.grey200}; outline: none; } - // .grid-item-highlighted { - // background: var(--colors-grey200); - // } - .app-icon { width: 48px; height: 48px; } - .app-name { font-size: 13px; text-align: center; @@ -50,6 +51,8 @@ function AppItem({ name, path, img }) { } AppItem.propTypes = { + handleMouseEnter: PropTypes.func, + highlighted: PropTypes.bool, img: PropTypes.string, name: PropTypes.string, path: PropTypes.string, diff --git a/components/header-bar/src/command-palette/sections/back-button.js b/components/header-bar/src/command-palette/sections/back-button.js index 3dfcfb951..6e0a2dc52 100644 --- a/components/header-bar/src/command-palette/sections/back-button.js +++ b/components/header-bar/src/command-palette/sections/back-button.js @@ -12,6 +12,7 @@ function BackButton({ onClickHandler }) { name="Back" value="back" className="back-btn" + tabIndex={-1} > <IconArrowLeft16 /> </button> diff --git a/components/header-bar/src/command-palette/sections/list-item.js b/components/header-bar/src/command-palette/sections/list-item.js index 1107ce4bb..c3e1c4dad 100644 --- a/components/header-bar/src/command-palette/sections/list-item.js +++ b/components/header-bar/src/command-palette/sections/list-item.js @@ -13,6 +13,7 @@ function ListItem({ onClickHandler, highlighted, dataTest = 'headerbar-list-item', + handleMouseEnter, }) { const showDescription = type === 'commands' return ( @@ -21,6 +22,8 @@ function ListItem({ onClick={onClickHandler} className={cx('item', { highlighted })} data-test={dataTest} + onMouseEnter={handleMouseEnter} + tabIndex={-1} > <div className="icon"> {icon && <span className="icon-content">{icon}</span>} @@ -74,12 +77,9 @@ function ListItem({ } .text-content { display: flex; - width: 100%; - align-items: baseline; - gap: 4px; - } - .text-content { flex-direction: column; + align-items: baseline; + width: 100%; gap: ${spacers.dp8}; padding-top: 2px; } @@ -100,6 +100,7 @@ function ListItem({ ListItem.propTypes = { dataTest: PropTypes.string, description: PropTypes.string, + handleMouseEnter: PropTypes.func, highlighted: PropTypes.bool, icon: PropTypes.node, image: PropTypes.string, diff --git a/components/header-bar/src/command-palette/sections/list.js b/components/header-bar/src/command-palette/sections/list.js index 96d2cee8c..8a5761180 100644 --- a/components/header-bar/src/command-palette/sections/list.js +++ b/components/header-bar/src/command-palette/sections/list.js @@ -1,37 +1,12 @@ import PropTypes from 'prop-types' -import React, { useState, useRef, useEffect } from 'react' +import React from 'react' +import { useCommandPaletteContext } from '../context/command-palette-context.js' import ListItem from './list-item.js' function List({ filteredItems, type }) { - const divRef = useRef(null) - const [activeItem, setActiveItem] = useState(-1) - const lastIndex = filteredItems.length - 1 - - const handleKeyDown = (event) => { - switch (event.key) { - case 'ArrowDown': - setActiveItem(activeItem >= lastIndex ? 0 : activeItem + 1) - break - case 'ArrowUp': - setActiveItem(activeItem > 0 ? activeItem - 1 : lastIndex) - break - case 'Enter': - event.preventDefault() - event.target?.click() - break - } - } - - useEffect(() => { - if (divRef) { - if (filteredItems.length && activeItem > -1) { - divRef.current.children[activeItem].focus() - } - } - }, [activeItem, filteredItems]) - + const { highlightedIndex, setHighlightedIndex } = useCommandPaletteContext() return ( - <div data-test="headerbar-list" onKeyDown={handleKeyDown} ref={divRef}> + <div data-test="headerbar-list"> {filteredItems.map( ( { displayName, name, defaultAction, icon, description }, @@ -41,20 +16,14 @@ function List({ filteredItems, type }) { type={type} key={`app-${name}-${idx}`} title={displayName || name} - href={defaultAction} + path={defaultAction} image={icon} description={description} - highlighted={activeItem === idx} + highlighted={highlightedIndex === idx} + handleMouseEnter={() => setHighlightedIndex(idx)} /> ) )} - <style jsx>{` - div { - display: flex; - flex-direction: column; - overflow-x: hidden; - } - `}</style> </div> ) } diff --git a/components/header-bar/src/command-palette/sections/search-field.js b/components/header-bar/src/command-palette/sections/search-field.js index cd4a511da..6ea20c4cc 100644 --- a/components/header-bar/src/command-palette/sections/search-field.js +++ b/components/header-bar/src/command-palette/sections/search-field.js @@ -21,9 +21,7 @@ function Search({ value, onChange, placeholder }) { /> <style>{` .search { - border-bottom: 1px solid ${colors.grey300}; - } .search input { font-size: 14px; diff --git a/components/header-bar/src/command-palette/sections/search-results.js b/components/header-bar/src/command-palette/sections/search-results.js new file mode 100644 index 000000000..574f51e76 --- /dev/null +++ b/components/header-bar/src/command-palette/sections/search-results.js @@ -0,0 +1,31 @@ +import { colors } from '@dhis2/ui-constants' +import React from 'react' +import i18n from '../../locales/index.js' +import { useCommandPaletteContext } from '../context/command-palette-context.js' +import Heading from './heading.js' + +export function EmptySearchResults() { + const { filter } = useCommandPaletteContext() + + return ( + <> + <div className="empty-results" data-test="headerbar-empty-search"> + <Heading heading={i18n.t(`Nothing found for "${filter}"`)} /> + </div> + <style jsx>{` + .empty-results { + min-height: 92px; + display: flex; + align-items: center; + justify-content: center; + text-align: center; + font-size: 14px; + line-height: 19px; + color: ${colors.grey700}; + } + `}</style> + </> + ) +} + +export default EmptySearchResults diff --git a/components/header-bar/src/command-palette/views/browse-apps.js b/components/header-bar/src/command-palette/views/browse-apps.js deleted file mode 100644 index 4224c849a..000000000 --- a/components/header-bar/src/command-palette/views/browse-apps.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types' -import React from 'react' -import i18n from '../../locales/index.js' -import Heading from '../sections/heading.js' -import List from '../sections/list.js' -import SearchResults from './search-results.js' - -function BrowseApps({ apps, filter }) { - return ( - <> - {filter.length > 0 && ( - <SearchResults filter={filter} filteredItems={apps} /> - )} - {filter.length < 1 && ( - <> - <Heading heading={i18n.t('All Apps')} /> - <List filteredItems={apps} /> - </> - )} - </> - ) -} - -BrowseApps.propTypes = { - apps: PropTypes.array, - filter: PropTypes.string, -} - -export default BrowseApps diff --git a/components/header-bar/src/command-palette/views/browse-commands.js b/components/header-bar/src/command-palette/views/browse-commands.js deleted file mode 100644 index b24dcfbef..000000000 --- a/components/header-bar/src/command-palette/views/browse-commands.js +++ /dev/null @@ -1,30 +0,0 @@ -import PropTypes from 'prop-types' -import React from 'react' -import i18n from '../../locales/index.js' -import Heading from '../sections/heading.js' -import List from '../sections/list.js' -import SearchResults from './search-results.js' - -function BrowseCommands({ commands, filter, type }) { - return ( - <> - {filter.length > 0 && ( - <SearchResults filter={filter} filteredItems={commands} /> - )} - {filter.length < 1 && ( - <> - <Heading heading={i18n.t('All Commands')} /> - <List filteredItems={commands} type={type} /> - </> - )} - </> - ) -} - -BrowseCommands.propTypes = { - commands: PropTypes.array, - filter: PropTypes.string, - type: PropTypes.string, -} - -export default BrowseCommands diff --git a/components/header-bar/src/command-palette/views/browse-shortcuts.js b/components/header-bar/src/command-palette/views/browse-shortcuts.js deleted file mode 100644 index c86761d24..000000000 --- a/components/header-bar/src/command-palette/views/browse-shortcuts.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types' -import React from 'react' -import i18n from '../../locales/index.js' -import Heading from '../sections/heading.js' -import List from '../sections/list.js' -import SearchResults from './search-results.js' - -function BrowseShortcuts({ shortcuts, filter }) { - return ( - <> - {filter.length > 0 && ( - <SearchResults filter={filter} filteredItems={shortcuts} /> - )} - {filter.length < 1 && ( - <> - <Heading heading={i18n.t('All Shortcuts')} /> - <List filteredItems={shortcuts} /> - </> - )} - </> - ) -} - -BrowseShortcuts.propTypes = { - filter: PropTypes.string, - shortcuts: PropTypes.array, -} - -export default BrowseShortcuts diff --git a/components/header-bar/src/command-palette/views/home-view.js b/components/header-bar/src/command-palette/views/home-view.js index d261884c2..33b03da76 100644 --- a/components/header-bar/src/command-palette/views/home-view.js +++ b/components/header-bar/src/command-palette/views/home-view.js @@ -1,116 +1,189 @@ -import { spacers } from '@dhis2/ui-constants' +import { clearSensitiveCaches, useConfig } from '@dhis2/app-runtime' +import { spacers, colors } from '@dhis2/ui-constants' +import { + IconApps16, + IconLogOut16, + IconRedo16, + IconTerminalWindow16, +} from '@dhis2/ui-icons' import PropTypes from 'prop-types' -import React, { useEffect, useRef, useState } from 'react' +import React from 'react' +import { joinPath } from '../../join-path.js' +import i18n from '../../locales/index.js' +import { useCommandPaletteContext } from '../context/command-palette-context.js' +import { GRID_ITEMS_LENGTH } from '../hooks/use-navigation.js' import AppItem from '../sections/app-item.js' import Heading from '../sections/heading.js' -import SearchResults from './search-results.js' +import ListItem from '../sections/list-item.js' +import ListView from './list-view.js' -function HomeView({ apps, commands, shortcuts, filter }) { - const divRef = useRef(null) - const [activeItem, setActiveItem] = useState(-1) - const filteredItems = apps.concat(commands, shortcuts) - - const handleKeyDown = (event) => { - switch (event.key) { - case 'ArrowLeft': - // row 1 - if (activeItem <= 3) { - setActiveItem(activeItem > 0 ? activeItem - 1 : 3) - } - // row 2 - if (activeItem >= 4) { - setActiveItem(activeItem > 4 ? activeItem - 1 : 7) - } - break - case 'ArrowRight': - // row 1 - if (activeItem <= 3) { - setActiveItem(activeItem >= 3 ? 0 : activeItem + 1) - } - // row 2 - if (activeItem >= 4) { - setActiveItem(activeItem >= 7 ? 4 : activeItem + 1) - } - break - case 'ArrowDown': - setActiveItem(activeItem >= 4 ? activeItem - 4 : activeItem + 4) - break - case 'ArrowUp': - setActiveItem(activeItem <= 3 ? activeItem + 4 : activeItem - 4) - break - case 'Enter': - event.preventDefault() - event.target?.click() - break - case 'Tab': - event.preventDefault() - } - } - - const handleFocus = () => { - if (divRef) { - if (activeItem <= -1) { - setActiveItem(0) - } - } - } - - useEffect(() => { - if (divRef) { - if (apps.length && activeItem > -1) { - divRef.current?.children[activeItem]?.focus() - } - } - }, [activeItem, apps.length]) +const MIN_APPS_NUM = GRID_ITEMS_LENGTH +function HomeView({ apps, commands, shortcuts }) { + const { baseUrl } = useConfig() + const { + filter, + setCurrentView, + highlightedIndex, + setHighlightedIndex, + activeSection, + setActiveSection, + } = useCommandPaletteContext() + const filteredItems = apps.concat(commands, shortcuts) + const topApps = apps?.slice(0, 8) return ( - <div onKeyDown={handleKeyDown} onFocus={handleFocus}> - {filter.length > 0 && ( - <SearchResults filter={filter} filteredItems={filteredItems} /> - )} - {/* normal view */} - {filter.length < 1 && apps.length > 0 && ( + <> + {filter.length > 0 ? ( + <ListView filteredItems={filteredItems} /> + ) : ( <> - <Heading heading={'Top apps'} /> + {apps.length > 0 && ( + <> + <Heading heading={i18n.t('Top apps')} /> + <div + data-test="headerbar-top-apps-list" + className="headerbar-top-apps" + > + {topApps.map( + ( + { + displayName, + name, + defaultAction, + icon, + }, + idx + ) => ( + <AppItem + key={`app-${name}-${idx}`} + name={displayName || name} + path={defaultAction} + img={icon} + highlighted={ + activeSection === 'grid' && + highlightedIndex === idx + } + handleMouseEnter={() => { + setActiveSection('grid') + setHighlightedIndex(idx) + }} + /> + ) + )} + <style jsx>{` + .headerbar-top-apps { + display: grid; + grid-template-columns: repeat(4, 1fr); + padding: 0 ${spacers.dp4}; + } + `}</style> + </div> + </> + )} + {/* actions menu */} + <Heading heading={'Actions'} /> <div - data-test="headerbar-top-apps-list" - ref={divRef} - className="headerbar-top-apps" + role="menu" + className="actions-menu" + data-test="headerbar-actions-menu" > - {apps - .slice(0, 8) - .map( - ( - { displayName, name, defaultAction, icon }, - idx - ) => ( - <AppItem - key={`app-${name}-${idx}`} - name={displayName || name} - path={defaultAction} - img={icon} + {apps?.length > MIN_APPS_NUM ? ( + <ListItem + title={i18n.t('Browse apps')} + icon={<IconApps16 color={colors.grey700} />} + onClickHandler={() => { + setCurrentView('apps') + setHighlightedIndex(0) + }} + dataTest="headerbar-browse-apps" + highlighted={ + activeSection === 'actions' && + highlightedIndex === 0 + } + handleMouseEnter={() => { + setActiveSection('actions') + setHighlightedIndex(0) + }} + /> + ) : null} + {commands?.length > 0 ? ( + <ListItem + title={i18n.t('Browse commands')} + icon={ + <IconTerminalWindow16 + color={colors.grey700} /> + } + onClickHandler={() => { + setCurrentView('commands') + setHighlightedIndex(0) + }} + dataTest="headerbar-browse-commands" + highlighted={ + activeSection === 'actions' && + highlightedIndex === 1 + } + handleMouseEnter={() => { + setActiveSection('actions') + setHighlightedIndex(1) + }} + /> + ) : null} + {shortcuts?.length > 0 ? ( + <ListItem + title={i18n.t('Browse shortcuts')} + icon={<IconRedo16 color={colors.grey700} />} + onClickHandler={() => { + setCurrentView('shortcuts') + setHighlightedIndex(0) + }} + dataTest="headerbar-browse-shortcuts" + highlighted={ + activeSection === 'actions' && + highlightedIndex === 2 + } + handleMouseEnter={() => { + setActiveSection('actions') + setHighlightedIndex(2) + }} + /> + ) : null} + <ListItem + title={i18n.t('Logout')} + icon={<IconLogOut16 color={colors.grey700} />} + onClickHandler={async () => { + await clearSensitiveCaches() + window.location.assign( + joinPath( + baseUrl, + 'dhis-web-commons-security/logout.action' + ) ) + }} + href={joinPath( + baseUrl, + 'dhis-web-commons-security/logout.action' )} - - <style jsx>{` - .headerbar-top-apps { - display: grid; - grid-template-columns: repeat(4, 1fr); - padding: 0 ${spacers.dp4}; + dataTest="headerbar-logout" + highlighted={ + activeSection === 'actions' && + highlightedIndex === 3 } - `}</style> + handleMouseEnter={() => { + setActiveSection('actions') + setHighlightedIndex(3) + }} + /> </div> </> )} - </div> + </> ) } HomeView.propTypes = { apps: PropTypes.array, commands: PropTypes.array, - filter: PropTypes.string, shortcuts: PropTypes.array, } diff --git a/components/header-bar/src/command-palette/views/list-view.js b/components/header-bar/src/command-palette/views/list-view.js index 9371761a6..d62882ebb 100644 --- a/components/header-bar/src/command-palette/views/list-view.js +++ b/components/header-bar/src/command-palette/views/list-view.js @@ -1,36 +1,64 @@ import PropTypes from 'prop-types' import React from 'react' +import i18n from '../../locales/index.js' +import { useCommandPaletteContext } from '../context/command-palette-context.js' import Heading from '../sections/heading.js' import List from '../sections/list.js' -import { escapeRegExpCharacters } from '../utils/escapeCharacters.js' +import { EmptySearchResults } from '../sections/search-results.js' -function ListView({ heading, itemsArray, filter, type }) { - const filteredItems = itemsArray.filter(({ displayName, name }) => { - const itemName = displayName || name - const formattedItemName = itemName.toLowerCase() - const formattedFilter = escapeRegExpCharacters(filter).toLowerCase() +export function BrowseApps({ apps }) { + return <ListView heading={i18n.t('All Apps')} filteredItems={apps} /> +} - return filter.length > 0 - ? formattedItemName.match(formattedFilter) - : true - }) +BrowseApps.propTypes = { + apps: PropTypes.array, +} +export function BrowseCommands({ commands }) { + return ( + <ListView + heading={i18n.t('All Commands')} + filteredItems={commands} + type={'commands'} + /> + ) +} + +BrowseCommands.propTypes = { + commands: PropTypes.array, +} +export function BrowseShortcuts({ shortcuts }) { return ( - <div> + <ListView heading={i18n.t('All Shortcuts')} filteredItems={shortcuts} /> + ) +} + +BrowseShortcuts.propTypes = { + shortcuts: PropTypes.array, +} + +function ListView({ heading, filteredItems, type }) { + const { filter } = useCommandPaletteContext() + + return filteredItems.length > 0 ? ( + <> <Heading - filter={filter} - filteredItems={filteredItems} - heading={heading} + heading={ + filter.length > 0 + ? i18n.t(`Results for "${filter}"`) + : heading + } /> <List filteredItems={filteredItems} type={type} /> - </div> - ) + </> + ) : filter ? ( + <EmptySearchResults /> + ) : null } ListView.propTypes = { - filter: PropTypes.string, + filteredItems: PropTypes.array, heading: PropTypes.string, - itemsArray: PropTypes.array, type: PropTypes.string, } diff --git a/components/header-bar/src/command-palette/views/search-results.js b/components/header-bar/src/command-palette/views/search-results.js deleted file mode 100644 index 892b79d3a..000000000 --- a/components/header-bar/src/command-palette/views/search-results.js +++ /dev/null @@ -1,47 +0,0 @@ -import { colors } from '@dhis2/ui-constants' -import PropTypes from 'prop-types' -import React from 'react' -import i18n from '../../locales/index.js' -import Heading from '../sections/heading.js' -import List from '../sections/list.js' - -function SearchResults({ filter, filteredItems }) { - return ( - <> - {filteredItems.length > 0 ? ( - <div data-test="headerbar-search-results"> - <Heading heading={i18n.t(`Results for "${filter}"`)} /> - <List filteredItems={filteredItems} /> - </div> - ) : ( - <div - className="empty-results" - data-test="headerbar-empty-search" - > - <Heading - heading={i18n.t(`Nothing found for "${filter}"`)} - /> - </div> - )} - <style jsx>{` - .empty-results { - min-height: 92px; - display: flex; - align-items: center; - justify-content: center; - text-align: center; - font-size: 14px; - line-height: 19px; - color: ${colors.grey700}; - } - `}</style> - </> - ) -} - -SearchResults.propTypes = { - filter: PropTypes.string, - filteredItems: PropTypes.array, -} - -export default SearchResults diff --git a/components/header-bar/src/header-bar.js b/components/header-bar/src/header-bar.js index 1a2854046..47271f93d 100755 --- a/components/header-bar/src/header-bar.js +++ b/components/header-bar/src/header-bar.js @@ -3,6 +3,7 @@ import { colors } from '@dhis2/ui-constants' import PropTypes from 'prop-types' import React, { useMemo } from 'react' import CommandPalette from './command-palette/command-palette.js' +import { CommandPaletteContextProvider } from './command-palette/context/command-palette-context.js' import { HeaderBarContextProvider } from './header-bar-context.js' import { joinPath } from './join-path.js' import i18n from './locales/index.js' @@ -128,12 +129,13 @@ export const HeaderBar = ({ } userAuthorities={data.user.authorities} /> - <CommandPalette - apps={apps} - commands={commands} - shortcuts={shortcuts} - /> - + <CommandPaletteContextProvider> + <CommandPalette + apps={apps} + commands={commands} + shortcuts={shortcuts} + /> + </CommandPaletteContextProvider> <Profile name={data.user.name} email={data.user.email}