diff --git a/collections/forms/i18n/en.pot b/collections/forms/i18n/en.pot index 33fbbb3db..0baae7e99 100644 --- a/collections/forms/i18n/en.pot +++ b/collections/forms/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-11-11T07:36:10.587Z\n" -"PO-Revision-Date: 2024-11-11T07:36:10.588Z\n" +"POT-Creation-Date: 2025-02-13T16:05:53.251Z\n" +"PO-Revision-Date: 2025-02-13T16:05:53.254Z\n" msgid "Upload file" msgstr "Upload file" diff --git a/components/header-bar/i18n/en.pot b/components/header-bar/i18n/en.pot index b24ee470f..3224e20ef 100644 --- a/components/header-bar/i18n/en.pot +++ b/components/header-bar/i18n/en.pot @@ -5,12 +5,48 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-06-12T03:40:49.012Z\n" -"PO-Revision-Date: 2024-06-12T03:40:49.013Z\n" +"POT-Creation-Date: 2025-02-13T15:42:36.303Z\n" +"PO-Revision-Date: 2025-02-13T15:42:36.304Z\n" + +msgid "Browse apps" +msgstr "Browse apps" + +msgid "Browse commands" +msgstr "Browse commands" + +msgid "Browse shortcuts" +msgstr "Browse shortcuts" + +msgid "Logout" +msgstr "Logout" msgid "Search apps" msgstr "Search apps" +msgid "Search commands" +msgstr "Search commands" + +msgid "Search shortcuts" +msgstr "Search shortcuts" + +msgid "Search apps, shortcuts, commands" +msgstr "Search apps, shortcuts, commands" + +msgid "Top apps" +msgstr "Top apps" + +msgid "Actions" +msgstr "Actions" + +msgid "All Apps" +msgstr "All Apps" + +msgid "All Commands" +msgstr "All Commands" + +msgid "All Shortcuts" +msgstr "All Shortcuts" + msgid "DHIS2 {{dhis2Version}}" msgstr "DHIS2 {{dhis2Version}}" @@ -62,9 +98,6 @@ msgstr "Help" msgid "About DHIS2" msgstr "About DHIS2" -msgid "Logout" -msgstr "Logout" - msgid "New {{appName}} version available" msgstr "New {{appName}} version available" 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 index aae336bd6..4fba6366a 100644 --- 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 @@ -1,5 +1,5 @@ import { fireEvent } from '@testing-library/react' -import userEvent from '@testing-library/user-event' +import { userEvent } from '@testing-library/user-event' import React from 'react' import CommandPalette from '../command-palette.js' import { @@ -11,6 +11,13 @@ import { } from './command-palette.test.js' describe('Command Palette - List View - Browse Apps View', () => { + beforeAll(() => { + // Testing environment does not support the component yet so it has to mocked + // linked issue: https://github.com/jsdom/jsdom/issues/3294 + HTMLDialogElement.prototype.showModal = jest.fn() + HTMLDialogElement.prototype.close = jest.fn() + }) + it('renders Browse Apps View', async () => { const user = userEvent.setup() const { @@ -67,8 +74,8 @@ describe('Command Palette - List View - Browse Apps View', () => { commands={testCommands} /> ) - // open modal with (meta + /) keys - fireEvent.keyDown(container, { key: '/', metaKey: true }) + // open modal with (meta + k) keys + fireEvent.keyDown(container, { key: 'k', metaKey: true }) // click browse-apps link const browseAppsLink = await findByTestId('headerbar-browse-apps') @@ -87,6 +94,7 @@ describe('Command Palette - List View - Browse Apps View', () => { expect(listItems[0].querySelector('span')).toHaveTextContent( 'Test App 1' ) + listItems[0].focus() await user.keyboard('{ArrowDown}') expect(listItems[0]).not.toHaveClass('highlighted') @@ -94,6 +102,7 @@ describe('Command Palette - List View - Browse Apps View', () => { expect(listItems[1].querySelector('span')).toHaveTextContent( 'Test App 2' ) + listItems[1].focus() await user.keyboard('{ArrowDown}') expect(listItems[1]).not.toHaveClass('highlighted') @@ -101,6 +110,7 @@ describe('Command Palette - List View - Browse Apps View', () => { expect(listItems[2].querySelector('span')).toHaveTextContent( 'Test App 3' ) + listItems[2].focus() await user.keyboard('{ArrowUp}') expect(listItems[2]).not.toHaveClass('highlighted') @@ -108,6 +118,7 @@ describe('Command Palette - List View - Browse Apps View', () => { expect(listItems[1].querySelector('span')).toHaveTextContent( 'Test App 2' ) + listItems[1].focus() // filter items view await user.type(searchField, 'Test App') @@ -122,10 +133,10 @@ describe('Command Palette - List View - Browse Apps View', () => { 'Test App 1' ) - // simulate hover + // simulate hover - no highlight await user.hover(listItems[8]) - expect(listItems[1]).not.toHaveClass('highlighted') - expect(listItems[8]).toHaveClass('highlighted') + expect(listItems[0]).toHaveClass('highlighted') + expect(listItems[8]).not.toHaveClass('highlighted') expect(listItems[8].querySelector('span')).toHaveTextContent( 'Test App 9' ) 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 index 918e0406b..25cf89d67 100644 --- 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 @@ -8,6 +8,12 @@ import { } from './command-palette.test.js' describe('Command Palette - List View - Browse Commands', () => { + beforeAll(() => { + // Testing environment does not support the component yet so it has to mocked + // linked issue: https://github.com/jsdom/jsdom/issues/3294 + HTMLDialogElement.prototype.showModal = jest.fn() + HTMLDialogElement.prototype.close = jest.fn() + }) it('renders Browse Commands View', async () => { const user = userEvent.setup() const { @@ -42,10 +48,11 @@ describe('Command Palette - List View - Browse Commands', () => { 'Test Command 1' ) expect(listItem).toHaveClass('highlighted') + listItem.focus() - // Esc key goes back to default view - await user.keyboard('{Escape}') - // expect(queryByText(/All Commands/i)).not.toBeInTheDocument() + // Backspace key goes back to default view + await user.keyboard('{Backspace}') + 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 index 5b6c5c543..94b1e0f73 100644 --- 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 @@ -8,6 +8,12 @@ import { } from './command-palette.test.js' describe('Command Palette - List View - Browse Shortcuts', () => { + beforeAll(() => { + // Testing environment does not support the component yet so it has to mocked + // linked issue: https://github.com/jsdom/jsdom/issues/3294 + HTMLDialogElement.prototype.showModal = jest.fn() + HTMLDialogElement.prototype.close = jest.fn() + }) it('renders Browse Shortcuts View', async () => { const user = userEvent.setup() const { diff --git a/components/header-bar/src/command-palette/__tests__/command-palette.test.js b/components/header-bar/src/command-palette/__tests__/command-palette.test.js index d6ac84e42..0c56f85ff 100644 --- a/components/header-bar/src/command-palette/__tests__/command-palette.test.js +++ b/components/header-bar/src/command-palette/__tests__/command-palette.test.js @@ -4,7 +4,7 @@ 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' +import { MIN_APPS_NUM } from '../utils/constants.js' const CommandPaletteProviderWrapper = ({ children }) => { return ( @@ -53,6 +53,12 @@ export const testShortcuts = [ ] describe('Command Palette Component', () => { + beforeAll(() => { + // Testing environment does not support the component yet so it has to mocked + // linked issue: https://github.com/jsdom/jsdom/issues/3294 + HTMLDialogElement.prototype.showModal = jest.fn() + HTMLDialogElement.prototype.close = jest.fn() + }) it('renders bare default view when Command Palette is opened', async () => { const user = userEvent.setup() const { getByTestId, queryByTestId, getByPlaceholderText } = render( @@ -95,39 +101,39 @@ describe('Command Palette Component', () => { expect(queryByTestId(modalTest)).not.toBeInTheDocument() }) - it('opens and closes Command Palette using ctrl + /', async () => { + it('opens and closes Command Palette using ctrl + k', 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 }) + // open modal with (Ctrl + k) keys + fireEvent.keyDown(container, { key: 'k', ctrlKey: true }) expect(queryByTestId(modalTest)).toBeInTheDocument() - // close modal with (Ctrl + /) keys - fireEvent.keyDown(container, { key: '/', ctrlKey: true }) + // close modal with (Ctrl + k) keys + fireEvent.keyDown(container, { key: 'k', ctrlKey: true }) expect(queryByTestId(modalTest)).not.toBeInTheDocument() }) - it('opens and closes Command Palette using meta + /', async () => { + it('opens and closes Command Palette using meta + k', 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 }) + // open modal with (Meta + k) keys + fireEvent.keyDown(container, { key: 'k', metaKey: true }) expect(queryByTestId(modalTest)).toBeInTheDocument() - // close modal with (Ctrl + /) keys - fireEvent.keyDown(container, { key: '/', metaKey: true }) + // close modal with (Ctrl + k) keys + fireEvent.keyDown(container, { key: 'k', metaKey: true }) expect(queryByTestId(modalTest)).not.toBeInTheDocument() }) - it('closes Command Palette using Esc key', async () => { + it('closes Command Palette using Esc key in default view', async () => { const user = userEvent.setup() const { container, queryByTestId } = render( @@ -135,12 +141,97 @@ describe('Command Palette Component', () => { // modal not rendered yet expect(queryByTestId(modalTest)).not.toBeInTheDocument() - // open modal with (Ctrl + /) keys - fireEvent.keyDown(container, { key: '/', ctrlKey: true }) + // open modal with (Ctrl + k) keys + fireEvent.keyDown(container, { key: 'k', ctrlKey: true }) + expect(queryByTestId(modalTest)).toBeInTheDocument() + + // Esc key closes the modal + await user.keyboard('{Escape}') + expect(queryByTestId(modalTest)).not.toBeInTheDocument() + }) + + it('closes Command Palette using Esc key in any list view', async () => { + const user = userEvent.setup() + const { container, getByTestId, queryByTestId, queryByText } = render( + + ) + // modal not rendered yet + expect(queryByTestId(modalTest)).not.toBeInTheDocument() + + // open modal with (Ctrl + k) keys + fireEvent.keyDown(container, { key: 'k', ctrlKey: true }) expect(queryByTestId(modalTest)).toBeInTheDocument() + // go to All Shortcuts (list) view + await user.click(getByTestId('headerbar-browse-shortcuts')) + expect(queryByText(/Top Apps/i)).not.toBeInTheDocument() + expect(queryByText(/All Shortcuts/i)).toBeInTheDocument() + // Esc key closes the modal await user.keyboard('{Escape}') expect(queryByTestId(modalTest)).not.toBeInTheDocument() }) + + it('deletes the search filter with the backspace key', async () => { + const user = userEvent.setup() + const { container, getByPlaceholderText } = render( + + ) + // open modal + fireEvent.keyDown(container, { key: 'k', ctrlKey: true }) + + // Search field + const searchField = await getByPlaceholderText( + 'Search apps, shortcuts, commands' + ) + expect(searchField).toHaveValue('') + searchField.focus() + + // backspace + await user.type(searchField, '123') + expect(searchField).toHaveValue('123') + + await user.keyboard('{Backspace}') + expect(searchField).toHaveValue('12') + + await user.keyboard('{Backspace}') + expect(searchField).toHaveValue('1') + + await user.keyboard('{Backspace}') + expect(searchField).toHaveValue('') + }) + + it('moves to the default view with backspace key if there is no search filter', async () => { + const user = userEvent.setup() + const { + container, + getByPlaceholderText, + getByTestId, + queryByTestId, + queryByText, + } = render( + + ) + // open modal + fireEvent.keyDown(container, { key: 'k', ctrlKey: true }) + + // go to All Shortcuts (list) view + await user.click(getByTestId('headerbar-browse-shortcuts')) + + expect(queryByTestId('headerbar-actions-menu')).not.toBeInTheDocument() + expect(queryByText(/All Shortcuts/i)).toBeInTheDocument() + + // Search field + const searchField = await getByPlaceholderText('Search shortcuts') + expect(searchField).toHaveValue('') + + // Backspace key goes back to default view + await user.keyboard('{Backspace}') + expect(queryByText(/All Shortcuts/i)).not.toBeInTheDocument() + expect(queryByTestId('headerbar-actions-menu')).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 index a8854ffdd..5d6fbb248 100644 --- a/components/header-bar/src/command-palette/__tests__/home-view.test.js +++ b/components/header-bar/src/command-palette/__tests__/home-view.test.js @@ -12,6 +12,12 @@ import { } from './command-palette.test.js' describe('Command Palette - Home View', () => { + beforeAll(() => { + // Testing environment does not support the component yet so it has to mocked + // linked issue: https://github.com/jsdom/jsdom/issues/3294 + HTMLDialogElement.prototype.showModal = jest.fn() + HTMLDialogElement.prototype.close = jest.fn() + }) it('shows the full default view upon opening the Command Palette', async () => { const user = userEvent.setup() const { @@ -87,8 +93,8 @@ describe('Command Palette - Home View', () => { /> ) - // open modal with (Ctrl + /) keys - fireEvent.keyDown(container, { key: '/', ctrlKey: true }) + // open modal with (Ctrl + k) keys + fireEvent.keyDown(container, { key: 'k', ctrlKey: true }) // topApps const appsGrid = queryByTestId('headerbar-top-apps-list') @@ -135,8 +141,8 @@ describe('Command Palette - Home View', () => { /> ) - // open modal with (Ctrl + /) keys - fireEvent.keyDown(container, { key: '/', ctrlKey: true }) + // open modal with (Ctrl + k) keys + fireEvent.keyDown(container, { key: 'k', ctrlKey: true }) // topApps const appsGrid = getByTestId('headerbar-top-apps-list') @@ -187,8 +193,8 @@ describe('Command Palette - Home View', () => { /> ) - // open modal with (Ctrl + /) keys - fireEvent.keyDown(container, { key: '/', ctrlKey: true }) + // open modal with (Ctrl + k) keys + fireEvent.keyDown(container, { key: 'k', ctrlKey: true }) // topApps const appsGrid = queryByTestId('headerbar-top-apps-list') @@ -246,8 +252,8 @@ describe('Command Palette - Home View', () => { /> ) - // open modal with (Ctrl + /) keys - fireEvent.keyDown(container, { key: '/', ctrlKey: true }) + // open modal with (Ctrl + k) keys + fireEvent.keyDown(container, { key: 'k', ctrlKey: true }) // topApps const appsGrid = getByTestId('headerbar-top-apps-list') 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 index 164dea918..13900397d 100644 --- a/components/header-bar/src/command-palette/__tests__/search-results.test.js +++ b/components/header-bar/src/command-palette/__tests__/search-results.test.js @@ -11,6 +11,12 @@ import { } from './command-palette.test.js' describe('Command Palette - List View - Search Results', () => { + beforeAll(() => { + // Testing environment does not support the component yet so it has to mocked + // linked issue: https://github.com/jsdom/jsdom/issues/3294 + HTMLDialogElement.prototype.showModal = jest.fn() + HTMLDialogElement.prototype.close = jest.fn() + }) it('filters for one item and handles navigation of singular item list', async () => { const user = userEvent.setup() const { getByPlaceholderText, queryAllByTestId, container } = render( @@ -21,7 +27,7 @@ describe('Command Palette - List View - Search Results', () => { /> ) // open modal - fireEvent.keyDown(container, { key: '/', metaKey: true }) + fireEvent.keyDown(container, { key: 'k', metaKey: true }) // Search field const searchField = await getByPlaceholderText( @@ -69,4 +75,31 @@ describe('Command Palette - List View - Search Results', () => { expect(queryByTestId('headerbar-empty-search')).toBeInTheDocument() expect(queryByText(/Nothing found for "abc"/i)).toBeInTheDocument() }) + + it('handles search for logout action in the command palette', async () => { + const user = userEvent.setup() + const { getByPlaceholderText, queryAllByTestId, container } = render( + + ) + // open modal + fireEvent.keyDown(container, { key: 'k', metaKey: true }) + + // Search field + const searchField = await getByPlaceholderText( + 'Search apps, shortcuts, commands' + ) + expect(searchField).toHaveValue('') + + // result + await user.type(searchField, 'Logout') + const listItems = queryAllByTestId('headerbar-list-item') + expect(listItems.length).toBe(1) + + expect(listItems[0]).toHaveTextContent('Logout') + expect(listItems[0]).toHaveClass('highlighted') + }) }) diff --git a/components/header-bar/src/command-palette/command-palette.js b/components/header-bar/src/command-palette/command-palette.js index d1da6c279..ec348f350 100755 --- a/components/header-bar/src/command-palette/command-palette.js +++ b/components/header-bar/src/command-palette/command-palette.js @@ -1,15 +1,21 @@ 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 React, { useCallback, useRef, useEffect } from 'react' 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 ModalContainer from './sections/modal-container.js' +import SearchFilter from './sections/search-field.js' +import { + ALL_APPS_VIEW, + ALL_COMMANDS_VIEW, + ALL_SHORTCUTS_VIEW, + FILTERABLE_ACTION, + HOME_VIEW, +} from './utils/constants.js' import HomeView from './views/home-view.js' import { BrowseApps, @@ -19,57 +25,69 @@ import { 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 { currentView, filter, setShowGrid } = useCommandPaletteContext() const actionsArray = useAvailableActions({ apps, shortcuts, commands }) + const searchableActions = actionsArray.filter( + (action) => action.type === FILTERABLE_ACTION + ) 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, + } = useFilter({ + apps, + commands, + shortcuts, + actions: searchableActions, }) useEffect(() => { - const activeItem = document.querySelector('.highlighted') - if (activeItem && typeof activeItem.scrollIntoView === 'function') { - activeItem?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) - } - }) + setShowGrid(apps?.length > 0) + }, [apps, setShowGrid]) - useEffect(() => { - if (modalRef.current) { - modalRef.current?.focus() - } + const { + handleKeyDown, + modalRef, + setModalOpen, + showModal, + goToDefaultView, + } = useNavigation({ + itemsArray: currentViewItemsArray, + actionsArray, }) - const handleFocus = (event) => { - if (event.target === modalRef?.current) { - modalRef.current?.querySelector('input').focus() - } - } + const handleVisibilityToggle = useCallback(() => { + setModalOpen((open) => !open) + goToDefaultView() + }, [setModalOpen, goToDefaultView]) + + const handleModalClick = useCallback( + (event) => { + if (event.target === modalRef?.current) { + setModalOpen(false) + } else { + modalRef?.current?.querySelector('input').focus() + } + }, + [modalRef, setModalOpen] + ) useEffect(() => { + const handleKeyDown = (event) => { + if ((event.metaKey || event.ctrlKey) && event.key === 'k') { + event.preventDefault() + handleVisibilityToggle() + } + } + document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('keydown', handleKeyDown) } - }, [handleKeyDown]) + }, [handleVisibilityToggle]) return (
@@ -79,48 +97,33 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { > - {show ? ( - -
- + {showModal ? ( + +
+
- {currentView !== 'home' && !filter ? ( + {currentView !== HOME_VIEW && !filter ? ( ) : null} {/* switch views */} - {currentView === 'home' && ( + {currentView === HOME_VIEW && ( )} - {currentView === 'apps' && ( + {currentView === ALL_APPS_VIEW && ( )} - {currentView === 'commands' && ( + {currentView === ALL_COMMANDS_VIEW && ( )} - {currentView === 'shortcuts' && ( + {currentView === ALL_SHORTCUTS_VIEW && ( @@ -157,6 +160,9 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { position: relative; height: 100%; } + .headerbar-menu { + width: 100%; + } .headerbar-menu-content { overflow-y: auto; max-height: calc(544px - 50px); 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 index b465ae4f0..858e78ad9 100644 --- a/components/header-bar/src/command-palette/context/command-palette-context.js +++ b/components/header-bar/src/command-palette/context/command-palette-context.js @@ -1,28 +1,35 @@ import PropTypes from 'prop-types' -import React, { createContext, useContext, useState } from 'react' +import React, { createContext, useContext, useMemo, useState } from 'react' +import { HOME_VIEW } from '../utils/constants.js' const commandPaletteContext = createContext() export const CommandPaletteContextProvider = ({ children }) => { const [filter, setFilter] = useState('') const [highlightedIndex, setHighlightedIndex] = useState(0) - const [currentView, setCurrentView] = useState('home') + const [currentView, setCurrentView] = useState(HOME_VIEW) // home view sections const [activeSection, setActiveSection] = useState(null) + const [showGrid, setShowGrid] = useState(null) + + const contextValue = useMemo( + () => ({ + filter, + setFilter, + highlightedIndex, + setHighlightedIndex, + currentView, + setCurrentView, + activeSection, + setActiveSection, + showGrid, + setShowGrid, + }), + [filter, highlightedIndex, currentView, activeSection, showGrid] + ) return ( - + {children} ) diff --git a/components/header-bar/src/command-palette/hooks/use-actions.js b/components/header-bar/src/command-palette/hooks/use-actions.js index 127a66f1b..27dd8a8d4 100644 --- a/components/header-bar/src/command-palette/hooks/use-actions.js +++ b/components/header-bar/src/command-palette/hooks/use-actions.js @@ -1,3 +1,4 @@ +import { clearSensitiveCaches, useConfig } from '@dhis2/app-runtime' import { colors } from '@dhis2/ui-constants' import { IconApps16, @@ -5,45 +6,81 @@ import { IconRedo16, IconTerminalWindow16, } from '@dhis2/ui-icons' -import React, { useMemo } from 'react' +import React, { useCallback, useMemo } from 'react' +import { joinPath } from '../../join-path.js' import i18n from '../../locales/index.js' -import { MIN_APPS_NUM } from './use-navigation.js' +import { useCommandPaletteContext } from '../context/command-palette-context.js' +import { + ACTION, + ALL_APPS_VIEW, + ALL_COMMANDS_VIEW, + ALL_SHORTCUTS_VIEW, + FILTERABLE_ACTION, + MIN_APPS_NUM, + MIN_COMMANDS_NUM, + MIN_SHORTCUTS_NUM, +} from '../utils/constants.js' export const useAvailableActions = ({ apps, shortcuts, commands }) => { + const { baseUrl } = useConfig() + const { setCurrentView, setHighlightedIndex } = useCommandPaletteContext() + + const logoutURL = joinPath( + baseUrl, + 'dhis-web-commons-security/logout.action' + ) + + const logoutAction = async (href) => { + await clearSensitiveCaches() + window.location.assign(href) + } + + const switchViewAction = useCallback( + (type) => { + setCurrentView(type) + setHighlightedIndex(0) + }, + [setCurrentView, setHighlightedIndex] + ) + const actions = useMemo(() => { const actionsArray = [] if (apps?.length > MIN_APPS_NUM) { actionsArray.push({ - type: 'apps', - title: i18n.t('Browse apps'), + type: ACTION, + name: i18n.t('Browse apps'), icon: , dataTest: 'headerbar-browse-apps', + action: () => switchViewAction(ALL_APPS_VIEW), }) } - if (commands?.length > 0) { + if (commands?.length > MIN_COMMANDS_NUM) { actionsArray.push({ - type: 'commands', - title: i18n.t('Browse commands'), + type: ACTION, + name: i18n.t('Browse commands'), icon: , dataTest: 'headerbar-browse-commands', + action: () => switchViewAction(ALL_COMMANDS_VIEW), }) } - if (shortcuts?.length > 0) { + if (shortcuts?.length > MIN_SHORTCUTS_NUM) { actionsArray.push({ - type: 'shortcuts', - title: i18n.t('Browse shortcuts'), + type: ACTION, + name: i18n.t('Browse shortcuts'), icon: , dataTest: 'headerbar-browse-shortcuts', + action: () => switchViewAction(ALL_SHORTCUTS_VIEW), }) } // default logout action actionsArray.push({ - type: 'logout', - title: i18n.t('Logout'), + type: FILTERABLE_ACTION, + name: i18n.t('Logout'), icon: , dataTest: 'headerbar-logout', + action: () => logoutAction(logoutURL), }) return actionsArray - }, [apps, shortcuts, commands]) + }, [apps, shortcuts, commands, logoutURL, switchViewAction]) 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 index 7f65be78e..dbd6077f0 100644 --- a/components/header-bar/src/command-palette/hooks/use-filter.js +++ b/components/header-bar/src/command-palette/hooks/use-filter.js @@ -1,25 +1,41 @@ import { useMemo } from 'react' import { useCommandPaletteContext } from '../context/command-palette-context.js' +import { + ALL_APPS_VIEW, + ALL_COMMANDS_VIEW, + ALL_SHORTCUTS_VIEW, +} from '../utils/constants.js' import { filterItemsArray } from '../utils/filterItemsArray.js' -export const useFilter = ({ apps, commands, shortcuts }) => { +export const useFilter = ({ apps, commands, shortcuts, actions }) => { const { filter, currentView } = useCommandPaletteContext() const filteredApps = filterItemsArray(apps, filter) const filteredCommands = filterItemsArray(commands, filter) const filteredShortcuts = filterItemsArray(shortcuts, filter) + const filteredActions = filterItemsArray(actions, filter) const currentViewItemsArray = useMemo(() => { - if (currentView === 'apps') { + if (currentView === ALL_APPS_VIEW) { return filteredApps - } else if (currentView === 'commands') { + } else if (currentView === ALL_COMMANDS_VIEW) { return filteredCommands - } else if (currentView === 'shortcuts') { + } else if (currentView === ALL_SHORTCUTS_VIEW) { return filteredShortcuts } else { - return filteredApps.concat(filteredCommands, filteredShortcuts) + return filteredApps.concat( + filteredCommands, + filteredShortcuts, + filteredActions + ) } - }, [currentView, filteredApps, filteredCommands, filteredShortcuts]) + }, [ + currentView, + filteredApps, + filteredCommands, + filteredShortcuts, + filteredActions, + ]) return { filteredApps, diff --git a/components/header-bar/src/command-palette/hooks/use-modal.js b/components/header-bar/src/command-palette/hooks/use-modal.js new file mode 100644 index 000000000..cc80c718a --- /dev/null +++ b/components/header-bar/src/command-palette/hooks/use-modal.js @@ -0,0 +1,32 @@ +import { useCallback, useState, useEffect } from 'react' + +const useModal = (modalRef) => { + const [modalOpen, setModalOpen] = useState(false) + + const handleOpenModal = useCallback(() => { + if (modalRef.current) { + modalRef.current.showModal() + } + }, [modalRef]) + + const handleCloseModal = useCallback(() => { + if (modalRef.current) { + modalRef.current.close() + } + }, [modalRef]) + + useEffect(() => { + if (modalOpen) { + handleOpenModal() + } else { + handleCloseModal() + } + }, [modalOpen, handleCloseModal, handleOpenModal]) + + return { + modalOpen, + setModalOpen, + } +} + +export default useModal diff --git a/components/header-bar/src/command-palette/hooks/use-navigation.js b/components/header-bar/src/command-palette/hooks/use-navigation.js index 8cd1a63d8..730323f23 100644 --- a/components/header-bar/src/command-palette/hooks/use-navigation.js +++ b/components/header-bar/src/command-palette/hooks/use-navigation.js @@ -1,16 +1,17 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useCallback, useEffect, useMemo, 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, -}) => { +import { + ACTIONS_SECTION, + GRID_COLUMNS, + GRID_ROWS, + GRID_SECTION, + HOME_VIEW, +} from '../utils/constants.js' +import { handleHomeNavigation } from '../utils/home-navigation.js' +import { handleListNavigation } from '../utils/list-navigation.js' +import useModal from './use-modal.js' + +export const useNavigation = ({ itemsArray, actionsArray }) => { const modalRef = useRef(null) const { @@ -19,244 +20,135 @@ export const useNavigation = ({ filter, highlightedIndex, setHighlightedIndex, + showGrid, + setActiveSection, setFilter, setCurrentView, - setActiveSection, } = useCommandPaletteContext() - // highlight first item in filtered results - useEffect(() => { - setHighlightedIndex(0) - }, [filter, setHighlightedIndex]) + const activeItems = useMemo(() => { + if (currentView === HOME_VIEW && activeSection === ACTIONS_SECTION) { + return actionsArray + } + return itemsArray + }, [itemsArray, actionsArray, currentView, activeSection]) - const defaultSection = showGrid ? 'grid' : 'actions' + const { modalOpen, setModalOpen } = useModal(modalRef) const goToDefaultView = useCallback(() => { + const defaultSection = showGrid ? GRID_SECTION : ACTIONS_SECTION + setFilter('') - setCurrentView('home') + setCurrentView(HOME_VIEW) setActiveSection(defaultSection) setHighlightedIndex(0) }, [ - setActiveSection, + showGrid, setCurrentView, setFilter, + setActiveSection, setHighlightedIndex, - defaultSection, ]) + // highlight first item in filtered results + useEffect(() => { + setHighlightedIndex(0) + }, [filter, setHighlightedIndex]) + 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 + const index = handleListNavigation({ + event, + listLength, + highlightedIndex, + }) + setHighlightedIndex(index) + + if (!filter.length && event.key === 'Backspace') { + event.preventDefault() + goToDefaultView() } }, - [goToDefaultView, highlightedIndex, setHighlightedIndex] + [filter, 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 + const actionsListLength = actionsArray.length 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 - } + const { section, index } = handleHomeNavigation({ + event, + activeSection, + rows: GRID_ROWS, + columns: GRID_COLUMNS, + highlightedIndex, + actionsListLength, + }) + setActiveSection(section) + setHighlightedIndex(index) } else { - if (activeSection === 'actions') { - handleListViewNavigation({ - event, - listLength: actionsLength, - }) - } - } - - if (event.key === 'Escape') { - event.preventDefault() - setShow(false) - setActiveSection(defaultSection) - setHighlightedIndex(0) + const index = handleListNavigation({ + event, + listLength: actionsListLength, + highlightedIndex, + }) + setHighlightedIndex(index) } }, [ + actionsArray, activeSection, - actionsLength, - defaultSection, - handleListViewNavigation, highlightedIndex, + showGrid, 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) - } + if (currentView === HOME_VIEW && filter?.length === 0) { + handleHomeViewNavigation(event) } else { - setActiveSection(null) handleListViewNavigation({ event, - listLength: itemsArray.length, + listLength: activeItems.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 - } + switch (event.key) { + case 'Escape': + event.preventDefault() + setModalOpen(false) + break + case 'Enter': + event.preventDefault() + activeItems[highlightedIndex]?.['action']?.() + break + case 'Tab': + event.preventDefault() + break + default: + break } }, [ - activeSection, + activeItems, currentView, filter.length, - goToDefaultView, handleHomeViewNavigation, handleListViewNavigation, highlightedIndex, - itemsArray, - setActiveSection, - setShow, - show, - showGrid, + setModalOpen, ] ) return { handleKeyDown, - goToDefaultView, modalRef, - activeSection, - setActiveSection, + setModalOpen, + showModal: modalOpen, + goToDefaultView, } } diff --git a/components/header-bar/src/command-palette/sections/app-item.js b/components/header-bar/src/command-palette/sections/app-item.js index a258d346a..356ec36dc 100644 --- a/components/header-bar/src/command-palette/sections/app-item.js +++ b/components/header-bar/src/command-palette/sections/app-item.js @@ -3,13 +3,13 @@ import cx from 'classnames' import PropTypes from 'prop-types' import React from 'react' -function AppItem({ name, path, img, highlighted, handleMouseEnter }) { +function AppItem({ name, path, img, highlighted }) { return ( app {name} @@ -20,6 +20,7 @@ function AppItem({ name, path, img, highlighted, handleMouseEnter }) { gap: ${spacers.dp12}; align-items: center; padding: ${spacers.dp16} ${spacers.dp4}; + margin: 0; background: ${colors.white}; border-radius: 1px; text-decoration: none; @@ -37,6 +38,9 @@ function AppItem({ name, path, img, highlighted, handleMouseEnter }) { a:focus { outline: none; } + a:last-of-type { + margin-bottom: 0; + } .app-icon { width: 48px; height: 48px; @@ -51,7 +55,6 @@ function AppItem({ name, path, img, highlighted, handleMouseEnter }) { } AppItem.propTypes = { - handleMouseEnter: PropTypes.func, highlighted: PropTypes.bool, img: PropTypes.string, name: PropTypes.string, diff --git a/components/header-bar/src/command-palette/sections/container.js b/components/header-bar/src/command-palette/sections/container.js deleted file mode 100644 index 107bb5fb8..000000000 --- a/components/header-bar/src/command-palette/sections/container.js +++ /dev/null @@ -1,37 +0,0 @@ -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/list-item.js b/components/header-bar/src/command-palette/sections/list-item.js index c3e1c4dad..014cc5a52 100644 --- a/components/header-bar/src/command-palette/sections/list-item.js +++ b/components/header-bar/src/command-palette/sections/list-item.js @@ -2,6 +2,7 @@ import { colors, spacers } from '@dhis2/ui-constants' import cx from 'classnames' import PropTypes from 'prop-types' import React from 'react' +import { COMMAND } from '../utils/constants.js' function ListItem({ title, @@ -13,16 +14,15 @@ function ListItem({ onClickHandler, highlighted, dataTest = 'headerbar-list-item', - handleMouseEnter, }) { - const showDescription = type === 'commands' + const showDescription = type === COMMAND return (
@@ -100,7 +100,6 @@ function ListItem({ ListItem.propTypes = { dataTest: PropTypes.string, description: PropTypes.string, - handleMouseEnter: PropTypes.func, highlighted: PropTypes.bool, icon: PropTypes.node, image: PropTypes.string, diff --git a/components/header-bar/src/command-palette/sections/list.js b/components/header-bar/src/command-palette/sections/list.js index 8a5761180..734d396ce 100644 --- a/components/header-bar/src/command-palette/sections/list.js +++ b/components/header-bar/src/command-palette/sections/list.js @@ -4,25 +4,36 @@ import { useCommandPaletteContext } from '../context/command-palette-context.js' import ListItem from './list-item.js' function List({ filteredItems, type }) { - const { highlightedIndex, setHighlightedIndex } = useCommandPaletteContext() + const { highlightedIndex } = useCommandPaletteContext() return (
{filteredItems.map( ( - { displayName, name, defaultAction, icon, description }, + { + displayName, + name, + defaultAction, + icon, + description, + url, + }, idx - ) => ( - setHighlightedIndex(idx)} - /> - ) + ) => { + const isImage = typeof icon === 'string' + const isIcon = React.isValidElement(icon) + return ( + + ) + } )}
) diff --git a/components/header-bar/src/command-palette/sections/modal-container.js b/components/header-bar/src/command-palette/sections/modal-container.js new file mode 100644 index 000000000..bb9ce17b9 --- /dev/null +++ b/components/header-bar/src/command-palette/sections/modal-container.js @@ -0,0 +1,68 @@ +import { colors, elevations } from '@dhis2/ui-constants' +import PropTypes from 'prop-types' +import React, { forwardRef, useEffect } from 'react' + +const ModalContainer = forwardRef(function ModalContainer( + { children, onKeyDown, onClick }, + ref +) { + useEffect(() => { + const activeItem = ref?.current?.querySelector('.highlighted') + if (activeItem && typeof activeItem.scrollIntoView === 'function') { + activeItem?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + }, [ref]) + + useEffect(() => { + if (!ref.current) { + return + } + const modal = ref.current + + const handleFocus = (event) => { + if (event.target === modal) { + modal?.querySelector('input').focus() + } + } + + modal.addEventListener('click', onClick) + modal.addEventListener('focus', handleFocus) + modal.addEventListener('keydown', onKeyDown) + + return () => { + modal.removeEventListener('click', onClick) + modal.removeEventListener('focus', handleFocus) + modal.removeEventListener('keydown', onKeyDown) + } + }, [onKeyDown, ref, onClick]) + + return ( + <> + {children} + + + ) +}) + +ModalContainer.propTypes = { + children: PropTypes.node, + onClick: PropTypes.func, + onKeyDown: PropTypes.func, +} + +export default ModalContainer diff --git a/components/header-bar/src/command-palette/sections/search-field.js b/components/header-bar/src/command-palette/sections/search-field.js index 6ea20c4cc..5e875aea5 100644 --- a/components/header-bar/src/command-palette/sections/search-field.js +++ b/components/header-bar/src/command-palette/sections/search-field.js @@ -1,17 +1,41 @@ 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' +import { InputField } from '@dhis2-ui/input' +import React, { useCallback, useMemo } from 'react' +import i18n from '../../locales/index.js' +import { useCommandPaletteContext } from '../context/command-palette-context.js' +import { + ALL_APPS_VIEW, + ALL_COMMANDS_VIEW, + ALL_SHORTCUTS_VIEW, +} from '../utils/constants.js' + +function SearchFilter() { + const { currentView, filter, setFilter } = useCommandPaletteContext() + + const handleFilterChange = useCallback( + ({ value }) => setFilter(value), + [setFilter] + ) + + const placeholder = useMemo(() => { + if (currentView === ALL_APPS_VIEW) { + return i18n.t('Search apps') + } else if (currentView === ALL_COMMANDS_VIEW) { + return i18n.t('Search commands') + } else if (currentView === ALL_SHORTCUTS_VIEW) { + return i18n.t('Search shortcuts') + } + return i18n.t('Search apps, shortcuts, commands') + }, [currentView]) -function Search({ value, onChange, placeholder }) { return ( <> { + const arr = [] + for (let rowNumber = 0; rowNumber < rows; rowNumber++) { + arr.push(rowNumber * columns) + } + return arr +} + +export const getLastIndexPerRow = (rows, columns) => { + const rowIndexDifference = columns - 1 + const arr = [] + + for (let rowNumber = 0; rowNumber < rows; rowNumber++) { + arr.push(rowNumber * columns + rowIndexDifference) + } + return arr +} + +export const getActiveRow = (rows, columns, highlightedIndex) => { + let row = 0 + const firstIndexPerRowArray = getFirstIndexPerRow(rows, columns) + + for (let rowNumber = 0; rowNumber < rows; rowNumber++) { + if (highlightedIndex >= firstIndexPerRowArray[rowNumber]) { + row = rowNumber + } + } + return row +} + +export const getFirstIndexOfActiveRow = (rows, columns, highlightedIndex) => { + const activeRow = getActiveRow(rows, columns, highlightedIndex) + const firstIndexPerRowArray = getFirstIndexPerRow(rows, columns) + + return firstIndexPerRowArray[activeRow] +} + +export const getLastIndexOfActiveRow = (rows, columns, highlightedIndex) => { + const activeRow = getActiveRow(rows, columns, highlightedIndex) + const lastIndexPerRowArray = getLastIndexPerRow(rows, columns) + + return lastIndexPerRowArray[activeRow] +} + +export const getFirstIndexOfLastRow = (rows, columns) => { + const firstIndexPerRowArray = getFirstIndexPerRow(rows, columns) + return firstIndexPerRowArray[rows - 1] +} + +export const getNextLeftIndex = (rows, columns, highlightedIndex) => { + const firstIndexOfActiveRow = getFirstIndexOfActiveRow( + rows, + columns, + highlightedIndex + ) + const lastIndexOfActiveRow = getLastIndexOfActiveRow( + rows, + columns, + highlightedIndex + ) + + return highlightedIndex > firstIndexOfActiveRow + ? highlightedIndex - 1 + : lastIndexOfActiveRow +} + +export const getNextRightIndex = (rows, columns, highlightedIndex) => { + const firstIndexOfActiveRow = getFirstIndexOfActiveRow( + rows, + columns, + highlightedIndex + ) + const lastIndexOfActiveRow = getLastIndexOfActiveRow( + rows, + columns, + highlightedIndex + ) + + return highlightedIndex >= lastIndexOfActiveRow + ? firstIndexOfActiveRow + : highlightedIndex + 1 +} + +export const isInFirstRow = (rows, columns, index) => { + const firstIndexPerRowArray = getFirstIndexPerRow(rows, columns) + const lastIndexPerRowArray = getLastIndexPerRow(rows, columns) + + const firstIndexOfFirstRow = firstIndexPerRowArray[0] + const lastIndexOfFirstRow = lastIndexPerRowArray[0] + + return index >= firstIndexOfFirstRow && index <= lastIndexOfFirstRow +} + +export const isInLastRow = (rows, columns, index) => { + const firstIndexOfLastRow = getFirstIndexOfLastRow(rows, columns) + return index >= firstIndexOfLastRow +} diff --git a/components/header-bar/src/command-palette/utils/home-navigation.js b/components/header-bar/src/command-palette/utils/home-navigation.js new file mode 100644 index 000000000..37e3e59d0 --- /dev/null +++ b/components/header-bar/src/command-palette/utils/home-navigation.js @@ -0,0 +1,132 @@ +import { GRID_COLUMNS, GRID_SECTION, ACTIONS_SECTION } from './constants.js' +import { + getNextLeftIndex, + getNextRightIndex, + getFirstIndexOfLastRow, + isInFirstRow, + isInLastRow, +} from './grid-functions.js' + +const isFirstListIndex = (index) => index <= 0 +const isLastListIndex = (index, listLength) => index >= listLength - 1 + +const getNextUpperIndex = ({ + isTopIndexInCurrentSection, + lastIndexInNextSection, + verticalGap, + highlightedIndex, +}) => { + if (isTopIndexInCurrentSection) { + return lastIndexInNextSection + } else { + return highlightedIndex - verticalGap + } +} + +const getNextLowerIndex = ({ + isLastIndexInCurrentSection, + verticalGap, + highlightedIndex, +}) => { + if (isLastIndexInCurrentSection) { + return 0 + } else { + return highlightedIndex + verticalGap + } +} + +export const handleHomeNavigation = ({ + event, + activeSection, + rows, + columns, + highlightedIndex, + actionsListLength, +}) => { + const gridVerticalGap = GRID_COLUMNS + + const nextSection = + activeSection === GRID_SECTION ? ACTIONS_SECTION : GRID_SECTION + const verticalGap = activeSection === GRID_SECTION ? gridVerticalGap : 1 + + const defaultValue = { section: activeSection, index: highlightedIndex } + + const handleArrowLeft = () => { + event.preventDefault() + if (activeSection === GRID_SECTION) { + return { + section: activeSection, + index: getNextLeftIndex(rows, columns, highlightedIndex), + } + } + return defaultValue + } + + const handleArrowRight = () => { + event.preventDefault() + if (activeSection === GRID_SECTION) { + return { + section: activeSection, + index: getNextRightIndex(rows, columns, highlightedIndex), + } + } + return defaultValue + } + + const handleArrowUp = () => { + event.preventDefault() + const firstIndexOfLastRow = getFirstIndexOfLastRow(rows, columns) + const isTopIndex = + activeSection === GRID_SECTION + ? isInFirstRow(rows, columns, highlightedIndex) + : isFirstListIndex(highlightedIndex) + + const lastIndexInNextSection = + activeSection === GRID_SECTION + ? actionsListLength - 1 + : firstIndexOfLastRow + + const nextTopIndex = getNextUpperIndex({ + isTopIndexInCurrentSection: isTopIndex, + lastIndexInNextSection, + verticalGap, + highlightedIndex, + }) + + return { + section: isTopIndex ? nextSection : activeSection, + index: nextTopIndex, + } + } + + const handleArrowDown = () => { + event.preventDefault() + const isLastIndex = + activeSection === GRID_SECTION + ? isInLastRow(rows, columns, highlightedIndex) + : isLastListIndex(highlightedIndex, actionsListLength) + + const nextLowerIndex = getNextLowerIndex({ + isLastIndexInCurrentSection: isLastIndex, + verticalGap, + highlightedIndex, + }) + return { + section: isLastIndex ? nextSection : activeSection, + index: nextLowerIndex, + } + } + + switch (event.key) { + case 'ArrowLeft': + return handleArrowLeft() + case 'ArrowRight': + return handleArrowRight() + case 'ArrowDown': + return handleArrowDown() + case 'ArrowUp': + return handleArrowUp() + default: + return defaultValue + } +} diff --git a/components/header-bar/src/command-palette/utils/list-navigation.js b/components/header-bar/src/command-palette/utils/list-navigation.js new file mode 100644 index 000000000..4bf83b721 --- /dev/null +++ b/components/header-bar/src/command-palette/utils/list-navigation.js @@ -0,0 +1,18 @@ +export const handleListNavigation = ({ + event, + listLength, + highlightedIndex, +}) => { + const lastIndex = listLength - 1 + switch (event.key) { + case 'ArrowDown': + event.preventDefault() + return highlightedIndex >= lastIndex ? 0 : highlightedIndex + 1 + + case 'ArrowUp': + event.preventDefault() + return highlightedIndex > 0 ? highlightedIndex - 1 : lastIndex + default: + return highlightedIndex + } +} diff --git a/components/header-bar/src/command-palette/views/home-view.js b/components/header-bar/src/command-palette/views/home-view.js index 89ef449d6..b820aab04 100644 --- a/components/header-bar/src/command-palette/views/home-view.js +++ b/components/header-bar/src/command-palette/views/home-view.js @@ -1,27 +1,22 @@ -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 { + ACTIONS_SECTION, + GRID_SECTION, + MIN_APPS_NUM, +} from '../utils/constants.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) +function HomeView({ apps, filteredItems, actions }) { + const { filter, highlightedIndex, activeSection } = + useCommandPaletteContext() + const topApps = apps?.slice(0, MIN_APPS_NUM) return ( <> {filter.length > 0 ? ( @@ -51,13 +46,10 @@ function HomeView({ apps, commands, shortcuts, actions }) { path={defaultAction} img={icon} highlighted={ - activeSection === 'grid' && + activeSection === + GRID_SECTION && highlightedIndex === idx } - handleMouseEnter={() => { - setActiveSection('grid') - setHighlightedIndex(idx) - }} /> ) )} @@ -72,59 +64,38 @@ function HomeView({ apps, commands, shortcuts, actions }) { )} {/* 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) - }} - /> - ) - } + ( + { + dataTest, + icon, + title, + name, + type, + href, + action, + }, + index + ) => ( + + ) )}
@@ -136,8 +107,7 @@ function HomeView({ apps, commands, shortcuts, actions }) { HomeView.propTypes = { actions: PropTypes.array, apps: PropTypes.array, - commands: PropTypes.array, - shortcuts: PropTypes.array, + filteredItems: 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 index d62882ebb..cc3e9e12d 100644 --- a/components/header-bar/src/command-palette/views/list-view.js +++ b/components/header-bar/src/command-palette/views/list-view.js @@ -5,6 +5,7 @@ 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' +import { COMMAND } from '../utils/constants.js' export function BrowseApps({ apps }) { return @@ -18,7 +19,7 @@ export function BrowseCommands({ commands }) { ) } diff --git a/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 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 index ed0803bcc..5cbdd662b 100644 --- a/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 +++ 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('.backdrop').click({ force: true }) + cy.get('.headerbar > dialog').click({ force: true }) }) diff --git a/components/header-bar/src/header-bar.js b/components/header-bar/src/header-bar.js index 94fe5b538..40e4ec590 100755 --- a/components/header-bar/src/header-bar.js +++ b/components/header-bar/src/header-bar.js @@ -4,6 +4,7 @@ import PropTypes from 'prop-types' import React, { useMemo } from 'react' import CommandPalette from './command-palette/command-palette.js' import { CommandPaletteContextProvider } from './command-palette/context/command-palette-context.js' +import { APP } from './command-palette/utils/constants.js' import { HeaderBarContextProvider } from './header-bar-context.js' import { joinPath } from './join-path.js' import i18n from './locales/index.js' @@ -51,8 +52,11 @@ export const HeaderBar = ({ return data?.apps.modules.map((app) => ({ ...app, + type: APP, icon: getPath(app.icon), - defaultAction: getPath(app.defaultAction), + action: () => { + window.location.href = getPath(app.defaultAction) + }, })) }, [data, baseUrl])