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 ( - - app logo - -
{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 ( + + app + {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 && ( + img + )} +
+
+ {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} /> - - + + +