diff --git a/components/header-bar/src/apps.js b/components/header-bar/src/apps.js
deleted file mode 100755
index 37e5f805ce..0000000000
--- a/components/header-bar/src/apps.js
+++ /dev/null
@@ -1,276 +0,0 @@
-import { useConfig } from '@dhis2/app-runtime'
-import { colors, spacers, theme } from '@dhis2/ui-constants'
-import { IconApps24, IconSettings24 } from '@dhis2/ui-icons'
-import { Card } from '@dhis2-ui/card'
-import { InputField } from '@dhis2-ui/input'
-import PropTypes from 'prop-types'
-import React, { useState, useEffect, useCallback, useRef } from 'react'
-import { joinPath } from './join-path.js'
-import i18n from './locales/index.js'
-
-/**
- * Copied from here:
- * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
- */
-function escapeRegExpCharacters(text) {
- return text.replace(/[/.*+?^${}()|[\]\\]/g, '\\$&')
-}
-
-function Search({ value, onChange }) {
- const { baseUrl } = useConfig()
-
- return (
-
- )
-}
-
-Search.propTypes = {
- value: PropTypes.string.isRequired,
- onChange: PropTypes.func.isRequired,
-}
-
-function Item({ name, path, img }) {
- return (
-
-
-
- {name}
-
-
-
- )
-}
-
-Item.propTypes = {
- img: PropTypes.string,
- name: PropTypes.string,
- path: PropTypes.string,
-}
-
-function List({ apps, filter }) {
- return (
-
- {apps
- .filter(({ displayName, name }) => {
- const appName = displayName || name
- const formattedAppName = appName.toLowerCase()
- const formattedFilter =
- escapeRegExpCharacters(filter).toLowerCase()
-
- return filter.length > 0
- ? formattedAppName.match(formattedFilter)
- : true
- })
- .map(({ displayName, name, defaultAction, icon }, idx) => (
-
- ))}
-
-
-
- )
-}
-List.propTypes = {
- apps: PropTypes.array,
- filter: PropTypes.string,
-}
-
-const AppMenu = ({ apps, filter, onFilterChange }) => (
-
-
-
-
-
-
-
-
-)
-
-AppMenu.propTypes = {
- apps: PropTypes.array.isRequired,
- onFilterChange: PropTypes.func.isRequired,
- filter: PropTypes.string,
-}
-
-const Apps = ({ apps }) => {
- const [show, setShow] = useState(false)
- const [filter, setFilter] = useState('')
-
- const handleVisibilityToggle = useCallback(() => setShow(!show), [show])
- const handleFilterChange = useCallback(({ value }) => setFilter(value), [])
-
- const containerEl = useRef(null)
- const onDocClick = useCallback((evt) => {
- if (containerEl.current && !containerEl.current.contains(evt.target)) {
- setShow(false)
- }
- }, [])
- useEffect(() => {
- document.addEventListener('click', onDocClick)
- return () => document.removeEventListener('click', onDocClick)
- }, [onDocClick])
-
- return (
-
-
-
-
-
- {show ? (
-
- ) : null}
-
-
-
- )
-}
-
-Apps.propTypes = {
- apps: PropTypes.array.isRequired,
-}
-
-export default Apps
diff --git a/components/header-bar/src/command-palette/__tests__/browse-apps-view.test.js b/components/header-bar/src/command-palette/__tests__/browse-apps-view.test.js
new file mode 100644
index 0000000000..aae336bd61
--- /dev/null
+++ b/components/header-bar/src/command-palette/__tests__/browse-apps-view.test.js
@@ -0,0 +1,147 @@
+import { fireEvent } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import React from 'react'
+import CommandPalette from '../command-palette.js'
+import {
+ testApps,
+ testCommands,
+ testShortcuts,
+ render,
+ headerBarIconTest,
+} from './command-palette.test.js'
+
+describe('Command Palette - List View - Browse Apps View', () => {
+ it('renders Browse Apps View', async () => {
+ const user = userEvent.setup()
+ const {
+ getByTestId,
+ queryByTestId,
+ getByPlaceholderText,
+ queryByText,
+ getByLabelText,
+ queryAllByTestId,
+ } = render(
+
+ )
+ // open command palette
+ await user.click(getByTestId(headerBarIconTest))
+
+ expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument()
+ await user.click(getByTestId('headerbar-browse-apps'))
+
+ // Browse Apps View
+ const searchField = getByPlaceholderText('Search apps')
+ expect(searchField).toHaveValue('')
+
+ const backButton = getByLabelText('Back Button')
+ expect(backButton).toBeInTheDocument()
+
+ expect(queryByText(/All Apps/i)).toBeInTheDocument()
+
+ const listItems = queryAllByTestId('headerbar-list-item')
+ // first item highlighted
+ expect(listItems[0].querySelector('span')).toHaveTextContent(
+ 'Test App 1'
+ )
+ expect(listItems[0]).toHaveClass('highlighted')
+
+ // go back to default view
+ await user.click(getByLabelText('Back Button'))
+ expect(queryByText(/Top Apps/i)).toBeInTheDocument()
+ expect(queryByText(/Actions/i)).toBeInTheDocument()
+ })
+
+ it('handles navigation and hover state of list items', async () => {
+ const user = userEvent.setup()
+ const {
+ getAllByRole,
+ queryAllByTestId,
+ queryByText,
+ findByPlaceholderText,
+ container,
+ findByTestId,
+ } = render(
+
+ )
+ // open modal with (meta + /) keys
+ fireEvent.keyDown(container, { key: '/', metaKey: true })
+
+ // click browse-apps link
+ const browseAppsLink = await findByTestId('headerbar-browse-apps')
+ await user.click(browseAppsLink)
+
+ // no filter view
+ const searchField = await findByPlaceholderText('Search apps')
+ expect(queryByText(/All Apps/i)).toBeInTheDocument()
+
+ const listItems = queryAllByTestId('headerbar-list-item')
+ // 9 apps
+ expect(listItems.length).toBe(9)
+
+ // first item highlighted
+ expect(listItems[0]).toHaveClass('highlighted')
+ expect(listItems[0].querySelector('span')).toHaveTextContent(
+ 'Test App 1'
+ )
+
+ await user.keyboard('{ArrowDown}')
+ expect(listItems[0]).not.toHaveClass('highlighted')
+ expect(listItems[1]).toHaveClass('highlighted')
+ expect(listItems[1].querySelector('span')).toHaveTextContent(
+ 'Test App 2'
+ )
+
+ await user.keyboard('{ArrowDown}')
+ expect(listItems[1]).not.toHaveClass('highlighted')
+ expect(listItems[2]).toHaveClass('highlighted')
+ expect(listItems[2].querySelector('span')).toHaveTextContent(
+ 'Test App 3'
+ )
+
+ await user.keyboard('{ArrowUp}')
+ expect(listItems[2]).not.toHaveClass('highlighted')
+ expect(listItems[1]).toHaveClass('highlighted')
+ expect(listItems[1].querySelector('span')).toHaveTextContent(
+ 'Test App 2'
+ )
+
+ // filter items view
+ await user.type(searchField, 'Test App')
+ expect(searchField).toHaveValue('Test App')
+ expect(queryByText(/All Apps/i)).not.toBeInTheDocument()
+ expect(queryByText(/Results for "Test App"/i)).toBeInTheDocument()
+
+ // first item highlighted
+ expect(listItems[1]).not.toHaveClass('highlighted')
+ expect(listItems[0]).toHaveClass('highlighted')
+ expect(listItems[0].querySelector('span')).toHaveTextContent(
+ 'Test App 1'
+ )
+
+ // simulate hover
+ await user.hover(listItems[8])
+ expect(listItems[1]).not.toHaveClass('highlighted')
+ expect(listItems[8]).toHaveClass('highlighted')
+ expect(listItems[8].querySelector('span')).toHaveTextContent(
+ 'Test App 9'
+ )
+
+ const clearButton = getAllByRole('button')[1]
+ await user.click(clearButton)
+
+ // back to normal list view/no filter view
+ expect(queryByText(/All Apps/i)).toBeInTheDocument()
+ expect(queryByText(/Results for "Test App"/i)).not.toBeInTheDocument()
+
+ // first item highlighted
+ expect(listItems[8]).not.toHaveClass('highlighted')
+ expect(listItems[0]).toHaveClass('highlighted')
+ expect(listItems[0].querySelector('span')).toHaveTextContent(
+ 'Test App 1'
+ )
+ })
+})
diff --git a/components/header-bar/src/command-palette/__tests__/browse-commands-view.test.js b/components/header-bar/src/command-palette/__tests__/browse-commands-view.test.js
new file mode 100644
index 0000000000..918e0406b7
--- /dev/null
+++ b/components/header-bar/src/command-palette/__tests__/browse-commands-view.test.js
@@ -0,0 +1,51 @@
+import { userEvent } from '@testing-library/user-event'
+import React from 'react'
+import CommandPalette from '../command-palette.js'
+import {
+ headerBarIconTest,
+ render,
+ testCommands,
+} from './command-palette.test.js'
+
+describe('Command Palette - List View - Browse Commands', () => {
+ it('renders Browse Commands View', async () => {
+ const user = userEvent.setup()
+ const {
+ getByTestId,
+ queryByTestId,
+ getByPlaceholderText,
+ queryByText,
+ getByLabelText,
+ } = render(
+
+ )
+ // open command palette
+ await user.click(getByTestId(headerBarIconTest))
+
+ // click browse-commands link
+ expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument()
+ await user.click(getByTestId('headerbar-browse-commands'))
+
+ // Browse Commands View
+ // Search field
+ const searchField = getByPlaceholderText('Search commands')
+ expect(searchField).toHaveValue('')
+
+ const backButton = getByLabelText('Back Button')
+ expect(backButton).toBeInTheDocument()
+
+ expect(queryByText(/All Commands/i)).toBeInTheDocument()
+
+ const listItem = queryByTestId('headerbar-list-item')
+ // first item highlighted
+ expect(listItem.querySelector('span')).toHaveTextContent(
+ 'Test Command 1'
+ )
+ expect(listItem).toHaveClass('highlighted')
+
+ // Esc key goes back to default view
+ await user.keyboard('{Escape}')
+ // expect(queryByText(/All Commands/i)).not.toBeInTheDocument()
+ expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument()
+ })
+})
diff --git a/components/header-bar/src/command-palette/__tests__/browse-shortcuts-view.test.js b/components/header-bar/src/command-palette/__tests__/browse-shortcuts-view.test.js
new file mode 100644
index 0000000000..5b6c5c5430
--- /dev/null
+++ b/components/header-bar/src/command-palette/__tests__/browse-shortcuts-view.test.js
@@ -0,0 +1,51 @@
+import { userEvent } from '@testing-library/user-event'
+import React from 'react'
+import CommandPalette from '../command-palette.js'
+import {
+ headerBarIconTest,
+ render,
+ testShortcuts,
+} from './command-palette.test.js'
+
+describe('Command Palette - List View - Browse Shortcuts', () => {
+ it('renders Browse Shortcuts View', async () => {
+ const user = userEvent.setup()
+ const {
+ getByTestId,
+ queryByTestId,
+ getByPlaceholderText,
+ queryByText,
+ getByLabelText,
+ } = render(
+
+ )
+ // open command palette
+ await user.click(getByTestId(headerBarIconTest))
+
+ // click browse-shortcuts link
+ expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument()
+ await user.click(getByTestId('headerbar-browse-shortcuts'))
+
+ // Browse Shortcuts View
+ // Search field
+ const searchField = getByPlaceholderText('Search shortcuts')
+ expect(searchField).toHaveValue('')
+
+ const backButton = getByLabelText('Back Button')
+ expect(backButton).toBeInTheDocument()
+
+ expect(queryByText(/All Shortcuts/i)).toBeInTheDocument()
+
+ const listItem = queryByTestId('headerbar-list-item')
+ // first item highlighted
+ expect(listItem.querySelector('span')).toHaveTextContent(
+ 'Test Shortcut 1'
+ )
+ expect(listItem).toHaveClass('highlighted')
+
+ // go back to default view
+ await user.click(getByLabelText('Back Button'))
+ expect(queryByText(/All Shortcuts/i)).not.toBeInTheDocument()
+ expect(queryByText(/Actions/i)).toBeInTheDocument()
+ })
+})
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
new file mode 100644
index 0000000000..d6ac84e429
--- /dev/null
+++ b/components/header-bar/src/command-palette/__tests__/command-palette.test.js
@@ -0,0 +1,146 @@
+import { fireEvent, 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'
+import { MIN_APPS_NUM } from '../hooks/use-navigation.js'
+
+const CommandPaletteProviderWrapper = ({ children }) => {
+ return (
+
+ {children}
+
+ )
+}
+
+CommandPaletteProviderWrapper.propTypes = {
+ children: PropTypes.node,
+}
+
+export const render = (ui, options) =>
+ originalRender(ui, { wrapper: CommandPaletteProviderWrapper, ...options })
+
+export const headerBarIconTest = 'headerbar-apps-icon'
+export const modalTest = 'headerbar-menu'
+export const minAppsNum = MIN_APPS_NUM // 8
+
+export const testApps = new Array(minAppsNum + 1)
+ .fill(null)
+ .map((_, index) => ({
+ name: `Test App ${index + 1}`,
+ displayName: `Test App ${index + 1}`,
+ icon: '',
+ defaultAction: '',
+ }))
+
+export const testCommands = [
+ {
+ name: 'Test Command 1',
+ displayName: 'Test Command 1',
+ icon: '',
+ defaultAction: '',
+ },
+]
+
+export const testShortcuts = [
+ {
+ name: 'Test Shortcut 1',
+ displayName: 'Test Shortcut 1',
+ icon: '',
+ defaultAction: '',
+ },
+]
+
+describe('Command Palette Component', () => {
+ it('renders bare default view when Command Palette is opened', async () => {
+ const user = userEvent.setup()
+ const { getByTestId, queryByTestId, getByPlaceholderText } = render(
+
+ )
+
+ // modal not rendered yet
+ expect(queryByTestId(modalTest)).not.toBeInTheDocument()
+
+ const headerBarIcon = getByTestId(headerBarIconTest)
+ await user.click(headerBarIcon)
+ expect(queryByTestId(modalTest)).toBeInTheDocument()
+
+ // Search field
+ const searchField = getByPlaceholderText(
+ 'Search apps, shortcuts, commands'
+ )
+ expect(searchField).toHaveValue('')
+
+ // Top Apps
+ expect(queryByTestId('headerbar-top-apps-list')).not.toBeInTheDocument()
+
+ // Actions menu
+ expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument()
+ // since apps < MIN_APPS_NUM (8)
+ expect(queryByTestId('headerbar-browse-apps')).not.toBeInTheDocument()
+ // since commands < 1
+ expect(
+ queryByTestId('headerbar-browse-commands')
+ ).not.toBeInTheDocument()
+ // since shortcuts < 1
+ expect(
+ queryByTestId('headerbar-browse-shortcuts')
+ ).not.toBeInTheDocument()
+ // default action: logout
+ expect(queryByTestId('headerbar-logout')).toBeInTheDocument()
+
+ // click outside modal
+ await user.click(headerBarIcon)
+ expect(queryByTestId(modalTest)).not.toBeInTheDocument()
+ })
+
+ it('opens and closes Command Palette using ctrl + /', async () => {
+ const { container, queryByTestId } = render(
+
+ )
+ // modal not rendered yet
+ expect(queryByTestId(modalTest)).not.toBeInTheDocument()
+
+ // open modal with (Ctrl + /) keys
+ fireEvent.keyDown(container, { key: '/', ctrlKey: true })
+ expect(queryByTestId(modalTest)).toBeInTheDocument()
+
+ // close modal with (Ctrl + /) keys
+ fireEvent.keyDown(container, { key: '/', ctrlKey: true })
+ expect(queryByTestId(modalTest)).not.toBeInTheDocument()
+ })
+
+ it('opens and closes Command Palette using meta + /', async () => {
+ const { container, queryByTestId } = render(
+
+ )
+ // modal not rendered yet
+ expect(queryByTestId(modalTest)).not.toBeInTheDocument()
+
+ // open modal with (Meta + /) keys
+ fireEvent.keyDown(container, { key: '/', metaKey: true })
+ expect(queryByTestId(modalTest)).toBeInTheDocument()
+
+ // close modal with (Ctrl + /) keys
+ fireEvent.keyDown(container, { key: '/', metaKey: true })
+ expect(queryByTestId(modalTest)).not.toBeInTheDocument()
+ })
+
+ it('closes Command Palette using Esc key', async () => {
+ const user = userEvent.setup()
+ const { container, queryByTestId } = render(
+
+ )
+ // modal not rendered yet
+ expect(queryByTestId(modalTest)).not.toBeInTheDocument()
+
+ // open modal with (Ctrl + /) keys
+ fireEvent.keyDown(container, { key: '/', ctrlKey: true })
+ expect(queryByTestId(modalTest)).toBeInTheDocument()
+
+ // Esc key closes the modal
+ await user.keyboard('{Escape}')
+ expect(queryByTestId(modalTest)).not.toBeInTheDocument()
+ })
+})
diff --git a/components/header-bar/src/command-palette/__tests__/home-view.test.js b/components/header-bar/src/command-palette/__tests__/home-view.test.js
new file mode 100644
index 0000000000..a8854ffdd7
--- /dev/null
+++ b/components/header-bar/src/command-palette/__tests__/home-view.test.js
@@ -0,0 +1,295 @@
+import { fireEvent } from '@testing-library/dom'
+import { userEvent } from '@testing-library/user-event'
+import React from 'react'
+import CommandPalette from '../command-palette.js'
+import {
+ headerBarIconTest,
+ minAppsNum,
+ render,
+ testApps,
+ testCommands,
+ testShortcuts,
+} from './command-palette.test.js'
+
+describe('Command Palette - Home View', () => {
+ it('shows the full default view upon opening the Command Palette', async () => {
+ const user = userEvent.setup()
+ const {
+ getByTestId,
+ queryByTestId,
+ getAllByText,
+ getByPlaceholderText,
+ queryAllByText,
+ queryByText,
+ getAllByRole,
+ queryAllByTestId,
+ } = render(
+
+ )
+ // headerbar icon button
+ await await user.click(getByTestId(headerBarIconTest))
+
+ // Search field
+ const searchField = getByPlaceholderText(
+ 'Search apps, shortcuts, commands'
+ )
+ expect(searchField).toHaveValue('')
+
+ // Top Apps
+ expect(queryByTestId('headerbar-top-apps-list')).toBeInTheDocument()
+ expect(getAllByText(/Test App/)).toHaveLength(8)
+
+ // Actions menu
+ // since apps > MIN_APPS_NUM(8)
+ expect(queryByTestId('headerbar-browse-apps')).toBeInTheDocument()
+ // since commands > 1
+ expect(queryByTestId('headerbar-browse-commands')).toBeInTheDocument()
+ // since shortcuts > 1
+ expect(queryByTestId('headerbar-browse-shortcuts')).toBeInTheDocument()
+ // default action
+ expect(queryByTestId('headerbar-logout')).toBeInTheDocument()
+
+ // full search across apps, shortcuts, commands
+ await await user.type(searchField, 'Test')
+ expect(searchField).toHaveValue('Test')
+
+ expect(queryByTestId('headerbar-top-apps-list')).not.toBeInTheDocument()
+ expect(queryByText(/Results for "Test"/i)).toBeInTheDocument()
+
+ const listItems = queryAllByTestId('headerbar-list-item')
+ // 9 apps + 1 command + 1 shortcut
+ expect(listItems.length).toBe(11)
+ expect(queryAllByText(/Test App/).length).toBe(9)
+ expect(queryByText(/Test Command/)).toBeInTheDocument()
+ expect(queryByText(/Test Shortcut/)).toBeInTheDocument()
+
+ // clear field
+ const clearButton = getAllByRole('button')[1]
+ await user.click(clearButton)
+ expect(searchField).toHaveValue('')
+
+ // back to default view
+ expect(queryByTestId('headerbar-top-apps-list')).toBeInTheDocument()
+ expect(queryByText(/Results for "Test"/i)).not.toBeInTheDocument()
+ })
+
+ it('handles right arrow navigation in the grid on the home view', async () => {
+ const user = userEvent.setup()
+ const { container, queryByTestId } = render(
+
+ )
+
+ // open modal with (Ctrl + /) keys
+ fireEvent.keyDown(container, { key: '/', ctrlKey: true })
+
+ // topApps
+ const appsGrid = queryByTestId('headerbar-top-apps-list')
+ expect(appsGrid).toBeInTheDocument()
+
+ const topApps = appsGrid.querySelectorAll('a')
+ expect(topApps.length).toBe(minAppsNum)
+ const firstApp = topApps[0]
+
+ // first highlighted item
+ expect(firstApp).toHaveClass('highlighted')
+ expect(firstApp.querySelector('span')).toHaveTextContent('Test App 1')
+
+ // move right through the first row of items (0 - 3)
+ for (
+ let prevIndex = 0;
+ prevIndex < topApps.length / 2 - 1;
+ prevIndex++
+ ) {
+ const activeIndex = prevIndex + 1
+ expect(topApps[prevIndex]).toHaveClass('highlighted')
+
+ // move to next item
+ await user.keyboard('{ArrowRight}')
+ expect(topApps[prevIndex]).not.toHaveClass('highlighted')
+ expect(topApps[activeIndex]).toHaveClass('highlighted')
+ expect(
+ topApps[activeIndex].querySelector('span')
+ ).toHaveTextContent(`Test App ${activeIndex + 1}`)
+ }
+
+ // loops back to the first item
+ await user.keyboard('{ArrowRight}')
+ expect(firstApp).toHaveClass('highlighted')
+ })
+
+ it('handles left arrow navigation in the grid on the home view', async () => {
+ const user = userEvent.setup()
+ const { container, getByTestId } = render(
+
+ )
+
+ // open modal with (Ctrl + /) keys
+ fireEvent.keyDown(container, { key: '/', ctrlKey: true })
+
+ // topApps
+ const appsGrid = getByTestId('headerbar-top-apps-list')
+
+ const topApps = appsGrid.querySelectorAll('a')
+ expect(topApps.length).toBe(minAppsNum)
+ const firstApp = topApps[0]
+ const lastAppInFirstRow = topApps[3]
+
+ // first highlighted item
+ expect(firstApp).toHaveClass('highlighted')
+ expect(firstApp.querySelector('span')).toHaveTextContent('Test App 1')
+
+ // loops to last item in the row
+ await user.keyboard('{ArrowLeft}')
+ expect(firstApp).not.toHaveClass('highlighted')
+ expect(lastAppInFirstRow).toHaveClass('highlighted')
+ expect(lastAppInFirstRow.querySelector('span')).toHaveTextContent(
+ 'Test App 4'
+ )
+
+ // move left through the first row of items (3 - 0)
+ for (
+ let prevIndex = topApps.length / 2 - 1;
+ prevIndex > 0;
+ prevIndex--
+ ) {
+ const activeIndex = prevIndex - 1
+ expect(topApps[prevIndex]).toHaveClass('highlighted')
+
+ // move to next item
+ await user.keyboard('{ArrowLeft}')
+ expect(topApps[prevIndex]).not.toHaveClass('highlighted')
+ expect(topApps[activeIndex]).toHaveClass('highlighted')
+ expect(
+ topApps[activeIndex].querySelector('span')
+ ).toHaveTextContent(`Test App ${activeIndex + 1}`)
+ }
+ })
+
+ it('handles down arrow navigation on the home view', async () => {
+ const user = userEvent.setup()
+ const { queryByTestId, container } = render(
+
+ )
+
+ // open modal with (Ctrl + /) keys
+ fireEvent.keyDown(container, { key: '/', ctrlKey: true })
+
+ // topApps
+ const appsGrid = queryByTestId('headerbar-top-apps-list')
+ expect(appsGrid).toBeInTheDocument()
+
+ const topApps = appsGrid.querySelectorAll('a')
+ expect(topApps.length).toBe(minAppsNum)
+ const rowOneFirstApp = topApps[0]
+ const rowTwoFirstApp = topApps[4]
+
+ // first highlighted item
+ expect(rowOneFirstApp).toHaveClass('highlighted')
+ expect(rowOneFirstApp.querySelector('span')).toHaveTextContent(
+ 'Test App 1'
+ )
+
+ await user.keyboard('{ArrowDown}')
+ expect(rowOneFirstApp).not.toHaveClass('highlighted')
+ expect(rowTwoFirstApp).toHaveClass('highlighted')
+ expect(rowTwoFirstApp.querySelector('span')).toHaveTextContent(
+ 'Test App '
+ )
+
+ // actions menu
+ await user.keyboard('{ArrowDown}')
+ expect(queryByTestId('headerbar-browse-apps')).toHaveClass(
+ 'highlighted'
+ )
+
+ await user.keyboard('{ArrowDown}')
+ expect(queryByTestId('headerbar-browse-commands')).toHaveClass(
+ 'highlighted'
+ )
+
+ await user.keyboard('{ArrowDown}')
+ expect(queryByTestId('headerbar-browse-shortcuts')).toHaveClass(
+ 'highlighted'
+ )
+
+ await user.keyboard('{ArrowDown}')
+ expect(queryByTestId('headerbar-logout')).toHaveClass('highlighted')
+
+ // loop back to grid
+ await user.keyboard('{ArrowDown}')
+ expect(rowOneFirstApp).toHaveClass('highlighted')
+ })
+
+ it('handles up arrow navigation on the home view', async () => {
+ const user = userEvent.setup()
+ const { container, getByTestId, queryByTestId } = render(
+
+ )
+
+ // open modal with (Ctrl + /) keys
+ fireEvent.keyDown(container, { key: '/', ctrlKey: true })
+
+ // topApps
+ const appsGrid = getByTestId('headerbar-top-apps-list')
+ const topApps = appsGrid.querySelectorAll('a')
+
+ const rowOneFirstApp = topApps[0]
+ const rowTwoFirstApp = topApps[4]
+
+ // first highlighted item
+ expect(rowOneFirstApp).toHaveClass('highlighted')
+ expect(rowOneFirstApp.querySelector('span')).toHaveTextContent(
+ 'Test App 1'
+ )
+
+ // goes to bottom of actions menu
+ await user.keyboard('{ArrowUp}')
+ expect(rowOneFirstApp).not.toHaveClass('highlighted')
+ expect(queryByTestId('headerbar-logout')).toHaveClass('highlighted')
+
+ await user.keyboard('{ArrowUp}')
+ expect(queryByTestId('headerbar-browse-shortcuts')).toHaveClass(
+ 'highlighted'
+ )
+
+ await user.keyboard('{ArrowUp}')
+ expect(queryByTestId('headerbar-browse-commands')).toHaveClass(
+ 'highlighted'
+ )
+
+ await user.keyboard('{ArrowUp}')
+ expect(queryByTestId('headerbar-browse-apps')).toHaveClass(
+ 'highlighted'
+ )
+
+ // moves to grid
+ await user.keyboard('{ArrowUp}')
+ expect(rowTwoFirstApp).toHaveClass('highlighted')
+ expect(rowTwoFirstApp.querySelector('span')).toHaveTextContent(
+ 'Test App 5'
+ )
+
+ await user.keyboard('{ArrowUp}')
+ expect(rowOneFirstApp).toHaveClass('highlighted')
+ })
+})
diff --git a/components/header-bar/src/command-palette/__tests__/search-results.test.js b/components/header-bar/src/command-palette/__tests__/search-results.test.js
new file mode 100644
index 0000000000..164dea9189
--- /dev/null
+++ b/components/header-bar/src/command-palette/__tests__/search-results.test.js
@@ -0,0 +1,72 @@
+import { fireEvent } from '@testing-library/dom'
+import { userEvent } from '@testing-library/user-event'
+import React from 'react'
+import CommandPalette from '../command-palette.js'
+import {
+ headerBarIconTest,
+ render,
+ testApps,
+ testCommands,
+ testShortcuts,
+} from './command-palette.test.js'
+
+describe('Command Palette - List View - Search Results', () => {
+ it('filters for one item and handles navigation of singular item list', async () => {
+ const user = userEvent.setup()
+ const { getByPlaceholderText, queryAllByTestId, container } = render(
+
+ )
+ // open modal
+ fireEvent.keyDown(container, { key: '/', metaKey: true })
+
+ // Search field
+ const searchField = await getByPlaceholderText(
+ 'Search apps, shortcuts, commands'
+ )
+ expect(searchField).toHaveValue('')
+
+ // one item result
+ await user.type(searchField, 'Shortcut')
+ const listItems = queryAllByTestId('headerbar-list-item')
+ expect(listItems.length).toBe(1)
+
+ expect(listItems[0]).toHaveTextContent('Test Shortcut 1')
+ expect(listItems[0]).toHaveClass('highlighted')
+
+ await user.keyboard('{ArrowUp}')
+ expect(listItems[0]).toHaveClass('highlighted')
+
+ await user.keyboard('{ArrowDown}')
+ expect(listItems[0]).toHaveClass('highlighted')
+ })
+
+ it('shows empty search results if no match is made', async () => {
+ const user = userEvent.setup()
+ const {
+ getByTestId,
+ getByPlaceholderText,
+ queryByText,
+ queryByTestId,
+ } = render(
+
+ )
+ // open command palette
+ await user.click(getByTestId(headerBarIconTest))
+
+ // Search field
+ const searchField = getByPlaceholderText(
+ 'Search apps, shortcuts, commands'
+ )
+ expect(searchField).toHaveValue('')
+
+ await user.type(searchField, 'abc')
+ expect(searchField).toHaveValue('abc')
+
+ expect(queryByTestId('headerbar-empty-search')).toBeInTheDocument()
+ expect(queryByText(/Nothing found for "abc"/i)).toBeInTheDocument()
+ })
+})
diff --git a/components/header-bar/src/command-palette/command-palette.js b/components/header-bar/src/command-palette/command-palette.js
new file mode 100755
index 0000000000..d1da6c2795
--- /dev/null
+++ b/components/header-bar/src/command-palette/command-palette.js
@@ -0,0 +1,175 @@
+import { colors, spacers } from '@dhis2/ui-constants'
+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 { useCommandPaletteContext } from './context/command-palette-context.js'
+import { useAvailableActions } from './hooks/use-actions.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 HomeView from './views/home-view.js'
+import {
+ BrowseApps,
+ BrowseCommands,
+ BrowseShortcuts,
+} from './views/list-view.js'
+
+const CommandPalette = ({ apps, commands, shortcuts }) => {
+ const containerEl = useRef(null)
+ const [show, setShow] = useState(false)
+ const { currentView, filter, setFilter } = useCommandPaletteContext()
+
+ const handleVisibilityToggle = useCallback(() => setShow(!show), [show])
+ const handleFilterChange = useCallback(
+ ({ value }) => setFilter(value),
+ [setFilter]
+ )
+
+ const actionsArray = useAvailableActions({ apps, shortcuts, commands })
+
+ const {
+ filteredApps,
+ filteredCommands,
+ filteredShortcuts,
+ currentViewItemsArray,
+ } = useFilter({ apps, commands, shortcuts })
+
+ const { handleKeyDown, goToDefaultView, modalRef } = useNavigation({
+ setShow,
+ itemsArray: currentViewItemsArray,
+ show,
+ showGrid: apps?.length > 0,
+ actionsLength: actionsArray?.length,
+ })
+
+ useEffect(() => {
+ const activeItem = document.querySelector('.highlighted')
+ if (activeItem && typeof activeItem.scrollIntoView === 'function') {
+ activeItem?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
+ }
+ })
+
+ useEffect(() => {
+ if (modalRef.current) {
+ modalRef.current?.focus()
+ }
+ })
+
+ const handleFocus = (event) => {
+ if (event.target === modalRef?.current) {
+ modalRef.current?.querySelector('input').focus()
+ }
+ }
+
+ useEffect(() => {
+ document.addEventListener('keydown', handleKeyDown)
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown)
+ }
+ }, [handleKeyDown])
+
+ return (
+
+
+
+
+ {show ? (
+
+
+
+
+ {currentView !== 'home' && !filter ? (
+
+ ) : null}
+ {/* switch views */}
+ {currentView === 'home' && (
+
+ )}
+ {currentView === 'apps' && (
+
+ )}
+ {currentView === 'commands' && (
+
+ )}
+ {currentView === 'shortcuts' && (
+
+ )}
+
+
+
+ ) : null}
+
+
+ )
+}
+
+CommandPalette.propTypes = {
+ apps: PropTypes.array,
+ commands: PropTypes.array,
+ shortcuts: PropTypes.array,
+}
+
+export default CommandPalette
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 0000000000..b465ae4f04
--- /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(null)
+
+ return (
+
+ {children}
+
+ )
+}
+CommandPaletteContextProvider.propTypes = {
+ children: PropTypes.node,
+}
+
+export const useCommandPaletteContext = () => useContext(commandPaletteContext)
diff --git a/components/header-bar/src/command-palette/hooks/use-actions.js b/components/header-bar/src/command-palette/hooks/use-actions.js
new file mode 100644
index 0000000000..127a66f1b5
--- /dev/null
+++ b/components/header-bar/src/command-palette/hooks/use-actions.js
@@ -0,0 +1,49 @@
+import { colors } from '@dhis2/ui-constants'
+import {
+ IconApps16,
+ IconLogOut16,
+ IconRedo16,
+ IconTerminalWindow16,
+} from '@dhis2/ui-icons'
+import React, { useMemo } from 'react'
+import i18n from '../../locales/index.js'
+import { MIN_APPS_NUM } from './use-navigation.js'
+
+export const useAvailableActions = ({ apps, shortcuts, commands }) => {
+ const actions = useMemo(() => {
+ const actionsArray = []
+ if (apps?.length > MIN_APPS_NUM) {
+ actionsArray.push({
+ type: 'apps',
+ title: i18n.t('Browse apps'),
+ icon: ,
+ dataTest: 'headerbar-browse-apps',
+ })
+ }
+ if (commands?.length > 0) {
+ actionsArray.push({
+ type: 'commands',
+ title: i18n.t('Browse commands'),
+ icon: ,
+ dataTest: 'headerbar-browse-commands',
+ })
+ }
+ if (shortcuts?.length > 0) {
+ actionsArray.push({
+ type: 'shortcuts',
+ title: i18n.t('Browse shortcuts'),
+ icon: ,
+ dataTest: 'headerbar-browse-shortcuts',
+ })
+ }
+ // default logout action
+ actionsArray.push({
+ type: 'logout',
+ title: i18n.t('Logout'),
+ icon: ,
+ dataTest: 'headerbar-logout',
+ })
+ return actionsArray
+ }, [apps, shortcuts, commands])
+ return actions
+}
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 0000000000..7f65be78e3
--- /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 0000000000..8cd1a63d8e
--- /dev/null
+++ b/components/header-bar/src/command-palette/hooks/use-navigation.js
@@ -0,0 +1,262 @@
+import { useCallback, useEffect, useRef } from 'react'
+import { useCommandPaletteContext } from '../context/command-palette-context.js'
+
+export const GRID_ITEMS_LENGTH = 8
+export const MIN_APPS_NUM = GRID_ITEMS_LENGTH
+
+export const useNavigation = ({
+ setShow,
+ itemsArray,
+ show,
+ showGrid,
+ actionsLength,
+}) => {
+ const modalRef = useRef(null)
+
+ const {
+ activeSection,
+ currentView,
+ filter,
+ highlightedIndex,
+ setHighlightedIndex,
+ setFilter,
+ setCurrentView,
+ setActiveSection,
+ } = useCommandPaletteContext()
+
+ // highlight first item in filtered results
+ useEffect(() => {
+ setHighlightedIndex(0)
+ }, [filter, setHighlightedIndex])
+
+ const defaultSection = showGrid ? 'grid' : 'actions'
+
+ const goToDefaultView = useCallback(() => {
+ setFilter('')
+ setCurrentView('home')
+ setActiveSection(defaultSection)
+ setHighlightedIndex(0)
+ }, [
+ setActiveSection,
+ setCurrentView,
+ setFilter,
+ setHighlightedIndex,
+ defaultSection,
+ ])
+
+ const handleListViewNavigation = useCallback(
+ ({ event, listLength }) => {
+ const lastIndex = listLength - 1
+ switch (event.key) {
+ case 'ArrowDown':
+ event.preventDefault()
+ setHighlightedIndex(
+ highlightedIndex >= lastIndex ? 0 : highlightedIndex + 1
+ )
+ break
+ case 'ArrowUp':
+ event.preventDefault()
+ setHighlightedIndex(
+ highlightedIndex > 0 ? highlightedIndex - 1 : lastIndex
+ )
+ break
+ case 'Escape':
+ event.preventDefault()
+ goToDefaultView()
+ break
+ default:
+ break
+ }
+ },
+ [goToDefaultView, highlightedIndex, setHighlightedIndex]
+ )
+
+ const handleHomeViewNavigation = useCallback(
+ (event) => {
+ // grid
+ const gridRowLength = GRID_ITEMS_LENGTH / 2 // 4
+ const topRowLastIndex = gridRowLength - 1 // 3
+ const lastRowFirstIndex = gridRowLength // 4
+ const lastRowLastIndex = GRID_ITEMS_LENGTH - 1 // 7
+
+ // actions
+ const lastActionIndex = actionsLength - 1
+
+ if (showGrid) {
+ switch (event.key) {
+ case 'ArrowLeft':
+ event.preventDefault()
+ 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':
+ event.preventDefault()
+ 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':
+ event.preventDefault()
+ if (activeSection === 'grid') {
+ if (highlightedIndex >= lastRowFirstIndex) {
+ setActiveSection('actions')
+ setHighlightedIndex(0)
+ } else {
+ setHighlightedIndex(
+ highlightedIndex + gridRowLength
+ )
+ }
+ } else if (activeSection === 'actions') {
+ if (highlightedIndex >= actionsLength - 1) {
+ setActiveSection('grid')
+ setHighlightedIndex(0)
+ } else {
+ setHighlightedIndex(highlightedIndex + 1)
+ }
+ }
+ break
+ case 'ArrowUp':
+ event.preventDefault()
+ if (activeSection === 'grid') {
+ if (highlightedIndex < lastRowFirstIndex) {
+ setActiveSection('actions')
+ setHighlightedIndex(lastActionIndex)
+ } else {
+ setHighlightedIndex(
+ highlightedIndex - gridRowLength
+ )
+ }
+ } else if (activeSection === 'actions') {
+ if (highlightedIndex <= 0) {
+ setActiveSection('grid')
+ setHighlightedIndex(lastRowFirstIndex)
+ } else {
+ setHighlightedIndex(highlightedIndex - 1)
+ }
+ }
+ break
+ default:
+ break
+ }
+ } else {
+ if (activeSection === 'actions') {
+ handleListViewNavigation({
+ event,
+ listLength: actionsLength,
+ })
+ }
+ }
+
+ if (event.key === 'Escape') {
+ event.preventDefault()
+ setShow(false)
+ setActiveSection(defaultSection)
+ setHighlightedIndex(0)
+ }
+ },
+ [
+ activeSection,
+ actionsLength,
+ defaultSection,
+ handleListViewNavigation,
+ highlightedIndex,
+ setActiveSection,
+ setHighlightedIndex,
+ setShow,
+ ]
+ )
+
+ const handleKeyDown = useCallback(
+ (event) => {
+ const modal = modalRef.current
+
+ if (currentView === 'home') {
+ if (filter.length > 0) {
+ // search mode
+ handleListViewNavigation({
+ event,
+ listLength: itemsArray.length,
+ })
+ } else {
+ handleHomeViewNavigation(event)
+ }
+ } else {
+ setActiveSection(null)
+ handleListViewNavigation({
+ event,
+ listLength: itemsArray.length,
+ })
+ }
+
+ if ((event.metaKey || event.ctrlKey) && event.key === '/') {
+ setShow((show) => !show)
+ goToDefaultView()
+ }
+
+ if (event.key === 'Enter') {
+ 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,
+ showGrid,
+ ]
+ )
+
+ return {
+ handleKeyDown,
+ goToDefaultView,
+ modalRef,
+ activeSection,
+ setActiveSection,
+ }
+}
diff --git a/components/header-bar/src/command-palette/sections/app-item.js b/components/header-bar/src/command-palette/sections/app-item.js
new file mode 100644
index 0000000000..a258d346ab
--- /dev/null
+++ b/components/header-bar/src/command-palette/sections/app-item.js
@@ -0,0 +1,61 @@
+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, highlighted, handleMouseEnter }) {
+ return (
+
+
+ {name}
+
+
+ )
+}
+
+AppItem.propTypes = {
+ handleMouseEnter: PropTypes.func,
+ highlighted: PropTypes.bool,
+ img: PropTypes.string,
+ name: PropTypes.string,
+ path: PropTypes.string,
+}
+
+export default AppItem
diff --git a/components/header-bar/src/command-palette/sections/back-button.js b/components/header-bar/src/command-palette/sections/back-button.js
new file mode 100644
index 0000000000..6e0a2dc52c
--- /dev/null
+++ b/components/header-bar/src/command-palette/sections/back-button.js
@@ -0,0 +1,53 @@
+import { colors, spacers, theme } from '@dhis2/ui-constants'
+import { IconArrowLeft16 } from '@dhis2/ui-icons'
+import PropTypes from 'prop-types'
+import React from 'react'
+
+function BackButton({ onClickHandler }) {
+ return (
+ <>
+
+
+
+
+ >
+ )
+}
+
+BackButton.propTypes = {
+ onClickHandler: PropTypes.func,
+}
+
+export default BackButton
diff --git a/components/header-bar/src/command-palette/sections/container.js b/components/header-bar/src/command-palette/sections/container.js
new file mode 100644
index 0000000000..107bb5fb85
--- /dev/null
+++ b/components/header-bar/src/command-palette/sections/container.js
@@ -0,0 +1,37 @@
+import { colors, elevations } from '@dhis2/ui-constants'
+import { Layer } from '@dhis2-ui/layer'
+import PropTypes from 'prop-types'
+import React from 'react'
+
+const ModalContainer = ({ children, setShow, show }) => {
+ return (
+ setShow(false)} translucent={show}>
+
+ {children}
+
+
+
+ )
+}
+
+ModalContainer.propTypes = {
+ children: PropTypes.node,
+ setShow: PropTypes.func,
+ show: PropTypes.bool,
+}
+
+export default ModalContainer
diff --git a/components/header-bar/src/command-palette/sections/heading.js b/components/header-bar/src/command-palette/sections/heading.js
new file mode 100644
index 0000000000..86011cbef5
--- /dev/null
+++ b/components/header-bar/src/command-palette/sections/heading.js
@@ -0,0 +1,30 @@
+import { colors, spacers } from '@dhis2/ui-constants'
+import PropTypes from 'prop-types'
+import React from 'react'
+
+function Heading({ heading }) {
+ return (
+
+ {/* role='header' ?*/}
+ {heading}
+
+
+ )
+}
+
+Heading.propTypes = {
+ heading: PropTypes.string,
+}
+
+export default Heading
diff --git a/components/header-bar/src/command-palette/sections/list-item.js b/components/header-bar/src/command-palette/sections/list-item.js
new file mode 100644
index 0000000000..c3e1c4dad7
--- /dev/null
+++ b/components/header-bar/src/command-palette/sections/list-item.js
@@ -0,0 +1,113 @@
+import { colors, spacers } from '@dhis2/ui-constants'
+import cx from 'classnames'
+import PropTypes from 'prop-types'
+import React from 'react'
+
+function ListItem({
+ title,
+ path,
+ icon,
+ image,
+ description,
+ type,
+ onClickHandler,
+ highlighted,
+ dataTest = 'headerbar-list-item',
+ handleMouseEnter,
+}) {
+ const showDescription = type === 'commands'
+ return (
+
+
+ {icon &&
{icon} }
+ {image && (
+
+ )}
+
+
+ {title}
+ {showDescription && (
+ {description}
+ )}
+
+
+
+ )
+}
+
+ListItem.propTypes = {
+ dataTest: PropTypes.string,
+ description: PropTypes.string,
+ handleMouseEnter: PropTypes.func,
+ highlighted: PropTypes.bool,
+ icon: PropTypes.node,
+ image: PropTypes.string,
+ path: PropTypes.string,
+ title: PropTypes.string,
+ type: PropTypes.string,
+ onClickHandler: PropTypes.func,
+}
+
+export default ListItem
diff --git a/components/header-bar/src/command-palette/sections/list.js b/components/header-bar/src/command-palette/sections/list.js
new file mode 100644
index 0000000000..8a5761180a
--- /dev/null
+++ b/components/header-bar/src/command-palette/sections/list.js
@@ -0,0 +1,35 @@
+import PropTypes from 'prop-types'
+import React from 'react'
+import { useCommandPaletteContext } from '../context/command-palette-context.js'
+import ListItem from './list-item.js'
+
+function List({ filteredItems, type }) {
+ const { highlightedIndex, setHighlightedIndex } = useCommandPaletteContext()
+ return (
+
+ {filteredItems.map(
+ (
+ { displayName, name, defaultAction, icon, description },
+ idx
+ ) => (
+ setHighlightedIndex(idx)}
+ />
+ )
+ )}
+
+ )
+}
+List.propTypes = {
+ filteredItems: PropTypes.array,
+ type: PropTypes.string,
+}
+
+export default List
diff --git a/components/header-bar/src/command-palette/sections/search-field.js b/components/header-bar/src/command-palette/sections/search-field.js
new file mode 100644
index 0000000000..6ea20c4cc9
--- /dev/null
+++ b/components/header-bar/src/command-palette/sections/search-field.js
@@ -0,0 +1,54 @@
+import { colors, spacers, theme } from '@dhis2/ui-constants'
+import { IconSearch16 } from '@dhis2/ui-icons'
+import PropTypes from 'prop-types'
+import React from 'react'
+import { InputField } from '../../../../input/src/input-field/input-field.js'
+
+function Search({ value, onChange, placeholder }) {
+ return (
+ <>
+ }
+ clearable
+ dataTest="headerbar-search"
+ />
+
+ >
+ )
+}
+
+Search.propTypes = {
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired,
+ placeholder: PropTypes.string,
+}
+
+export default Search
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 0000000000..574f51e764
--- /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 (
+ <>
+
+
+
+
+ >
+ )
+}
+
+export default EmptySearchResults
diff --git a/components/header-bar/src/command-palette/utils/escapeCharacters.js b/components/header-bar/src/command-palette/utils/escapeCharacters.js
new file mode 100644
index 0000000000..26ed7e12f0
--- /dev/null
+++ b/components/header-bar/src/command-palette/utils/escapeCharacters.js
@@ -0,0 +1,7 @@
+/**
+ * Copied from here:
+ * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping
+ */
+export function escapeRegExpCharacters(text) {
+ return text.replace(/[/.*+?^${}()|[\]\\]/g, '\\$&')
+}
diff --git a/components/header-bar/src/command-palette/utils/filterItemsArray.js b/components/header-bar/src/command-palette/utils/filterItemsArray.js
new file mode 100644
index 0000000000..75a792da80
--- /dev/null
+++ b/components/header-bar/src/command-palette/utils/filterItemsArray.js
@@ -0,0 +1,13 @@
+import { escapeRegExpCharacters } from './escapeCharacters.js'
+
+export const filterItemsArray = (items, filter) => {
+ return items.filter(({ displayName, name }) => {
+ const itemName = displayName || name
+ const formattedItemName = itemName.toLowerCase()
+ const formattedFilter = escapeRegExpCharacters(filter).toLowerCase()
+
+ return filter.length > 0
+ ? formattedItemName.match(formattedFilter)
+ : true
+ })
+}
diff --git a/components/header-bar/src/command-palette/views/home-view.js b/components/header-bar/src/command-palette/views/home-view.js
new file mode 100644
index 0000000000..89ef449d6b
--- /dev/null
+++ b/components/header-bar/src/command-palette/views/home-view.js
@@ -0,0 +1,143 @@
+import { clearSensitiveCaches, useConfig } from '@dhis2/app-runtime'
+import { spacers } from '@dhis2/ui-constants'
+import PropTypes from 'prop-types'
+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 AppItem from '../sections/app-item.js'
+import Heading from '../sections/heading.js'
+import ListItem from '../sections/list-item.js'
+import ListView from './list-view.js'
+
+function HomeView({ apps, commands, shortcuts, actions }) {
+ const { baseUrl } = useConfig()
+ const {
+ filter,
+ setCurrentView,
+ highlightedIndex,
+ setHighlightedIndex,
+ activeSection,
+ setActiveSection,
+ } = useCommandPaletteContext()
+ const filteredItems = apps.concat(commands, shortcuts)
+ const topApps = apps?.slice(0, 8)
+ return (
+ <>
+ {filter.length > 0 ? (
+
+ ) : (
+ <>
+ {apps.length > 0 && (
+ <>
+
+
+ {topApps.map(
+ (
+ {
+ displayName,
+ name,
+ defaultAction,
+ icon,
+ },
+ idx
+ ) => (
+
{
+ setActiveSection('grid')
+ setHighlightedIndex(idx)
+ }}
+ />
+ )
+ )}
+
+
+ >
+ )}
+ {/* actions menu */}
+
+
+ {actions.map(
+ ({ dataTest, icon, title, type }, index) => {
+ const logoutActionHandler = async () => {
+ await clearSensitiveCaches()
+ window.location.assign(
+ joinPath(
+ baseUrl,
+ 'dhis-web-commons-security/logout.action'
+ )
+ )
+ }
+
+ const viewActionHandler = () => {
+ setCurrentView(type)
+ setHighlightedIndex(0)
+ }
+
+ return (
+ {
+ setActiveSection('actions')
+ setHighlightedIndex(index)
+ }}
+ />
+ )
+ }
+ )}
+
+ >
+ )}
+ >
+ )
+}
+
+HomeView.propTypes = {
+ actions: PropTypes.array,
+ apps: PropTypes.array,
+ commands: PropTypes.array,
+ shortcuts: PropTypes.array,
+}
+
+export default HomeView
diff --git a/components/header-bar/src/command-palette/views/list-view.js b/components/header-bar/src/command-palette/views/list-view.js
new file mode 100644
index 0000000000..d62882ebbd
--- /dev/null
+++ b/components/header-bar/src/command-palette/views/list-view.js
@@ -0,0 +1,65 @@
+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 { EmptySearchResults } from '../sections/search-results.js'
+
+export function BrowseApps({ apps }) {
+ return
+}
+
+BrowseApps.propTypes = {
+ apps: PropTypes.array,
+}
+export function BrowseCommands({ commands }) {
+ return (
+
+ )
+}
+
+BrowseCommands.propTypes = {
+ commands: PropTypes.array,
+}
+
+export function BrowseShortcuts({ shortcuts }) {
+ return (
+
+ )
+}
+
+BrowseShortcuts.propTypes = {
+ shortcuts: PropTypes.array,
+}
+
+function ListView({ heading, filteredItems, type }) {
+ const { filter } = useCommandPaletteContext()
+
+ return filteredItems.length > 0 ? (
+ <>
+ 0
+ ? i18n.t(`Results for "${filter}"`)
+ : heading
+ }
+ />
+
+ >
+ ) : filter ? (
+
+ ) : null
+}
+
+ListView.propTypes = {
+ filteredItems: PropTypes.array,
+ heading: PropTypes.string,
+ type: PropTypes.string,
+}
+
+export default ListView
diff --git a/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps/common.js b/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps/common.js
deleted file mode 100644
index 0691260013..0000000000
--- a/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps/common.js
+++ /dev/null
@@ -1,5 +0,0 @@
-import { Then } from '@badeball/cypress-cucumber-preprocessor'
-
-Then('the HeaderBar dos not display the app menu', () => {
- cy.get('[data-test="headerbar-apps-menu"]').should('not.exist')
-})
diff --git a/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps.feature b/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands.feature
similarity index 68%
rename from components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps.feature
rename to components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands.feature
index cee1b27b67..ddda647d67 100644
--- a/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps.feature
+++ b/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands.feature
@@ -1,4 +1,4 @@
-Feature: The HeaderBar contains a menu to all apps
+Feature: The HeaderBar contains a menu to all apps, shortcuts, and commands
Scenario: The HeaderBar contains a menu icon
Given the HeaderBar loads without an error
@@ -6,9 +6,9 @@ Feature: The HeaderBar contains a menu to all apps
Scenario: The menu is closed by default
Given the HeaderBar loads without an error
- Then the HeaderBar dos not display the app menu
+ Then the HeaderBar does not display the command palette
- Scenario: The user will be offered a menu with apps
+ Scenario: The user will be offered a menu with apps, shortcuts and commands
Given the HeaderBar loads without an error
When the user clicks on the menu icons
Then the menu opens
@@ -18,4 +18,4 @@ Feature: The HeaderBar contains a menu to all apps
Given the HeaderBar loads without an error
When the user opens the menu
And the user clicks outside of the menu
- Then the HeaderBar dos not display the app menu
+ Then the HeaderBar does not display the command palette
diff --git a/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands/common.js b/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands/common.js
new file mode 100644
index 0000000000..bc22cc22e9
--- /dev/null
+++ b/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands/common.js
@@ -0,0 +1,5 @@
+import { Then } from '@badeball/cypress-cucumber-preprocessor'
+
+Then('the HeaderBar does not display the command palette', () => {
+ cy.get('[data-test="headerbar-menu"]').should('not.exist')
+})
diff --git a/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps/the_app_menu_closes_when_the_user_clicks_outside.js b/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands/the_app_menu_closes_when_the_user_clicks_outside.js
similarity index 80%
rename from components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps/the_app_menu_closes_when_the_user_clicks_outside.js
rename to components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands/the_app_menu_closes_when_the_user_clicks_outside.js
index 73e9ce20a5..ed0803bcc8 100644
--- a/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps/the_app_menu_closes_when_the_user_clicks_outside.js
+++ b/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands/the_app_menu_closes_when_the_user_clicks_outside.js
@@ -5,5 +5,5 @@ When('the user opens the menu', () => {
})
When('the user clicks outside of the menu', () => {
- cy.get('[data-test="headerbar-title"]').click()
+ cy.get('.backdrop').click({ force: true })
})
diff --git a/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps/the_headerbar_contains_a_menu_icon.js b/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands/the_headerbar_contains_a_menu_icon.js
similarity index 100%
rename from components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps/the_headerbar_contains_a_menu_icon.js
rename to components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands/the_headerbar_contains_a_menu_icon.js
diff --git a/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps/the_user_will_be_offered_a_menu_with_5_apps.js b/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands/the_user_will_be_offered_a_menu_with_8_apps_and_actions_menu.js
similarity index 64%
rename from components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps/the_user_will_be_offered_a_menu_with_5_apps.js
rename to components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands/the_user_will_be_offered_a_menu_with_8_apps_and_actions_menu.js
index 31db255a59..ec1c4b9c65 100644
--- a/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps/the_user_will_be_offered_a_menu_with_5_apps.js
+++ b/components/header-bar/src/features/the_headerbar_contains_a_menu_to_all_apps_shortcuts_and_commands/the_user_will_be_offered_a_menu_with_8_apps_and_actions_menu.js
@@ -5,12 +5,14 @@ When('the user clicks on the menu icons', () => {
})
Then('the menu opens', () => {
- cy.get('[data-test="headerbar-apps-menu"]').should('be.visible')
+ cy.get('[data-test="headerbar-menu"]').should('be.visible')
})
Then('contains items with links', () => {
- cy.get('[data-test="headerbar-apps-menu-list"]')
+ cy.get('[data-test="headerbar-top-apps-list"]')
.find('a')
.its('length')
.should('be.greaterThan', 0)
+
+ cy.get('[data-test="headerbar-actions-menu"]').should('exist')
})
diff --git a/components/header-bar/src/features/the_search_should_escape_regexp_character/the_modules_do_not_contain_items_with_special_chars.js b/components/header-bar/src/features/the_search_should_escape_regexp_character/the_modules_do_not_contain_items_with_special_chars.js
index f725a07c4c..ec96542752 100644
--- a/components/header-bar/src/features/the_search_should_escape_regexp_character/the_modules_do_not_contain_items_with_special_chars.js
+++ b/components/header-bar/src/features/the_search_should_escape_regexp_character/the_modules_do_not_contain_items_with_special_chars.js
@@ -17,7 +17,8 @@ Given(/no app name contains a (.*)/, (character) => {
})
Then('no results should be shown', () => {
- cy.get('[data-test="headerbar-apps-menu-list"] > a > div').should(
+ cy.get('[data-test="headerbar-list"] > a > .text-content .title').should(
'not.exist'
)
+ cy.get('[data-test="headerbar-empty-search"]').should('exist')
})
diff --git a/components/header-bar/src/features/the_search_should_escape_regexp_character/the_user_searches_for_an_app_with_a_regex_character.js b/components/header-bar/src/features/the_search_should_escape_regexp_character/the_user_searches_for_an_app_with_a_regex_character.js
index 8ed1c1e596..5d92f767fc 100644
--- a/components/header-bar/src/features/the_search_should_escape_regexp_character/the_user_searches_for_an_app_with_a_regex_character.js
+++ b/components/header-bar/src/features/the_search_should_escape_regexp_character/the_user_searches_for_an_app_with_a_regex_character.js
@@ -18,7 +18,7 @@ Given(/some app names contain a (.*)/, (character) => {
})
Then(/only apps with (.*) in their name should be shown/, (character) => {
- cy.get('[data-test="headerbar-apps-menu-list"] > a > div').should(
+ cy.get('[data-test="headerbar-list"] > a .text-content .title').should(
($modules) => {
$modules.each((index, module) => {
const displayName = Cypress.$(module).text()
diff --git a/components/header-bar/src/header-bar.js b/components/header-bar/src/header-bar.js
index 673c6b4ab9..94fe5b5384 100755
--- a/components/header-bar/src/header-bar.js
+++ b/components/header-bar/src/header-bar.js
@@ -2,7 +2,8 @@ import { useDataQuery, useConfig } from '@dhis2/app-runtime'
import { colors } from '@dhis2/ui-constants'
import PropTypes from 'prop-types'
import React, { useMemo } from 'react'
-import Apps from './apps.js'
+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'
@@ -55,6 +56,12 @@ export const HeaderBar = ({
}))
}, [data, baseUrl])
+ // fetch commands
+ const commands = []
+
+ // fetch shortcuts
+ const shortcuts = []
+
// See https://jira.dhis2.org/browse/LIBS-180
if (!loading && !error) {
// TODO: This will run every render which is probably wrong!
@@ -94,8 +101,13 @@ export const HeaderBar = ({
}
userAuthorities={data.user.authorities}
/>
-
-
+
+
+