From c9a17a3da0ec7409f0236e85e5c0b49134f4d6a9 Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Mon, 14 Oct 2024 16:32:45 +0300 Subject: [PATCH 01/21] feat: make input clearable and add prefix icon --- .../input/src/input-field/input-field.js | 12 ++++ .../input-field/input-field.prod.stories.js | 23 ++++++++ components/input/src/input/input.js | 58 ++++++++++++++++++- components/input/types/index.d.ts | 24 ++++++++ 4 files changed, 116 insertions(+), 1 deletion(-) diff --git a/components/input/src/input-field/input-field.js b/components/input/src/input-field/input-field.js index 53875cbdd6..64c3197e70 100644 --- a/components/input/src/input-field/input-field.js +++ b/components/input/src/input-field/input-field.js @@ -39,6 +39,9 @@ class InputField extends React.Component { validationText, inputWidth, autoComplete, + clearable, + clearText, + prefixIcon, dataTest = 'dhis2-uiwidgets-inputfield', } = this.props @@ -79,6 +82,9 @@ class InputField extends React.Component { initialFocus={initialFocus} readOnly={readOnly} autoComplete={autoComplete} + clearable={clearable} + clearText={clearText} + prefixIcon={prefixIcon} /> @@ -90,6 +96,10 @@ const InputFieldProps = { /** The [native `autocomplete` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autocomplete) */ autoComplete: PropTypes.string, className: PropTypes.string, + /** function to clear the input value */ + clearText: PropTypes.func, + /** Makes the input field clearable */ + clearable: PropTypes.bool, dataTest: PropTypes.string, /** Makes the input smaller */ dense: PropTypes.bool, @@ -115,6 +125,8 @@ const InputFieldProps = { name: PropTypes.string, /** Placeholder text for the input */ placeholder: PropTypes.string, + /** Add prefix icon */ + prefixIcon: PropTypes.element, /** Makes the input read-only */ readOnly: PropTypes.bool, /** Indicates this input is required */ diff --git a/components/input/src/input-field/input-field.prod.stories.js b/components/input/src/input-field/input-field.prod.stories.js index 63dcda1c67..2e8d318016 100644 --- a/components/input/src/input-field/input-field.prod.stories.js +++ b/components/input/src/input-field/input-field.prod.stories.js @@ -1,4 +1,5 @@ import { sharedPropTypes } from '@dhis2/ui-constants' +import { IconSearch16 } from '@dhis2/ui-icons' import React from 'react' import { InputField } from './index.js' @@ -156,3 +157,25 @@ ValueTextOverflow.args = { export const Required = Template.bind({}) Required.args = { required: true } + +export const InputWithPrefixIcon = (args) => ( + } + /> +) + +export const ClearableInput = (args) => ( + console.log('clear the value')} + value={'value'} + /> +) diff --git a/components/input/src/input/input.js b/components/input/src/input/input.js index 3fd5ed00c0..e65dbab237 100644 --- a/components/input/src/input/input.js +++ b/components/input/src/input/input.js @@ -1,4 +1,5 @@ import { theme, colors, spacers, sharedPropTypes } from '@dhis2/ui-constants' +import { IconCross16 } from '@dhis2/ui-icons' import { StatusIcon } from '@dhis2-ui/status-icon' import cx from 'classnames' import PropTypes from 'prop-types' @@ -155,10 +156,22 @@ export class Input extends Component { step, autoComplete, dataTest = 'dhis2-uicore-input', + clearable, + clearText, + prefixIcon, } = this.props return ( -
+
+ {prefixIcon && {prefixIcon}} + {clearable && value.length ? ( + + ) : null}
) @@ -209,6 +259,10 @@ Input.propTypes = { /** The [native `autocomplete` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autocomplete) */ autoComplete: PropTypes.string, className: PropTypes.string, + /** function to clear the input value */ + clearText: PropTypes.func, + /** Makes the input field clearable */ + clearable: PropTypes.bool, dataTest: PropTypes.string, /** Makes the input smaller */ dense: PropTypes.bool, @@ -228,6 +282,8 @@ Input.propTypes = { name: PropTypes.string, /** Placeholder text for the input */ placeholder: PropTypes.string, + /** Add prefix icon */ + prefixIcon: PropTypes.element, /** Makes the input read-only */ readOnly: PropTypes.bool, /** Sets a role attribute on the input */ diff --git a/components/input/types/index.d.ts b/components/input/types/index.d.ts index e9bca202d9..c6a82abab8 100644 --- a/components/input/types/index.d.ts +++ b/components/input/types/index.d.ts @@ -41,6 +41,14 @@ export interface InputProps { */ autoComplete?: string className?: string + /** + * Function to clear the input value + */ + clearText?: func + /** + * Makes the input clearable + */ + clearable?: boolean dataTest?: string /** * Makes the input smaller @@ -78,6 +86,10 @@ export interface InputProps { * Placeholder text for the input */ placeholder?: string + /** + * Add prefix icon + */ + prefixIcon?: Element /** * Makes the input read-only */ @@ -135,6 +147,14 @@ export interface InputFieldProps { */ autoComplete?: string className?: string + /** + * Function to clear the input value + */ + clearText?: func + /** + * Makes the input field clearable + */ + clearable?: boolean dataTest?: string /** * Makes the input smaller @@ -184,6 +204,10 @@ export interface InputFieldProps { * Placeholder text for the input */ placeholder?: string + /** + * Add prefix icon to input + */ + prefixIcon?: Element /** * Makes the input read-only */ From fbb2d4a8a9c0dbb2c78fe1de8b76ace38eed931f Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Tue, 15 Oct 2024 19:39:56 +0300 Subject: [PATCH 02/21] fix: prefix and clear button css alignment --- .../input/src/input-field/input-field.js | 1 + .../input-field/input-field.prod.stories.js | 54 ++++++++++++------- components/input/src/input/input.js | 43 +++++++++------ 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/components/input/src/input-field/input-field.js b/components/input/src/input-field/input-field.js index 64c3197e70..c7a6e7d2db 100644 --- a/components/input/src/input-field/input-field.js +++ b/components/input/src/input-field/input-field.js @@ -85,6 +85,7 @@ class InputField extends React.Component { clearable={clearable} clearText={clearText} prefixIcon={prefixIcon} + width={inputWidth} /> diff --git a/components/input/src/input-field/input-field.prod.stories.js b/components/input/src/input-field/input-field.prod.stories.js index 2e8d318016..faacad2633 100644 --- a/components/input/src/input-field/input-field.prod.stories.js +++ b/components/input/src/input-field/input-field.prod.stories.js @@ -1,6 +1,6 @@ import { sharedPropTypes } from '@dhis2/ui-constants' -import { IconSearch16 } from '@dhis2/ui-icons' -import React from 'react' +import { IconLocation16, IconSearch16 } from '@dhis2/ui-icons' +import React, { useState } from 'react' import { InputField } from './index.js' const subtitle = 'Allows a user to enter data, usually text' @@ -159,23 +159,37 @@ export const Required = Template.bind({}) Required.args = { required: true } export const InputWithPrefixIcon = (args) => ( - } - /> + <> + } + /> + } + inputWidth={'200px'} + /> + ) -export const ClearableInput = (args) => ( - console.log('clear the value')} - value={'value'} - /> -) +export const ClearableInput = (args) => { + const [value, setValue] = useState('value') + return ( + setValue(e.value)} + clearable + clearText={() => setValue('')} + value={value} + /> + ) +} diff --git a/components/input/src/input/input.js b/components/input/src/input/input.js index e65dbab237..411cba2b2f 100644 --- a/components/input/src/input/input.js +++ b/components/input/src/input/input.js @@ -9,8 +9,9 @@ import { inputTypes } from './inputTypes.js' const styles = css` .input { - display: flex; + display: inline-flex; align-items: center; + position: relative; gap: ${spacers.dp8}; } @@ -159,6 +160,7 @@ export class Input extends Component { clearable, clearText, prefixIcon, + width, } = this.props return ( @@ -200,12 +202,13 @@ export class Input extends Component { 'read-only': readOnly, })} /> - {clearable && value.length ? ( - ) : null} {styles}
@@ -299,6 +310,8 @@ Input.propTypes = { value: PropTypes.string, /** Applies 'warning' appearance for validation feedback. Mutually exclusive with `valid` and `error` props */ warning: sharedPropTypes.statusPropType, + /** Defines the width of the input. Can be any valid CSS measurement */ + width: PropTypes.string, /** Called with signature `({ name: string, value: string }, event)` */ onBlur: PropTypes.func, /** Called with signature `({ name: string, value: string }, event)` */ From 5bed81842fbab47b4993ac1ea1b3aa3c26c29020 Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Wed, 16 Oct 2024 13:59:56 +0300 Subject: [PATCH 03/21] refactor: use onChange handler to clear text --- components/input/src/input-field/input-field.js | 4 ---- components/input/src/input/input.js | 14 ++++++++++---- components/input/types/index.d.ts | 8 -------- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/components/input/src/input-field/input-field.js b/components/input/src/input-field/input-field.js index c7a6e7d2db..d9275dca17 100644 --- a/components/input/src/input-field/input-field.js +++ b/components/input/src/input-field/input-field.js @@ -40,7 +40,6 @@ class InputField extends React.Component { inputWidth, autoComplete, clearable, - clearText, prefixIcon, dataTest = 'dhis2-uiwidgets-inputfield', } = this.props @@ -83,7 +82,6 @@ class InputField extends React.Component { readOnly={readOnly} autoComplete={autoComplete} clearable={clearable} - clearText={clearText} prefixIcon={prefixIcon} width={inputWidth} /> @@ -97,8 +95,6 @@ const InputFieldProps = { /** The [native `autocomplete` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autocomplete) */ autoComplete: PropTypes.string, className: PropTypes.string, - /** function to clear the input value */ - clearText: PropTypes.func, /** Makes the input field clearable */ clearable: PropTypes.bool, dataTest: PropTypes.string, diff --git a/components/input/src/input/input.js b/components/input/src/input/input.js index 411cba2b2f..6f702aa3fd 100644 --- a/components/input/src/input/input.js +++ b/components/input/src/input/input.js @@ -129,6 +129,15 @@ export class Input extends Component { } } + handleClear = () => { + if (this.props.onChange) { + this.props.onChange({ + value: '', + name: this.props.name, + }) + } + } + createHandlerPayload(e) { return { value: e.target.value, @@ -158,7 +167,6 @@ export class Input extends Component { autoComplete, dataTest = 'dhis2-uicore-input', clearable, - clearText, prefixIcon, width, } = this.props @@ -205,7 +213,7 @@ export class Input extends Component { {clearable && value?.length ? ( {show ? ( - @@ -269,8 +279,360 @@ const Apps = ({ apps }) => { ) } -Apps.propTypes = { - apps: PropTypes.array.isRequired, +CommandPalette.propTypes = { + apps: PropTypes.array, + commands: PropTypes.array, +} + +export const MenuModal = ({ show, apps, commands, filter, onFilterChange }) => { + console.log(apps, 'apps') + const [currentView, setCurrentView] = useState('home') + const showActions = filter.length <= 0 && currentView === 'home' + + return ( +
+ {show && ( + + +
+ + + + {showActions ? ( + + ) : null} + +
+
+
+ )} +
+ ) +} + +MenuModal.propTypes = { + apps: PropTypes.array, + commands: PropTypes.array, + filter: PropTypes.string, + show: PropTypes.bool, + onFilterChange: PropTypes.func, +} + +function ViewSwitcher({ apps, commands, filter, view, setView }) { + switch (view) { + case 'apps': + return + case 'commands': + return ( + + ) + case 'home': + default: + return + } +} + +ViewSwitcher.propTypes = { + apps: PropTypes.array, + commands: PropTypes.array, + filter: PropTypes.string, + setView: PropTypes.func, + view: PropTypes.string, +} + +function AllAppsView({ apps, filter, setView }) { + const filteredApps = 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 + }) + + return ( + <> + +
+ {filter ? ( + filteredApps.length > 0 ? ( +

+ Results for {filter} +

+ ) : ( +

+ Nothing found for {filter} +

+ ) + ) : ( +
+ {i18n.t('All Apps')} +
+ )} +
+ + + ) +} + +AllAppsView.propTypes = { + apps: PropTypes.array, + filter: PropTypes.string, + setView: PropTypes.func, +} + +function CommandsView({ commands, filter, setView }) { + const filteredCommands = commands.filter(({ displayName, name }) => { + const commandName = displayName || name + const formattedAppName = commandName.toLowerCase() + const formattedFilter = escapeRegExpCharacters(filter).toLowerCase() + + return filter.length > 0 + ? formattedAppName.match(formattedFilter) + : true + }) + + return ( + <> + +
+ {filter ? ( + filteredCommands.length > 0 ? ( + Results for {filter} + ) : ( + Nothing found for {filter} + ) + ) : ( +
+ {i18n.t('All Commands')} +
+ )} +
+ {filteredCommands.map( + ( + { displayName, name, defaultAction, icon, description }, + idx + ) => ( + + ) + )} + + ) +} + +CommandsView.propTypes = { + commands: PropTypes.array, + filter: PropTypes.string, + setView: PropTypes.func, +} + +function HomeView({ apps, filter }) { + const filteredApps = 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 + }) + return ( + <> + {filter ? ( + filteredApps.length > 0 ? ( + Results for {filter} + ) : ( + Nothing found for {filter} + ) + ) : ( +
{i18n.t('Top Apps')}
+ )} +
+ {filteredApps.length > 0 && + filteredApps + .slice(0, 8) + .map( + ( + { displayName, name, defaultAction, icon }, + idx + ) => ( + + ) + )} + + +
+ + ) +} + +HomeView.propTypes = { + apps: PropTypes.array, + filter: 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, +} + +function Actions({ setView }) { + const actions = [ + { + icon: IconApps16, + type: 'apps', + action: 'Browse apps', + }, + { + icon: IconTerminalWindow16, + type: 'commands', + action: 'Browse commands', + }, + ] + + const { baseUrl } = useConfig() + + return ( + <> +
{i18n.t('Actions')}
+ {actions.map((action, index) => ( + { + console.log(payload.value, event.target) + setView(action.type) + }} + label={i18n.t(`${action.action}`)} + value={action.action} + icon={} + /> + ))} + { + // setLoading(true) + await clearSensitiveCaches() + // setLoading(false) + window.location.assign( + joinPath( + baseUrl, + 'dhis-web-commons-security/logout.action' + ) + ) + }} + label={i18n.t('Logout')} + value="logout" + icon={} + /> + + ) +} + +Actions.propTypes = { + setView: PropTypes.func, } -export default Apps +export default CommandPalette diff --git a/components/header-bar/src/header-bar.js b/components/header-bar/src/header-bar.js index 673c6b4ab9..f8ebd5094e 100755 --- a/components/header-bar/src/header-bar.js +++ b/components/header-bar/src/header-bar.js @@ -2,7 +2,7 @@ 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 './apps.js' import { HeaderBarContextProvider } from './header-bar-context.js' import { joinPath } from './join-path.js' import i18n from './locales/index.js' @@ -55,6 +55,36 @@ export const HeaderBar = ({ })) }, [data, baseUrl]) + const commands = [ + { + defaultAction: function handleOpen() { + console.log('open...') + }, + description: 'Search for and open a visualisation, chart, or table', + displayName: 'Open...', + icon: 'https://domain.tld/api/../icons/dhis-web-dashboard.png', + name: 'open', + }, + { + defaultAction: function handleOpen() { + console.log('debug...') + }, + description: 'Copy debug information to the clipboard', + displayName: 'Debug...', + icon: 'https://domain.tld/api/../icons/dhis-web-dashboard.png', + name: 'debug', + }, + { + defaultAction: function handleOpen() { + console.log('clearing cache...') + }, + description: 'Empty system cache', + displayName: 'Clear cache...', + icon: 'https://domain.tld/api/../icons/dhis-web-dashboard.png', + name: 'clear-cache', + }, + ] + // See https://jira.dhis2.org/browse/LIBS-180 if (!loading && !error) { // TODO: This will run every render which is probably wrong! @@ -94,7 +124,7 @@ export const HeaderBar = ({ } userAuthorities={data.user.authorities} /> - + Date: Sun, 29 Sep 2024 04:10:21 +0300 Subject: [PATCH 05/21] refactor: replace multiple view components with one view + css modifications --- components/header-bar/src/apps.js | 691 +++++++++++++----------------- 1 file changed, 307 insertions(+), 384 deletions(-) diff --git a/components/header-bar/src/apps.js b/components/header-bar/src/apps.js index 95fd4ab99e..72db8e9f30 100755 --- a/components/header-bar/src/apps.js +++ b/components/header-bar/src/apps.js @@ -8,12 +8,11 @@ import { IconArrowLeft16, } from '@dhis2/ui-icons' import { Button } from '@dhis2-ui/button' -import { Card } from '@dhis2-ui/card' import { InputField } from '@dhis2-ui/input' import { MenuItem } from '@dhis2-ui/menu' import { Modal, ModalContent } from '@dhis2-ui/modal' import PropTypes from 'prop-types' -import React, { useState, useCallback, useRef } from 'react' +import React, { useState, useCallback, useRef, useEffect } from 'react' import { joinPath } from './join-path.js' import i18n from './locales/index.js' @@ -35,18 +34,16 @@ function Search({ value, onChange }) { placeholder={i18n.t('Search apps, shortcuts, commands')} onChange={onChange} initialFocus + // style={{ + // width: '100%', + // border: 'none', + // }} /> @@ -58,7 +55,7 @@ Search.propTypes = { onChange: PropTypes.func.isRequired, } -function Item({ name, path, img }) { +function AppItem({ name, path, img }) { return ( app logo @@ -67,14 +64,11 @@ function Item({ name, path, img }) { ) } -AppItem.propTypes = { +ListItem.propTypes = { description: PropTypes.string, image: PropTypes.string, name: PropTypes.string, path: PropTypes.string, } -function BackToHomeButton({ setView }) { - const handleClick = () => { - setView('home') - } - return ( - - - {show ? ( - - ) : null} +
+ {filteredItems.map( + ( + { displayName, name, defaultAction, icon, description }, + idx + ) => ( + + ) + )}
) } - -CommandPalette.propTypes = { - apps: PropTypes.array, - commands: PropTypes.array, +List.propTypes = { + filteredItems: PropTypes.array, } -export const MenuModal = ({ show, apps, commands, filter, onFilterChange }) => { - console.log(apps, 'apps') - const [currentView, setCurrentView] = useState('home') - const showActions = filter.length <= 0 && currentView === 'home' - - return ( -
- {show && ( - - -
- - - - {showActions ? ( - - ) : null} - -
-
-
- )} -
- ) -} +function Actions({ setView }) { + const actions = [ + { + icon: IconApps16, + type: 'apps', + action: 'Browse apps', + }, + { + icon: IconTerminalWindow16, + type: 'commands', + action: 'Browse commands', + }, + ] -MenuModal.propTypes = { - apps: PropTypes.array, - commands: PropTypes.array, - filter: PropTypes.string, - show: PropTypes.bool, - onFilterChange: PropTypes.func, -} + const { baseUrl } = useConfig() -function ViewSwitcher({ apps, commands, filter, view, setView }) { - switch (view) { - case 'apps': - return - case 'commands': - return ( - +
{i18n.t('Actions')}
+ {actions.map((action, index) => ( + { + console.log(payload.value, event.target) + setView(action.type) + }} + label={i18n.t(`${action.action}`)} + value={action.action} + icon={} /> - ) - case 'home': - default: - return - } + ))} + {/* got from profile-menu: https://github.com/dhis2/ui/blob/4902126ef0a6163961286a29f8df44b8fd3a0604/components/header-bar/src/profile-menu/profile-menu.js#L88 */} + { + // setLoading(true) + await clearSensitiveCaches() + // setLoading(false) + window.location.assign( + joinPath( + baseUrl, + 'dhis-web-commons-security/logout.action' + ) + ) + }} + label={i18n.t('Logout')} + value="logout" + icon={} + /> + + ) } -ViewSwitcher.propTypes = { - apps: PropTypes.array, - commands: PropTypes.array, - filter: PropTypes.string, +Actions.propTypes = { setView: PropTypes.func, - view: PropTypes.string, } -function AllAppsView({ apps, filter, setView }) { - const filteredApps = 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 - }) - +function SearchResults({ filter, filteredItems, heading }) { return ( - <> - -
- {filter ? ( - filteredApps.length > 0 ? ( -

- Results for {filter} -

- ) : ( -

- Nothing found for {filter} -

- ) +
+ {filter ? ( + filteredItems.length > 0 ? ( +

+ Results for {filter} +

) : ( -
- {i18n.t('All Apps')} -
- )} -
- - +

+ Nothing found for {filter} +

+ ) + ) : ( +
+ {i18n.t(`${heading}`)} +
+ )} +
) } -AllAppsView.propTypes = { - apps: PropTypes.array, +SearchResults.propTypes = { filter: PropTypes.string, - setView: PropTypes.func, + filteredItems: PropTypes.array, + heading: PropTypes.string, } -function CommandsView({ commands, filter, setView }) { - const filteredCommands = commands.filter(({ displayName, name }) => { - const commandName = displayName || name - const formattedAppName = commandName.toLowerCase() +function ActionsView({ heading, itemsArray, filter, setView }) { + const filteredItems = itemsArray.filter(({ displayName, name }) => { + const itemName = displayName || name + const formattedItemName = itemName.toLowerCase() const formattedFilter = escapeRegExpCharacters(filter).toLowerCase() return filter.length > 0 - ? formattedAppName.match(formattedFilter) + ? formattedItemName.match(formattedFilter) : true }) + const handleClick = () => { + setView('home') + } return ( <> - -
- {filter ? ( - filteredCommands.length > 0 ? ( - Results for {filter} - ) : ( - Nothing found for {filter} - ) - ) : ( -
- {i18n.t('All Commands')} -
- )} -
- {filteredCommands.map( - ( - { displayName, name, defaultAction, icon, description }, - idx - ) => ( - - ) - )} + + + {show ? ( + + +
+ + + {showActions ? ( + + ) : null} +
+
+
+ ) : null} + + + ) } -Actions.propTypes = { - setView: PropTypes.func, +CommandPalette.propTypes = { + apps: PropTypes.array, + commands: PropTypes.array, } export default CommandPalette From b6a17eee8d7b3c7865b1b0ecd46b5a0fc94f58d5 Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Wed, 2 Oct 2024 14:41:38 +0300 Subject: [PATCH 06/21] fix: separate fields, views and command palette components --- components/header-bar/src/apps.js | 561 ------------------ .../header-bar/src/command-palette/apps.js | 178 ++++++ .../header-bar/src/command-palette/fields.js | 387 ++++++++++++ .../header-bar/src/command-palette/views.js | 199 +++++++ components/header-bar/src/header-bar.js | 2 +- 5 files changed, 765 insertions(+), 562 deletions(-) delete mode 100755 components/header-bar/src/apps.js create mode 100755 components/header-bar/src/command-palette/apps.js create mode 100644 components/header-bar/src/command-palette/fields.js create mode 100644 components/header-bar/src/command-palette/views.js diff --git a/components/header-bar/src/apps.js b/components/header-bar/src/apps.js deleted file mode 100755 index 72db8e9f30..0000000000 --- a/components/header-bar/src/apps.js +++ /dev/null @@ -1,561 +0,0 @@ -import { clearSensitiveCaches, useConfig } from '@dhis2/app-runtime' -import { colors, spacers, theme } from '@dhis2/ui-constants' -import { - IconApps24, - IconApps16, - IconTerminalWindow16, - IconLogOut16, - IconArrowLeft16, -} from '@dhis2/ui-icons' -import { Button } from '@dhis2-ui/button' -import { InputField } from '@dhis2-ui/input' -import { MenuItem } from '@dhis2-ui/menu' -import { Modal, ModalContent } from '@dhis2-ui/modal' -import PropTypes from 'prop-types' -import React, { useState, useCallback, useRef, useEffect } 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 }) { - return ( -
- - - - -
- ) -} - -Search.propTypes = { - value: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, -} - -function AppItem({ name, path, img }) { - return ( - - app logo - -
{name}
- - -
- ) -} - -AppItem.propTypes = { - img: PropTypes.string, - name: PropTypes.string, - path: PropTypes.string, -} - -function ListItem({ name, path, image, description }) { - return ( - - logo -
{name}
- {description &&
{description}
} - -
- ) -} - -ListItem.propTypes = { - description: PropTypes.string, - image: PropTypes.string, - name: PropTypes.string, - path: PropTypes.string, -} - -function List({ filteredItems }) { - return ( -
- {filteredItems.map( - ( - { displayName, name, defaultAction, icon, description }, - idx - ) => ( - - ) - )} - - -
- ) -} -List.propTypes = { - filteredItems: PropTypes.array, -} - -function Actions({ setView }) { - const actions = [ - { - icon: IconApps16, - type: 'apps', - action: 'Browse apps', - }, - { - icon: IconTerminalWindow16, - type: 'commands', - action: 'Browse commands', - }, - ] - - const { baseUrl } = useConfig() - - return ( - <> -
{i18n.t('Actions')}
- {actions.map((action, index) => ( - { - console.log(payload.value, event.target) - setView(action.type) - }} - label={i18n.t(`${action.action}`)} - value={action.action} - icon={} - /> - ))} - {/* got from profile-menu: https://github.com/dhis2/ui/blob/4902126ef0a6163961286a29f8df44b8fd3a0604/components/header-bar/src/profile-menu/profile-menu.js#L88 */} - { - // setLoading(true) - await clearSensitiveCaches() - // setLoading(false) - window.location.assign( - joinPath( - baseUrl, - 'dhis-web-commons-security/logout.action' - ) - ) - }} - label={i18n.t('Logout')} - value="logout" - icon={} - /> - - ) -} - -Actions.propTypes = { - setView: PropTypes.func, -} - -function SearchResults({ filter, filteredItems, heading }) { - return ( -
- {filter ? ( - filteredItems.length > 0 ? ( -

- Results for {filter} -

- ) : ( -

- Nothing found for {filter} -

- ) - ) : ( -
- {i18n.t(`${heading}`)} -
- )} -
- ) -} - -SearchResults.propTypes = { - filter: PropTypes.string, - filteredItems: PropTypes.array, - heading: PropTypes.string, -} - -function ActionsView({ heading, itemsArray, filter, setView }) { - const filteredItems = itemsArray.filter(({ displayName, name }) => { - const itemName = displayName || name - const formattedItemName = itemName.toLowerCase() - const formattedFilter = escapeRegExpCharacters(filter).toLowerCase() - - return filter.length > 0 - ? formattedItemName.match(formattedFilter) - : true - }) - - const handleClick = () => { - setView('home') - } - return ( - <> - - - {show ? ( - - -
- - - {showActions ? ( - - ) : null} -
-
-
- ) : null} - - - - ) -} - -CommandPalette.propTypes = { - apps: PropTypes.array, - commands: PropTypes.array, -} - -export default CommandPalette diff --git a/components/header-bar/src/command-palette/apps.js b/components/header-bar/src/command-palette/apps.js new file mode 100755 index 0000000000..e67d6b56ac --- /dev/null +++ b/components/header-bar/src/command-palette/apps.js @@ -0,0 +1,178 @@ +import { colors, elevations, spacers } from '@dhis2/ui-constants' +import { IconApps24 } from '@dhis2/ui-icons' +import { Layer } from '@dhis2-ui/layer' +import PropTypes from 'prop-types' +import React, { useState, useCallback, useRef, useEffect } from 'react' +import { Actions, BackButton, Search } from './fields.js' +import { ViewSwitcher } from './views.js' + +const MIN_APPS_NUM = 8 + +export const Container = ({ children, setShow, show }) => { + return ( + setShow(false)} translucent={show}> +
+ {children} +
+ +
+ ) +} + +Container.propTypes = { + children: PropTypes.node, + setShow: PropTypes.func, + show: PropTypes.bool, +} + +const CommandPalette = ({ apps, commands }) => { + const [show, setShow] = useState(false) + const [filter, setFilter] = useState('') + + const [currentView, setCurrentView] = useState('home') + + const showActions = filter.length <= 0 && currentView === 'home' + const showBackButton = currentView !== 'home' + + const handleVisibilityToggle = useCallback(() => setShow(!show), [show]) + const handleFilterChange = useCallback(({ value }) => setFilter(value), []) + + const handleClearSearch = () => setFilter('') + + const containerEl = useRef(null) + + const handleKeyDown = useCallback( + (event) => { + switch (event.key) { + case 'Escape': + event.preventDefault() + if (currentView === 'home') { + setShow(false) + } else { + setCurrentView('home') + } + break + } + + if ((event.metaKey || event.ctrlKey) && event.key === '/') { + setShow(!show) + } + }, + [currentView, show] + ) + + const handleFocus = () => { + // this is about the focus of the element + // on launch: focus entire element + } + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('focus', handleFocus) + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('focus', handleFocus) + } + }, [handleKeyDown]) + + return ( +
+ + + {show ? ( + +
+ + +
+ {showBackButton ? ( + + ) : null} + + {showActions ? ( + MIN_APPS_NUM} + showCommands={commands?.length > 0} + /> + ) : null} +
+
+
+ ) : null} + + +
+ ) +} + +CommandPalette.propTypes = { + apps: PropTypes.array, + commands: PropTypes.array, +} + +export default CommandPalette diff --git a/components/header-bar/src/command-palette/fields.js b/components/header-bar/src/command-palette/fields.js new file mode 100644 index 0000000000..f4516b628c --- /dev/null +++ b/components/header-bar/src/command-palette/fields.js @@ -0,0 +1,387 @@ +import { clearSensitiveCaches, useConfig } from '@dhis2/app-runtime' +import { colors, spacers, theme } from '@dhis2/ui-constants' +import { + IconApps16, + IconTerminalWindow16, + IconLogOut16, + IconArrowLeft16, + IconSearch16, +} from '@dhis2/ui-icons' +import { MenuItem } from '@dhis2-ui/menu' +import PropTypes from 'prop-types' +import React, { useState, useRef, useEffect } from 'react' +import { InputField } from '../../../input/src/input-field/input-field.js' +import { joinPath } from '../join-path.js' +import i18n from '../locales/index.js' + +export function Search({ value, onChange, clearSearch }) { + return ( + <> + } + clearable + clearText={clearSearch} + /> + + + ) +} + +Search.propTypes = { + clearSearch: PropTypes.func.isRequired, + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +} + +export function AppItem({ name, path, img }) { + return ( + + app logo +
{name}
+ +
+ ) +} + +AppItem.propTypes = { + img: PropTypes.string, + name: PropTypes.string, + path: PropTypes.string, +} + +export function ListItem({ name, path, image, description, type }) { + const showDescription = type === 'commands' + return ( + + logo +
{name}
+ {showDescription && ( +
{description}
+ )} + +
+ ) +} + +ListItem.propTypes = { + description: PropTypes.string, + image: PropTypes.string, + name: PropTypes.string, + path: PropTypes.string, + type: PropTypes.string, +} + +export function List({ filteredItems, type }) { + const divRef = useRef(null) + const [activeItem, setActiveItem] = useState(-1) + const lastIndex = filteredItems.length - 1 + + const handleKeyDown = (event) => { + switch (event.key) { + case 'ArrowDown': + setActiveItem(activeItem >= lastIndex ? 0 : activeItem + 1) + break + case 'ArrowUp': + setActiveItem(activeItem > 0 ? activeItem - 1 : lastIndex) + break + case 'Enter': + event.preventDefault() + event.target?.click() + break + } + } + + useEffect(() => { + if (divRef) { + if (filteredItems.length && activeItem > -1) { + divRef.current.children[activeItem].focus() + } + } + }, [activeItem, filteredItems]) + + // useEffect(() => { + // if (!divRef && !divRef.current) {return} + // const div = divRef.current + // div.addEventListener('keydown', handleKeyDown) + // return () => { + // div.removeEventListener('keydown', handleKeyDown) + // } + // }, []) + return ( +
+ {filteredItems.map( + ( + { displayName, name, defaultAction, icon, description }, + idx + ) => ( + + ) + )} + + {/* // todo: use list with type/view filter to render correct item component */} + + +
+ ) +} +List.propTypes = { + filteredItems: PropTypes.array, + type: PropTypes.string, +} + +export function Actions({ setView, showApps, showCommands }) { + const { baseUrl } = useConfig() + console.log(showApps, showCommands) + + return ( + <> + + + {showApps ? ( + setView('apps')} + label={i18n.t('Browse apps')} + value="Browse apps" + icon={} + /> + ) : null} + {showCommands ? ( + setView('commands')} + label={i18n.t('Browse commands')} + value="Browse commands" + icon={} + /> + ) : null} + {/* got from profile-menu: https://github.com/dhis2/ui/blob/4902126ef0a6163961286a29f8df44b8fd3a0604/components/header-bar/src/profile-menu/profile-menu.js#L88 */} + { + await clearSensitiveCaches() + window.location.assign( + joinPath( + baseUrl, + 'dhis-web-commons-security/logout.action' + ) + ) + }} + label={i18n.t('Logout')} + value="logout" + icon={} + /> + + ) +} + +Actions.propTypes = { + setView: PropTypes.func, + showApps: PropTypes.bool, + showCommands: PropTypes.bool, +} + +export function Heading({ filter, filteredItems, heading }) { + return ( +
+ + {filter + ? filteredItems.length > 0 + ? i18n.t(`Results for ${filter}`) + : i18n.t(`Nothing found for ${filter}`) + : i18n.t(`${heading}`)} + + +
+ ) +} + +Heading.propTypes = { + filter: PropTypes.string, + filteredItems: PropTypes.array, + heading: PropTypes.string, +} + +export function BackButton({ setView, handleClearSearch }) { + const handleClick = () => { + setView('home') + handleClearSearch() + } + return ( + <> + + + + ) +} + +BackButton.propTypes = { + handleClearSearch: PropTypes.func, + setView: PropTypes.func, +} diff --git a/components/header-bar/src/command-palette/views.js b/components/header-bar/src/command-palette/views.js new file mode 100644 index 0000000000..4276399360 --- /dev/null +++ b/components/header-bar/src/command-palette/views.js @@ -0,0 +1,199 @@ +import PropTypes from 'prop-types' +import React, { useEffect, useRef, useState } from 'react' +import { AppItem, Heading, List } from './fields.js' + +/** + * Copied from here: + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping + */ +function escapeRegExpCharacters(text) { + return text.replace(/[/.*+?^${}()|[\]\\]/g, '\\$&') +} + +export function ListView({ heading, itemsArray, filter, type }) { + const filteredItems = itemsArray.filter(({ displayName, name }) => { + const itemName = displayName || name + const formattedItemName = itemName.toLowerCase() + const formattedFilter = escapeRegExpCharacters(filter).toLowerCase() + + return filter.length > 0 + ? formattedItemName.match(formattedFilter) + : true + }) + + return ( +
+ + +
+ ) +} + +ListView.propTypes = { + filter: PropTypes.string, + heading: PropTypes.string, + itemsArray: PropTypes.array, + type: PropTypes.string, +} + +export function HomeView({ apps, filter }) { + const divRef = useRef(null) + const [activeItem, setActiveItem] = useState(-1) + + const handleKeyDown = (event) => { + switch (event.key) { + case 'ArrowLeft': + // row 1 + if (activeItem <= 3) { + setActiveItem(activeItem > 0 ? activeItem - 1 : 3) + } + // row 2 + if (activeItem >= 4) { + setActiveItem(activeItem > 4 ? activeItem - 1 : 7) + } + break + case 'ArrowRight': + // row 1 + if (activeItem <= 3) { + setActiveItem(activeItem >= 3 ? 0 : activeItem + 1) + } + // row 2 + if (activeItem >= 4) { + setActiveItem(activeItem >= 7 ? 0 : activeItem + 1) + } + break + case 'ArrowDown': + setActiveItem(activeItem >= 4 ? activeItem - 4 : activeItem + 4) + break + case 'ArrowUp': + setActiveItem(activeItem <= 3 ? activeItem + 4 : activeItem - 4) + break + case 'Enter': + event.preventDefault() + event.target?.click() + break + case 'Tab': + event.preventDefault() + } + } + + const handleFocus = () => { + if (divRef) { + if (activeItem <= -1) { + setActiveItem(0) + } + } + } + + useEffect(() => { + if (divRef) { + if (apps.length && activeItem > -1) { + divRef.current?.children[activeItem]?.focus() + } + } + }, [activeItem, apps.length]) + // filter happens across everything here + const filteredApps = 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 + }) + + return ( +
+ {filter.length > 0 && ( + + )} + {filter.length < 1 && ( + <> + + +
+ {filteredApps.length > 0 && + filteredApps + .slice(0, 8) + .map( + ( + { + displayName, + name, + defaultAction, + icon, + }, + idx + ) => ( + + ) + )} + + +
+ + )} +
+ ) +} + +HomeView.propTypes = { + apps: PropTypes.array, + filter: PropTypes.string, +} + +export function ViewSwitcher({ apps, commands, filter, view }) { + switch (view) { + case 'apps': + return ( + + ) + case 'commands': + return ( + + ) + case 'home': + default: + return + } +} + +ViewSwitcher.propTypes = { + apps: PropTypes.array, + commands: PropTypes.array, + filter: PropTypes.string, + view: PropTypes.string, +} diff --git a/components/header-bar/src/header-bar.js b/components/header-bar/src/header-bar.js index f8ebd5094e..b0b9ff9eb8 100755 --- a/components/header-bar/src/header-bar.js +++ b/components/header-bar/src/header-bar.js @@ -2,7 +2,7 @@ import { useDataQuery, useConfig } from '@dhis2/app-runtime' import { colors } from '@dhis2/ui-constants' import PropTypes from 'prop-types' import React, { useMemo } from 'react' -import CommandPalette from './apps.js' +import CommandPalette from './command-palette/apps.js' import { HeaderBarContextProvider } from './header-bar-context.js' import { joinPath } from './join-path.js' import i18n from './locales/index.js' From 535121049754d63ef5a2773380a3fde02f867842 Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Wed, 16 Oct 2024 15:26:29 +0300 Subject: [PATCH 07/21] feat: update Search field and list item components --- .../header-bar/src/command-palette/apps.js | 6 +- .../header-bar/src/command-palette/fields.js | 72 +++++++++---------- 2 files changed, 37 insertions(+), 41 deletions(-) diff --git a/components/header-bar/src/command-palette/apps.js b/components/header-bar/src/command-palette/apps.js index e67d6b56ac..49ab2ff3df 100755 --- a/components/header-bar/src/command-palette/apps.js +++ b/components/header-bar/src/command-palette/apps.js @@ -101,11 +101,7 @@ const CommandPalette = ({ apps, commands }) => { {show ? (
- +
{showBackButton ? ( diff --git a/components/header-bar/src/command-palette/fields.js b/components/header-bar/src/command-palette/fields.js index f4516b628c..82fe8ca93d 100644 --- a/components/header-bar/src/command-palette/fields.js +++ b/components/header-bar/src/command-palette/fields.js @@ -7,14 +7,13 @@ import { IconArrowLeft16, IconSearch16, } from '@dhis2/ui-icons' -import { MenuItem } from '@dhis2-ui/menu' import PropTypes from 'prop-types' import React, { useState, useRef, useEffect } from 'react' import { InputField } from '../../../input/src/input-field/input-field.js' import { joinPath } from '../join-path.js' import i18n from '../locales/index.js' -export function Search({ value, onChange, clearSearch }) { +export function Search({ value, onChange }) { return ( <> } clearable - clearText={clearSearch} /> - - ) -} - -Container.propTypes = { - children: PropTypes.node, - setShow: PropTypes.func, - show: PropTypes.bool, -} - -const CommandPalette = ({ apps, commands }) => { - const [show, setShow] = useState(false) - const [filter, setFilter] = useState('') - - const [currentView, setCurrentView] = useState('home') - - const showActions = filter.length <= 0 && currentView === 'home' - const showBackButton = currentView !== 'home' - - const handleVisibilityToggle = useCallback(() => setShow(!show), [show]) - const handleFilterChange = useCallback(({ value }) => setFilter(value), []) - - const handleClearSearch = () => setFilter('') - - const containerEl = useRef(null) - - const handleKeyDown = useCallback( - (event) => { - switch (event.key) { - case 'Escape': - event.preventDefault() - if (currentView === 'home') { - setShow(false) - } else { - setCurrentView('home') - } - break - } - - if ((event.metaKey || event.ctrlKey) && event.key === '/') { - setShow(!show) - } - }, - [currentView, show] - ) - - const handleFocus = () => { - // this is about the focus of the element - // on launch: focus entire element - } - - useEffect(() => { - document.addEventListener('keydown', handleKeyDown) - document.addEventListener('focus', handleFocus) - return () => { - document.removeEventListener('keydown', handleKeyDown) - document.removeEventListener('focus', handleFocus) - } - }, [handleKeyDown]) - - return ( -
- - - {show ? ( - -
- - -
- {showBackButton ? ( - - ) : null} - - {showActions ? ( - MIN_APPS_NUM} - showCommands={commands?.length > 0} - /> - ) : null} -
-
-
- ) : null} - - -
- ) -} - -CommandPalette.propTypes = { - apps: PropTypes.array, - commands: PropTypes.array, -} - -export default CommandPalette 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..6e5569148b --- /dev/null +++ b/components/header-bar/src/command-palette/command-palette.js @@ -0,0 +1,214 @@ +import { clearSensitiveCaches, useConfig } from '@dhis2/app-runtime' +import { colors, spacers } from '@dhis2/ui-constants' +import { + IconApps16, + IconApps24, + IconLogOut16, + IconTerminalWindow16, +} from '@dhis2/ui-icons' +import PropTypes from 'prop-types' +import React, { useState, useCallback, useRef, useEffect } from 'react' +import { joinPath } from '../join-path.js' +import i18n from '../locales/index.js' +import BackButton from './sections/back-button.js' +import Container from './sections/container.js' +import Heading from './sections/heading.js' +import ListItem from './sections/list-item.js' +import Search from './sections/search-field.js' +import HomeView from './views/home-view.js' +import ListView from './views/list-view.js' + +const MIN_APPS_NUM = 8 + +const CommandPalette = ({ apps, commands }) => { + const { baseUrl } = useConfig() + const [show, setShow] = useState(false) + const [filter, setFilter] = useState('') + + const [currentView, setCurrentView] = useState('home') + + const showActions = filter.length <= 0 && currentView === 'home' + const showBackButton = currentView !== 'home' + + const handleVisibilityToggle = useCallback(() => setShow(!show), [show]) + const handleFilterChange = useCallback(({ value }) => setFilter(value), []) + + const handleClearSearch = () => setFilter('') + + const containerEl = useRef(null) + + const handleKeyDown = useCallback( + (event) => { + switch (event.key) { + case 'Escape': + event.preventDefault() + if (currentView === 'home') { + setShow(false) + } else { + setCurrentView('home') + } + break + } + + if ((event.metaKey || event.ctrlKey) && event.key === '/') { + setShow(!show) + } + }, + [currentView, show] + ) + + const handleFocus = () => { + // this is about the focus of the element + // on launch: focus entire element + } + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('focus', handleFocus) + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('focus', handleFocus) + } + }, [handleKeyDown]) + + return ( +
+ + + {show ? ( + +
+ +
+ {showBackButton ? ( + + ) : null} + {/* switch views */} + {currentView === 'apps' && ( + + )} + {currentView === 'commands' && ( + + )} + {currentView === 'home' && ( + + )} + {/* actions sections */} + {showActions ? ( + <> + + {apps?.length > MIN_APPS_NUM ? ( + + } + onClickHandler={() => + setCurrentView('apps') + } + /> + ) : null} + {commands?.length > 0 ? ( + + } + onClickHandler={() => + setCurrentView('commands') + } + /> + ) : null} + + } + onClickHandler={async () => { + await clearSensitiveCaches() + window.location.assign( + joinPath( + baseUrl, + 'dhis-web-commons-security/logout.action' + ) + ) + }} + href={joinPath( + baseUrl, + 'dhis-web-commons-security/logout.action' + )} + /> + + ) : null} +
+
+
+ ) : null} + + +
+ ) +} + +CommandPalette.propTypes = { + apps: PropTypes.array, + commands: PropTypes.array, +} + +export default CommandPalette diff --git a/components/header-bar/src/command-palette/fields.js b/components/header-bar/src/command-palette/fields.js deleted file mode 100644 index 82fe8ca93d..0000000000 --- a/components/header-bar/src/command-palette/fields.js +++ /dev/null @@ -1,387 +0,0 @@ -import { clearSensitiveCaches, useConfig } from '@dhis2/app-runtime' -import { colors, spacers, theme } from '@dhis2/ui-constants' -import { - IconApps16, - IconTerminalWindow16, - IconLogOut16, - IconArrowLeft16, - IconSearch16, -} from '@dhis2/ui-icons' -import PropTypes from 'prop-types' -import React, { useState, useRef, useEffect } from 'react' -import { InputField } from '../../../input/src/input-field/input-field.js' -import { joinPath } from '../join-path.js' -import i18n from '../locales/index.js' - -export function Search({ value, onChange }) { - return ( - <> - } - clearable - /> - - - ) -} - -Search.propTypes = { - value: PropTypes.string.isRequired, - onChange: PropTypes.func.isRequired, -} - -export function AppItem({ name, path, img }) { - return ( - - app logo -
{name}
- -
- ) -} - -AppItem.propTypes = { - img: PropTypes.string, - name: PropTypes.string, - path: PropTypes.string, -} - -export function ListItem({ - title, - path, - icon, - image, - description, - type, - onClickHandler, -}) { - const showDescription = type === 'commands' - return ( - - {icon && {icon}} - {image && logo} -
{title}
- {showDescription && ( -
{description}
- )} - -
- ) -} - -ListItem.propTypes = { - description: PropTypes.string, - icon: PropTypes.node, - image: PropTypes.string, - path: PropTypes.string, - title: PropTypes.string, - type: PropTypes.string, - onClickHandler: PropTypes.func, -} - -export function List({ filteredItems, type }) { - const divRef = useRef(null) - const [activeItem, setActiveItem] = useState(-1) - const lastIndex = filteredItems.length - 1 - - const handleKeyDown = (event) => { - switch (event.key) { - case 'ArrowDown': - setActiveItem(activeItem >= lastIndex ? 0 : activeItem + 1) - break - case 'ArrowUp': - setActiveItem(activeItem > 0 ? activeItem - 1 : lastIndex) - break - case 'Enter': - event.preventDefault() - event.target?.click() - break - } - } - - useEffect(() => { - if (divRef) { - if (filteredItems.length && activeItem > -1) { - divRef.current.children[activeItem].focus() - } - } - }, [activeItem, filteredItems]) - - // useEffect(() => { - // if (!divRef && !divRef.current) {return} - // const div = divRef.current - // div.addEventListener('keydown', handleKeyDown) - // return () => { - // div.removeEventListener('keydown', handleKeyDown) - // } - // }, []) - return ( -
- {filteredItems.map( - ( - { displayName, name, defaultAction, icon, description }, - idx - ) => ( - - ) - )} - - {/* // todo: use list with type/view filter to render correct item component */} - - -
- ) -} -List.propTypes = { - filteredItems: PropTypes.array, - type: PropTypes.string, -} - -export function Actions({ setView, showApps, showCommands }) { - const { baseUrl } = useConfig() - console.log(showApps, showCommands) - - return ( - <> - - - {showApps ? ( - } - onClickHandler={() => setView('apps')} - /> - ) : null} - {showCommands ? ( - } - onClickHandler={() => setView('commands')} - /> - ) : null} - } - onClickHandler={async () => { - await clearSensitiveCaches() - window.location.assign( - joinPath( - baseUrl, - 'dhis-web-commons-security/logout.action' - ) - ) - }} - href={joinPath( - baseUrl, - 'dhis-web-commons-security/logout.action' - )} - /> - - ) -} - -Actions.propTypes = { - setView: PropTypes.func, - showApps: PropTypes.bool, - showCommands: PropTypes.bool, -} - -export function Heading({ filter, filteredItems, heading }) { - return ( -
- - {filter - ? filteredItems.length > 0 - ? i18n.t(`Results for ${filter}`) - : i18n.t(`Nothing found for ${filter}`) - : i18n.t(`${heading}`)} - - -
- ) -} - -Heading.propTypes = { - filter: PropTypes.string, - filteredItems: PropTypes.array, - heading: PropTypes.string, -} - -export function BackButton({ setView, handleClearSearch }) { - const handleClick = () => { - setView('home') - handleClearSearch() - } - return ( - <> - - - - ) -} - -BackButton.propTypes = { - handleClearSearch: PropTypes.func, - setView: PropTypes.func, -} 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..6bb49ed118 --- /dev/null +++ b/components/header-bar/src/command-palette/sections/app-item.js @@ -0,0 +1,53 @@ +import { colors, spacers } from '@dhis2/ui-constants' +import PropTypes from 'prop-types' +import React from 'react' + +function AppItem({ name, path, img }) { + return ( + + app logo +
{name}
+ +
+ ) +} + +AppItem.propTypes = { + 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..80cca36e41 --- /dev/null +++ b/components/header-bar/src/command-palette/sections/back-button.js @@ -0,0 +1,56 @@ +import { colors, spacers } from '@dhis2/ui-constants' +import { IconArrowLeft16 } from '@dhis2/ui-icons' +import PropTypes from 'prop-types' +import React from 'react' + +function BackButton({ setView, handleClearSearch }) { + const handleClick = () => { + setView('home') + handleClearSearch() + } + return ( + <> + + + + ) +} + +BackButton.propTypes = { + handleClearSearch: PropTypes.func, + setView: 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..ae8a5df533 --- /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 Container = ({ children, setShow, show }) => { + return ( + setShow(false)} translucent={show}> +
+ {children} +
+ +
+ ) +} + +Container.propTypes = { + children: PropTypes.node, + setShow: PropTypes.func, + show: PropTypes.bool, +} + +export default Container 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..65288e8b85 --- /dev/null +++ b/components/header-bar/src/command-palette/sections/heading.js @@ -0,0 +1,38 @@ +import { colors, spacers } from '@dhis2/ui-constants' +import PropTypes from 'prop-types' +import React from 'react' +import i18n from '../../locales/index.js' + +function Heading({ filter, filteredItems, heading }) { + return ( +
+ + {filter + ? filteredItems.length > 0 + ? i18n.t(`Results for "${filter}"`) + : i18n.t(`Nothing found for "${filter}"`) + : i18n.t(`${heading}`)} + + +
+ ) +} + +Heading.propTypes = { + filter: PropTypes.string, + filteredItems: PropTypes.array, + 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..d3b7eda61c --- /dev/null +++ b/components/header-bar/src/command-palette/sections/list-item.js @@ -0,0 +1,106 @@ +import { colors, spacers } from '@dhis2/ui-constants' +import PropTypes from 'prop-types' +import React from 'react' + +function ListItem({ + title, + path, + icon, + image, + description, + type, + onClickHandler, +}) { + const showDescription = type === 'commands' + return ( + +
+ {icon && {icon}} + {image && ( + img + )} +
+
+ {title} + {showDescription && ( + {description} + )} +
+ +
+ ) +} + +ListItem.propTypes = { + description: PropTypes.string, + 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..2c91d37db4 --- /dev/null +++ b/components/header-bar/src/command-palette/sections/list.js @@ -0,0 +1,70 @@ +import PropTypes from 'prop-types' +import React, { useState, useRef, useEffect } from 'react' +import ListItem from './list-item.js' + +function List({ filteredItems, type }) { + const divRef = useRef(null) + const [activeItem, setActiveItem] = useState(-1) + const lastIndex = filteredItems.length - 1 + + const handleKeyDown = (event) => { + switch (event.key) { + case 'ArrowDown': + setActiveItem(activeItem >= lastIndex ? 0 : activeItem + 1) + break + case 'ArrowUp': + setActiveItem(activeItem > 0 ? activeItem - 1 : lastIndex) + break + case 'Enter': + event.preventDefault() + event.target?.click() + break + } + } + + useEffect(() => { + if (divRef) { + if (filteredItems.length && activeItem > -1) { + divRef.current.children[activeItem].focus() + } + } + }, [activeItem, filteredItems]) + + return ( +
+ {filteredItems.map( + ( + { displayName, name, defaultAction, icon, description }, + 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..7220575884 --- /dev/null +++ b/components/header-bar/src/command-palette/sections/search-field.js @@ -0,0 +1,49 @@ +import { colors, 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 i18n from '../../locales/index.js' + +function Search({ value, onChange }) { + return ( + <> + } + clearable + /> + + + ) +} + +Search.propTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, +} + +export default Search 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/views.js b/components/header-bar/src/command-palette/views/home-view.js similarity index 65% rename from components/header-bar/src/command-palette/views.js rename to components/header-bar/src/command-palette/views/home-view.js index 4276399360..a713d3f913 100644 --- a/components/header-bar/src/command-palette/views.js +++ b/components/header-bar/src/command-palette/views/home-view.js @@ -1,46 +1,11 @@ import PropTypes from 'prop-types' import React, { useEffect, useRef, useState } from 'react' -import { AppItem, Heading, List } from './fields.js' +import AppItem from '../sections/app-item.js' +import Heading from '../sections/heading.js' +import { escapeRegExpCharacters } from '../utils/escapeCharacters.js' +import ListView from './list-view.js' -/** - * Copied from here: - * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping - */ -function escapeRegExpCharacters(text) { - return text.replace(/[/.*+?^${}()|[\]\\]/g, '\\$&') -} - -export function ListView({ heading, itemsArray, filter, type }) { - const filteredItems = itemsArray.filter(({ displayName, name }) => { - const itemName = displayName || name - const formattedItemName = itemName.toLowerCase() - const formattedFilter = escapeRegExpCharacters(filter).toLowerCase() - - return filter.length > 0 - ? formattedItemName.match(formattedFilter) - : true - }) - - return ( -
- - -
- ) -} - -ListView.propTypes = { - filter: PropTypes.string, - heading: PropTypes.string, - itemsArray: PropTypes.array, - type: PropTypes.string, -} - -export function HomeView({ apps, filter }) { +function HomeView({ apps, filter }) { const divRef = useRef(null) const [activeItem, setActiveItem] = useState(-1) @@ -96,7 +61,7 @@ export function HomeView({ apps, filter }) { } } }, [activeItem, apps.length]) - // filter happens across everything here + // filter happens across everything here - apps, commands, shorcuts const filteredApps = apps.filter(({ displayName, name }) => { const appName = displayName || name const formattedAppName = appName.toLowerCase() @@ -109,13 +74,11 @@ export function HomeView({ apps, filter }) { return (
+ {/* Search results */} {filter.length > 0 && ( - + )} + {/* normal view */} {filter.length < 1 && ( <> - ) - case 'commands': - return ( - - ) - case 'home': - default: - return - } -} - -ViewSwitcher.propTypes = { - apps: PropTypes.array, - commands: PropTypes.array, - filter: PropTypes.string, - view: PropTypes.string, -} +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..9371761a6e --- /dev/null +++ b/components/header-bar/src/command-palette/views/list-view.js @@ -0,0 +1,37 @@ +import PropTypes from 'prop-types' +import React from 'react' +import Heading from '../sections/heading.js' +import List from '../sections/list.js' +import { escapeRegExpCharacters } from '../utils/escapeCharacters.js' + +function ListView({ heading, itemsArray, filter, type }) { + const filteredItems = itemsArray.filter(({ displayName, name }) => { + const itemName = displayName || name + const formattedItemName = itemName.toLowerCase() + const formattedFilter = escapeRegExpCharacters(filter).toLowerCase() + + return filter.length > 0 + ? formattedItemName.match(formattedFilter) + : true + }) + + return ( +
+ + +
+ ) +} + +ListView.propTypes = { + filter: PropTypes.string, + heading: PropTypes.string, + itemsArray: PropTypes.array, + type: PropTypes.string, +} + +export default ListView diff --git a/components/header-bar/src/header-bar.js b/components/header-bar/src/header-bar.js index b0b9ff9eb8..d63cc02a31 100755 --- a/components/header-bar/src/header-bar.js +++ b/components/header-bar/src/header-bar.js @@ -2,7 +2,7 @@ import { useDataQuery, useConfig } from '@dhis2/app-runtime' import { colors } from '@dhis2/ui-constants' import PropTypes from 'prop-types' import React, { useMemo } from 'react' -import CommandPalette from './command-palette/apps.js' +import CommandPalette from './command-palette/command-palette.js' import { HeaderBarContextProvider } from './header-bar-context.js' import { joinPath } from './join-path.js' import i18n from './locales/index.js' From f72dec274c35ab468282c28197404170b6b5ed67 Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Fri, 18 Oct 2024 09:55:12 +0300 Subject: [PATCH 09/21] fix: css adjustments --- .../src/command-palette/command-palette.js | 36 ++++++++++--------- .../src/command-palette/sections/app-item.js | 15 +++++--- .../command-palette/sections/back-button.js | 22 +++++------- .../src/command-palette/sections/heading.js | 2 +- .../command-palette/sections/search-field.js | 26 ++++++++------ .../src/command-palette/views/home-view.js | 13 ++++--- 6 files changed, 64 insertions(+), 50 deletions(-) diff --git a/components/header-bar/src/command-palette/command-palette.js b/components/header-bar/src/command-palette/command-palette.js index 6e5569148b..00c51186d8 100755 --- a/components/header-bar/src/command-palette/command-palette.js +++ b/components/header-bar/src/command-palette/command-palette.js @@ -33,7 +33,10 @@ const CommandPalette = ({ apps, commands }) => { const handleVisibilityToggle = useCallback(() => setShow(!show), [show]) const handleFilterChange = useCallback(({ value }) => setFilter(value), []) - const handleClearSearch = () => setFilter('') + const goToDefaultView = () => { + setFilter('') + setCurrentView('home') + } const containerEl = useRef(null) @@ -45,7 +48,7 @@ const CommandPalette = ({ apps, commands }) => { if (currentView === 'home') { setShow(false) } else { - setCurrentView('home') + goToDefaultView() } break } @@ -57,9 +60,11 @@ const CommandPalette = ({ apps, commands }) => { [currentView, show] ) - const handleFocus = () => { + const handleFocus = (e) => { // this is about the focus of the element // on launch: focus entire element + console.log(e.target, 'e.target') + console.log(document.activeElement, 'active element') } useEffect(() => { @@ -72,7 +77,7 @@ const CommandPalette = ({ apps, commands }) => { }, [handleKeyDown]) return ( -
+
- {show ? (
@@ -84,24 +85,18 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { ) : null} {/* switch views */} {currentView === 'apps' && ( - + )} {currentView === 'commands' && ( - )} {currentView === 'shortcuts' && ( - )} @@ -113,6 +108,7 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { MIN_APPS_NUM} showCommandsList={commands?.length > 0} + showShortcutsList={shortcuts?.length > 0} setCurrentView={setCurrentView} /> )} diff --git a/components/header-bar/src/command-palette/sections/actions-menu.js b/components/header-bar/src/command-palette/sections/actions-menu.js index 6434fe0a64..35da3a5e92 100644 --- a/components/header-bar/src/command-palette/sections/actions-menu.js +++ b/components/header-bar/src/command-palette/sections/actions-menu.js @@ -13,7 +13,12 @@ import i18n from '../../locales/index.js' import Heading from './heading.js' import ListItem from './list-item.js' -const ActionsMenu = ({ showAppsList, showCommandsList, setCurrentView }) => { +const ActionsMenu = ({ + showAppsList, + showCommandsList, + showShortcutsList, + setCurrentView, +}) => { const { baseUrl } = useConfig() return (
@@ -32,11 +37,13 @@ const ActionsMenu = ({ showAppsList, showCommandsList, setCurrentView }) => { onClickHandler={() => setCurrentView('commands')} /> ) : null} - } - onClickHandler={() => setCurrentView('shortcuts')} - /> + {showShortcutsList ? ( + } + onClickHandler={() => setCurrentView('shortcuts')} + /> + ) : null} } @@ -62,6 +69,7 @@ ActionsMenu.propTypes = { setCurrentView: PropTypes.func, showAppsList: PropTypes.bool, showCommandsList: PropTypes.bool, + showShortcutsList: PropTypes.bool, } export default ActionsMenu diff --git a/components/header-bar/src/command-palette/sections/heading.js b/components/header-bar/src/command-palette/sections/heading.js index 33abf363c9..86011cbef5 100644 --- a/components/header-bar/src/command-palette/sections/heading.js +++ b/components/header-bar/src/command-palette/sections/heading.js @@ -1,18 +1,12 @@ import { colors, spacers } from '@dhis2/ui-constants' import PropTypes from 'prop-types' import React from 'react' -import i18n from '../../locales/index.js' -function Heading({ filter, filteredItems, heading }) { +function Heading({ heading }) { return (
- - {filter - ? filteredItems.length > 0 - ? i18n.t(`Results for "${filter}"`) - : i18n.t(`Nothing found for "${filter}"`) - : i18n.t(`${heading}`)} - + {/* role='header' ?*/} + {heading} + + ) +} + +SearchResults.propTypes = { + filter: PropTypes.string, + filteredItems: PropTypes.array, +} + +export default SearchResults From 96ed25e8d75ae93b14e7c6083764041f06730933 Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Mon, 21 Oct 2024 17:02:01 +0300 Subject: [PATCH 13/21] feat: update function to filter items across all views --- .../src/command-palette/command-palette.js | 22 ++++++++++++++----- .../command-palette/utils/filterItemsArray.js | 13 +++++++++++ .../src/command-palette/views/browse-apps.js | 15 ++----------- .../command-palette/views/browse-commands.js | 15 ++----------- .../command-palette/views/browse-shortcuts.js | 15 ++----------- .../src/command-palette/views/home-view.js | 22 ++++++------------- 6 files changed, 43 insertions(+), 59 deletions(-) create mode 100644 components/header-bar/src/command-palette/utils/filterItemsArray.js diff --git a/components/header-bar/src/command-palette/command-palette.js b/components/header-bar/src/command-palette/command-palette.js index 26e436f580..b66e18cc6a 100755 --- a/components/header-bar/src/command-palette/command-palette.js +++ b/components/header-bar/src/command-palette/command-palette.js @@ -6,6 +6,7 @@ import ActionsMenu from './sections/actions-menu.js' import BackButton from './sections/back-button.js' import Container from './sections/container.js' import Search from './sections/search-field.js' +import { filterItemsArray } from './utils/filterItemsArray.js' import BrowseApps from './views/browse-apps.js' import BrowseCommands from './views/browse-commands.js' import BrowseShortcuts from './views/browse-shortcuts.js' @@ -14,6 +15,7 @@ import HomeView from './views/home-view.js' const MIN_APPS_NUM = 8 const CommandPalette = ({ apps, commands, shortcuts }) => { + const containerEl = useRef(null) const [show, setShow] = useState(false) const [filter, setFilter] = useState('') @@ -29,7 +31,9 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { setCurrentView('home') } - const containerEl = useRef(null) + const filteredApps = filterItemsArray(apps, filter) + const filteredCommands = filterItemsArray(commands, filter) + const filteredShortcuts = filterItemsArray(shortcuts, filter) const handleKeyDown = useCallback( (event) => { @@ -85,23 +89,31 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { ) : null} {/* switch views */} {currentView === 'apps' && ( - + )} {currentView === 'commands' && ( )} {currentView === 'shortcuts' && ( )} {currentView === 'home' && ( - + )} {/* actions sections */} {showActions && ( 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/browse-apps.js b/components/header-bar/src/command-palette/views/browse-apps.js index a58b631ec8..4224c849ae 100644 --- a/components/header-bar/src/command-palette/views/browse-apps.js +++ b/components/header-bar/src/command-palette/views/browse-apps.js @@ -3,29 +3,18 @@ import React from 'react' import i18n from '../../locales/index.js' import Heading from '../sections/heading.js' import List from '../sections/list.js' -import { escapeRegExpCharacters } from '../utils/escapeCharacters.js' import SearchResults from './search-results.js' function BrowseApps({ apps, filter }) { - const filteredItems = apps.filter(({ displayName, name }) => { - const itemName = displayName || name - const formattedItemName = itemName.toLowerCase() - const formattedFilter = escapeRegExpCharacters(filter).toLowerCase() - - return filter.length > 0 - ? formattedItemName.match(formattedFilter) - : true - }) - return ( <> {filter.length > 0 && ( - + )} {filter.length < 1 && ( <> - + )} diff --git a/components/header-bar/src/command-palette/views/browse-commands.js b/components/header-bar/src/command-palette/views/browse-commands.js index f3b01281c6..b24dcfbefd 100644 --- a/components/header-bar/src/command-palette/views/browse-commands.js +++ b/components/header-bar/src/command-palette/views/browse-commands.js @@ -3,29 +3,18 @@ import React from 'react' import i18n from '../../locales/index.js' import Heading from '../sections/heading.js' import List from '../sections/list.js' -import { escapeRegExpCharacters } from '../utils/escapeCharacters.js' import SearchResults from './search-results.js' function BrowseCommands({ commands, filter, type }) { - const filteredItems = commands.filter(({ displayName, name }) => { - const itemName = displayName || name - const formattedItemName = itemName.toLowerCase() - const formattedFilter = escapeRegExpCharacters(filter).toLowerCase() - - return filter.length > 0 - ? formattedItemName.match(formattedFilter) - : true - }) - return ( <> {filter.length > 0 && ( - + )} {filter.length < 1 && ( <> - + )} diff --git a/components/header-bar/src/command-palette/views/browse-shortcuts.js b/components/header-bar/src/command-palette/views/browse-shortcuts.js index d50a36b4e5..c86761d24f 100644 --- a/components/header-bar/src/command-palette/views/browse-shortcuts.js +++ b/components/header-bar/src/command-palette/views/browse-shortcuts.js @@ -3,29 +3,18 @@ import React from 'react' import i18n from '../../locales/index.js' import Heading from '../sections/heading.js' import List from '../sections/list.js' -import { escapeRegExpCharacters } from '../utils/escapeCharacters.js' import SearchResults from './search-results.js' function BrowseShortcuts({ shortcuts, filter }) { - const filteredItems = shortcuts.filter(({ displayName, name }) => { - const itemName = displayName || name - const formattedItemName = itemName.toLowerCase() - const formattedFilter = escapeRegExpCharacters(filter).toLowerCase() - - return filter.length > 0 - ? formattedItemName.match(formattedFilter) - : true - }) - return ( <> {filter.length > 0 && ( - + )} {filter.length < 1 && ( <> - + )} 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 b16de7b179..d9fbd67e6e 100644 --- a/components/header-bar/src/command-palette/views/home-view.js +++ b/components/header-bar/src/command-palette/views/home-view.js @@ -3,12 +3,12 @@ import PropTypes from 'prop-types' import React, { useEffect, useRef, useState } from 'react' import AppItem from '../sections/app-item.js' import Heading from '../sections/heading.js' -import { escapeRegExpCharacters } from '../utils/escapeCharacters.js' import SearchResults from './search-results.js' -function HomeView({ apps, filter }) { +function HomeView({ apps, commands, shortcuts, filter }) { const divRef = useRef(null) const [activeItem, setActiveItem] = useState(-1) + const filteredItems = apps.concat(commands, shortcuts) const handleKeyDown = (event) => { switch (event.key) { @@ -62,21 +62,11 @@ function HomeView({ apps, filter }) { } } }, [activeItem, apps.length]) - // filter happens across everything here - apps, commands, shorcuts - const filteredApps = 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 - }) return (
{filter.length > 0 && ( - + )} {/* normal view */} {filter.length < 1 && ( @@ -87,8 +77,8 @@ function HomeView({ apps, filter }) { ref={divRef} className="headerbar-top-apps" > - {filteredApps.length > 0 && - filteredApps + {apps.length > 0 && + apps .slice(0, 8) .map( ( @@ -125,7 +115,9 @@ function HomeView({ apps, filter }) { HomeView.propTypes = { apps: PropTypes.array, + commands: PropTypes.array, filter: PropTypes.string, + shortcuts: PropTypes.array, } export default HomeView From 284e284fe03fe7102633ce4e018f8873580cc8e6 Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Mon, 21 Oct 2024 17:05:34 +0300 Subject: [PATCH 14/21] feat: switch search field placeholder depending on the view --- .../src/command-palette/command-palette.js | 17 ++++++++++++++++- .../command-palette/sections/search-field.js | 6 +++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/components/header-bar/src/command-palette/command-palette.js b/components/header-bar/src/command-palette/command-palette.js index b66e18cc6a..9722579754 100755 --- a/components/header-bar/src/command-palette/command-palette.js +++ b/components/header-bar/src/command-palette/command-palette.js @@ -2,6 +2,7 @@ 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 ActionsMenu from './sections/actions-menu.js' import BackButton from './sections/back-button.js' import Container from './sections/container.js' @@ -82,7 +83,21 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { {show ? (
- +
{currentView !== 'home' && !filter ? ( 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 3ee529fe08..0d5ff1f769 100644 --- a/components/header-bar/src/command-palette/sections/search-field.js +++ b/components/header-bar/src/command-palette/sections/search-field.js @@ -3,15 +3,14 @@ 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 i18n from '../../locales/index.js' -function Search({ value, onChange }) { +function Search({ value, onChange, placeholder }) { return ( <> Date: Fri, 25 Oct 2024 10:46:13 +0300 Subject: [PATCH 15/21] chore: refactor and add unit tests for command palette component --- .../__tests__/command-palette.test.js | 278 ++++++++++++++++++ .../src/command-palette/command-palette.js | 6 +- .../command-palette/sections/actions-menu.js | 10 +- .../src/command-palette/sections/container.js | 6 +- .../src/command-palette/sections/list-item.js | 10 +- .../src/command-palette/sections/list.js | 8 +- .../command-palette/sections/search-field.js | 1 + .../src/command-palette/views/home-view.js | 42 ++- .../command-palette/views/search-results.js | 9 +- components/header-bar/src/header-bar.js | 12 +- 10 files changed, 334 insertions(+), 48 deletions(-) create mode 100644 components/header-bar/src/command-palette/__tests__/command-palette.test.js 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..7a4d318f9d --- /dev/null +++ b/components/header-bar/src/command-palette/__tests__/command-palette.test.js @@ -0,0 +1,278 @@ +import { render } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import React from 'react' +import CommandPalette from '../command-palette.js' + +describe('Command Palette Component', () => { + const headerBarIconTest = 'headerbar-apps-icon' + const modalTest = 'headerbar-menu' + + const apps = new Array(9).fill({ + name: 'Test App', + displayName: 'Test App', + icon: '', + defaultAction: '', + }) + + const commands = [ + { + name: 'Test Command', + displayName: 'Test Command', + icon: '', + defaultAction: '', + }, + ] + + const shortcuts = [ + { + name: 'Test Shortcut', + displayName: 'Test Shortcut', + icon: '', + defaultAction: '', + }, + ] + + it('renders bare default view when Command Palette is opened', () => { + const { getByTestId, queryByTestId, getByPlaceholderText } = render( + + ) + + // modal not rendered yet + expect(queryByTestId(modalTest)).not.toBeInTheDocument() + + const headerBarIcon = getByTestId(headerBarIconTest) + userEvent.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 < 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() + expect(queryByTestId('headerbar-logout')).toBeInTheDocument() + + // click outside modal + userEvent.click(headerBarIcon) + expect(queryByTestId(modalTest)).not.toBeInTheDocument() + }) + + it('opens and closes Command Palette using meta/ctrl + /', () => { + const { queryByTestId } = render( + + ) + // modal not rendered yet + expect(queryByTestId(modalTest)).not.toBeInTheDocument() + + // ctrl + / + // open modal + userEvent.keyboard('{ctrl}/') + expect(queryByTestId(modalTest)).toBeInTheDocument() + // close modal + userEvent.keyboard('{ctrl}/') + expect(queryByTestId(modalTest)).not.toBeInTheDocument() + }) + + it('closes Command Palette using Esc key', () => { + const { queryByTestId } = render( + + ) + // modal not rendered yet + expect(queryByTestId(modalTest)).not.toBeInTheDocument() + + // open modal + userEvent.keyboard('{ctrl}/') + expect(queryByTestId(modalTest)).toBeInTheDocument() + // Esc key closes the modal + userEvent.keyboard('{esc}') + expect(queryByTestId(modalTest)).not.toBeInTheDocument() + }) + + it('shows the full default view upon opening the Command Palette', () => { + const { + getByTestId, + queryByTestId, + getAllByText, + getByPlaceholderText, + queryByText, + getAllByRole, + } = render( + + ) + + userEvent.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 > 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() + expect(queryByTestId('headerbar-logout')).toBeInTheDocument() + + // search for command + userEvent.type(searchField, 'Command') + expect(searchField).toHaveValue('Command') + + expect(queryByTestId('headerbar-top-apps-list')).not.toBeInTheDocument() + expect(queryByTestId('headerbar-search-results')).toBeInTheDocument() + expect(queryByText(/Results for "Command"/i)).toBeInTheDocument() + expect(queryByText(/Test Command/)).toBeInTheDocument() + expect(queryByText(/Test App/)).not.toBeInTheDocument() + + // clear field + const clearButton = getAllByRole('button')[1] + userEvent.click(clearButton) + expect(searchField).toHaveValue('') + expect( + queryByTestId('headerbar-search-results') + ).not.toBeInTheDocument() + }) + + it('renders Browse Apps View', () => { + const { + getByTestId, + queryByTestId, + getByPlaceholderText, + queryByText, + getByLabelText, + queryAllByText, + } = render() + // open command palette + userEvent.click(getByTestId(headerBarIconTest)) + + expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument() + userEvent.click(getByTestId('headerbar-browse-apps')) + + // Browse Apps View + // Search field + const searchField = getByPlaceholderText('Search apps') + expect(searchField).toHaveValue('') + + const backButton = getByLabelText('Back Button') + expect(backButton).toBeInTheDocument() + + expect(queryByText(/All Apps/i)).toBeInTheDocument() + expect(queryAllByText(/Test App/)).toHaveLength(9) + + // go back to default view + userEvent.click(backButton) + expect(queryByText(/All Apps/i)).not.toBeInTheDocument() + expect(queryByText(/Top Apps/i)).toBeInTheDocument() + }) + + it('renders Browse Commands View', () => { + const { + getByTestId, + queryByTestId, + getByPlaceholderText, + queryByText, + getByLabelText, + } = render( + + ) + // open command palette + userEvent.click(getByTestId(headerBarIconTest)) + + expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument() + userEvent.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() + expect(queryByText(/Test Command/)).toBeInTheDocument() + + // Esc key goes back to default view + userEvent.keyboard('{esc}') + expect(queryByText(/All Commands/i)).not.toBeInTheDocument() + expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument() + }) + + it('renders Browse Shortcuts View', () => { + const { + getByTestId, + queryByTestId, + getByPlaceholderText, + queryByText, + getByLabelText, + } = render( + + ) + // open command palette + userEvent.click(getByTestId(headerBarIconTest)) + + expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument() + userEvent.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() + expect(queryByText(/Test Shortcut/)).toBeInTheDocument() + }) + + it('shows empty search results if no match is made', () => { + const { + getByTestId, + getByPlaceholderText, + queryByText, + queryByTestId, + } = render( + + ) + // open command palette + userEvent.click(getByTestId(headerBarIconTest)) + + // Search field + const searchField = getByPlaceholderText( + 'Search apps, shortcuts, commands' + ) + expect(searchField).toHaveValue('') + + userEvent.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 index 9722579754..d71f78578d 100755 --- a/components/header-bar/src/command-palette/command-palette.js +++ b/components/header-bar/src/command-palette/command-palette.js @@ -5,7 +5,7 @@ import React, { useState, useCallback, useRef, useEffect } from 'react' import i18n from '../locales/index.js' import ActionsMenu from './sections/actions-menu.js' import BackButton from './sections/back-button.js' -import Container from './sections/container.js' +import ModalContainer from './sections/container.js' import Search from './sections/search-field.js' import { filterItemsArray } from './utils/filterItemsArray.js' import BrowseApps from './views/browse-apps.js' @@ -81,7 +81,7 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { {show ? ( - +
{ )}
- + ) : null}
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..a237680377 --- /dev/null +++ b/components/header-bar/src/command-palette/context/command-palette-context.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types' +import React, { createContext, useContext, useState } from 'react' + +const commandPaletteContext = createContext() + +export const CommandPaletteContextProvider = ({ children }) => { + const [filter, setFilter] = useState('') + const [highlightedIndex, setHighlightedIndex] = useState(0) + const [currentView, setCurrentView] = useState('home') + // home view sections + const [activeSection, setActiveSection] = useState('grid') + + return ( + + {children} + + ) +} +CommandPaletteContextProvider.propTypes = { + children: PropTypes.node, +} + +export const useCommandPaletteContext = () => useContext(commandPaletteContext) diff --git a/components/header-bar/src/command-palette/hooks/use-filter.js b/components/header-bar/src/command-palette/hooks/use-filter.js new file mode 100644 index 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..d584dc857a --- /dev/null +++ b/components/header-bar/src/command-palette/hooks/use-navigation.js @@ -0,0 +1,219 @@ +import { useCallback, useRef } from 'react' +import { useCommandPaletteContext } from '../context/command-palette-context.js' + +export const GRID_ITEMS_LENGTH = 8 + +export const useNavigation = ({ setShow, itemsArray, show }) => { + const modalRef = useRef(null) + const { + activeSection, + currentView, + filter, + highlightedIndex, + setHighlightedIndex, + setFilter, + setCurrentView, + setActiveSection, + } = useCommandPaletteContext() + + const goToDefaultView = useCallback(() => { + setFilter('') + setCurrentView('home') + setActiveSection('grid') + setHighlightedIndex(0) + }, [setActiveSection, setCurrentView, setFilter, setHighlightedIndex]) + + const handleListViewNavigation = useCallback( + (event) => { + const lastIndex = itemsArray.length - 1 + switch (event.key) { + case 'ArrowDown': + setHighlightedIndex( + highlightedIndex >= lastIndex ? 0 : highlightedIndex + 1 + ) + break + case 'ArrowUp': + setHighlightedIndex( + highlightedIndex > 0 ? highlightedIndex - 1 : lastIndex + ) + break + case 'Escape': + event.preventDefault() + goToDefaultView() + break + default: + break + } + }, + [ + goToDefaultView, + highlightedIndex, + itemsArray.length, + setHighlightedIndex, + ] + ) + + const handleHomeViewNavigation = useCallback( + (event) => { + // grid + const gridRowLength = GRID_ITEMS_LENGTH / 2 + const topRowLastIndex = gridRowLength - 1 + const lastRowFirstIndex = gridRowLength + const lastRowLastIndex = GRID_ITEMS_LENGTH - 1 + + switch (event.key) { + case 'ArrowLeft': + if (activeSection === 'grid') { + // row 1 + if (highlightedIndex <= topRowLastIndex) { + setHighlightedIndex( + highlightedIndex > 0 + ? highlightedIndex - 1 + : topRowLastIndex + ) + } + // row 2 + if (highlightedIndex >= lastRowFirstIndex) { + setHighlightedIndex( + highlightedIndex > lastRowFirstIndex + ? highlightedIndex - 1 + : lastRowLastIndex + ) + } + } + break + case 'ArrowRight': + if (activeSection === 'grid') { + // row 1 + if (highlightedIndex <= topRowLastIndex) { + setHighlightedIndex( + highlightedIndex >= topRowLastIndex + ? 0 + : highlightedIndex + 1 + ) + } + // row 2 + if (highlightedIndex >= lastRowFirstIndex) { + setHighlightedIndex( + highlightedIndex >= lastRowLastIndex + ? lastRowFirstIndex + : highlightedIndex + 1 + ) + } + } + break + case 'ArrowDown': + if (activeSection === 'grid') { + if (highlightedIndex >= lastRowFirstIndex) { + setActiveSection('actions') + setHighlightedIndex(0) + } else { + setHighlightedIndex( + highlightedIndex + gridRowLength + ) + } + } else if (activeSection === 'actions') { + if (highlightedIndex >= 3) { + setActiveSection('grid') + setHighlightedIndex(0) + } else { + setHighlightedIndex(highlightedIndex + 1) + } + } + break + case 'ArrowUp': + if (activeSection === 'grid') { + if (highlightedIndex < lastRowFirstIndex) { + setActiveSection('actions') + setHighlightedIndex(3) + } else { + setHighlightedIndex( + highlightedIndex - gridRowLength + ) + } + } else if (activeSection === 'actions') { + if (highlightedIndex <= 0) { + setActiveSection('grid') + setHighlightedIndex(lastRowFirstIndex) + } else { + setHighlightedIndex(highlightedIndex - 1) + } + } + break + case 'Escape': + event.preventDefault() + setShow(false) + setActiveSection('grid') + setHighlightedIndex(0) + break + default: + break + } + }, + [ + activeSection, + highlightedIndex, + setActiveSection, + setHighlightedIndex, + setShow, + ] + ) + + const handleKeyDown = useCallback( + (event) => { + const modal = modalRef.current + + if (currentView === 'home') { + if (filter.length > 0) { + // search mode + handleListViewNavigation(event) + } else { + handleHomeViewNavigation(event) + } + } else { + setActiveSection(null) + handleListViewNavigation(event) + } + + if ((event.metaKey || event.ctrlKey) && event.key === '/') { + setShow(!show) + goToDefaultView() + } + + if (event.key === 'Enter') { + if (activeSection === 'grid') { + window.open(itemsArray[highlightedIndex]?.['defaultAction']) + } else if (activeSection === 'actions') { + modal + ?.querySelector('.actions-menu') + ?.childNodes?.[highlightedIndex]?.click() + } else { + // open apps, shortcuts link + window.open(itemsArray[highlightedIndex]?.['defaultAction']) + // TODO: execute commands + } + } + }, + [ + activeSection, + currentView, + filter.length, + goToDefaultView, + handleHomeViewNavigation, + handleListViewNavigation, + highlightedIndex, + itemsArray, + setActiveSection, + setShow, + show, + ] + ) + + return { + handleKeyDown, + goToDefaultView, + modalRef, + activeSection, + setActiveSection, + } +} diff --git a/components/header-bar/src/command-palette/sections/actions-menu.js b/components/header-bar/src/command-palette/sections/actions-menu.js deleted file mode 100644 index a96d3863ad..0000000000 --- a/components/header-bar/src/command-palette/sections/actions-menu.js +++ /dev/null @@ -1,83 +0,0 @@ -import { clearSensitiveCaches, useConfig } from '@dhis2/app-runtime' -import { colors } from '@dhis2/ui-constants' -import { - IconApps16, - IconLogOut16, - IconRedo16, - IconTerminalWindow16, -} from '@dhis2/ui-icons' -import PropTypes from 'prop-types' -import React from 'react' -import { joinPath } from '../../join-path.js' -import i18n from '../../locales/index.js' -import Heading from './heading.js' -import ListItem from './list-item.js' - -const ActionsMenu = ({ - showAppsList, - showCommandsList, - showShortcutsList, - setCurrentView, -}) => { - const { baseUrl } = useConfig() - return ( -
- - {showAppsList ? ( - } - onClickHandler={() => setCurrentView('apps')} - dataTest="headerbar-browse-apps" - /> - ) : null} - {showCommandsList ? ( - } - onClickHandler={() => setCurrentView('commands')} - dataTest="headerbar-browse-commands" - /> - ) : null} - {showShortcutsList ? ( - } - onClickHandler={() => setCurrentView('shortcuts')} - dataTest="headerbar-browse-shortcuts" - /> - ) : null} - } - onClickHandler={async () => { - await clearSensitiveCaches() - window.location.assign( - joinPath( - baseUrl, - 'dhis-web-commons-security/logout.action' - ) - ) - }} - href={joinPath( - baseUrl, - 'dhis-web-commons-security/logout.action' - )} - dataTest="headerbar-logout" - /> -
- ) -} - -ActionsMenu.propTypes = { - setCurrentView: PropTypes.func, - showAppsList: PropTypes.bool, - showCommandsList: PropTypes.bool, - showShortcutsList: PropTypes.bool, -} - -export default ActionsMenu diff --git a/components/header-bar/src/command-palette/sections/app-item.js b/components/header-bar/src/command-palette/sections/app-item.js index 519ea85c80..a258d346ab 100644 --- a/components/header-bar/src/command-palette/sections/app-item.js +++ b/components/header-bar/src/command-palette/sections/app-item.js @@ -1,10 +1,16 @@ import { colors, spacers } from '@dhis2/ui-constants' +import cx from 'classnames' import PropTypes from 'prop-types' import React from 'react' -function AppItem({ name, path, img }) { +function AppItem({ name, path, img, highlighted, handleMouseEnter }) { return ( -
+ app {name}
) } 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 cd4a511daf..6ea20c4cc9 100644 --- a/components/header-bar/src/command-palette/sections/search-field.js +++ b/components/header-bar/src/command-palette/sections/search-field.js @@ -21,9 +21,7 @@ function Search({ value, onChange, placeholder }) { /> + + ) +} + +export default EmptySearchResults diff --git a/components/header-bar/src/command-palette/views/browse-apps.js b/components/header-bar/src/command-palette/views/browse-apps.js deleted file mode 100644 index 4224c849ae..0000000000 --- a/components/header-bar/src/command-palette/views/browse-apps.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types' -import React from 'react' -import i18n from '../../locales/index.js' -import Heading from '../sections/heading.js' -import List from '../sections/list.js' -import SearchResults from './search-results.js' - -function BrowseApps({ apps, filter }) { - return ( - <> - {filter.length > 0 && ( - - )} - {filter.length < 1 && ( - <> - - - - )} - - ) -} - -BrowseApps.propTypes = { - apps: PropTypes.array, - filter: PropTypes.string, -} - -export default BrowseApps diff --git a/components/header-bar/src/command-palette/views/browse-commands.js b/components/header-bar/src/command-palette/views/browse-commands.js deleted file mode 100644 index b24dcfbefd..0000000000 --- a/components/header-bar/src/command-palette/views/browse-commands.js +++ /dev/null @@ -1,30 +0,0 @@ -import PropTypes from 'prop-types' -import React from 'react' -import i18n from '../../locales/index.js' -import Heading from '../sections/heading.js' -import List from '../sections/list.js' -import SearchResults from './search-results.js' - -function BrowseCommands({ commands, filter, type }) { - return ( - <> - {filter.length > 0 && ( - - )} - {filter.length < 1 && ( - <> - - - - )} - - ) -} - -BrowseCommands.propTypes = { - commands: PropTypes.array, - filter: PropTypes.string, - type: PropTypes.string, -} - -export default BrowseCommands diff --git a/components/header-bar/src/command-palette/views/browse-shortcuts.js b/components/header-bar/src/command-palette/views/browse-shortcuts.js deleted file mode 100644 index c86761d24f..0000000000 --- a/components/header-bar/src/command-palette/views/browse-shortcuts.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types' -import React from 'react' -import i18n from '../../locales/index.js' -import Heading from '../sections/heading.js' -import List from '../sections/list.js' -import SearchResults from './search-results.js' - -function BrowseShortcuts({ shortcuts, filter }) { - return ( - <> - {filter.length > 0 && ( - - )} - {filter.length < 1 && ( - <> - - - - )} - - ) -} - -BrowseShortcuts.propTypes = { - filter: PropTypes.string, - shortcuts: PropTypes.array, -} - -export default BrowseShortcuts diff --git a/components/header-bar/src/command-palette/views/home-view.js b/components/header-bar/src/command-palette/views/home-view.js index d261884c21..33b03da76d 100644 --- a/components/header-bar/src/command-palette/views/home-view.js +++ b/components/header-bar/src/command-palette/views/home-view.js @@ -1,116 +1,189 @@ -import { spacers } from '@dhis2/ui-constants' +import { clearSensitiveCaches, useConfig } from '@dhis2/app-runtime' +import { spacers, colors } from '@dhis2/ui-constants' +import { + IconApps16, + IconLogOut16, + IconRedo16, + IconTerminalWindow16, +} from '@dhis2/ui-icons' import PropTypes from 'prop-types' -import React, { useEffect, useRef, useState } from 'react' +import React from 'react' +import { joinPath } from '../../join-path.js' +import i18n from '../../locales/index.js' +import { useCommandPaletteContext } from '../context/command-palette-context.js' +import { GRID_ITEMS_LENGTH } from '../hooks/use-navigation.js' import AppItem from '../sections/app-item.js' import Heading from '../sections/heading.js' -import SearchResults from './search-results.js' +import ListItem from '../sections/list-item.js' +import ListView from './list-view.js' -function HomeView({ apps, commands, shortcuts, filter }) { - const divRef = useRef(null) - const [activeItem, setActiveItem] = useState(-1) - const filteredItems = apps.concat(commands, shortcuts) - - const handleKeyDown = (event) => { - switch (event.key) { - case 'ArrowLeft': - // row 1 - if (activeItem <= 3) { - setActiveItem(activeItem > 0 ? activeItem - 1 : 3) - } - // row 2 - if (activeItem >= 4) { - setActiveItem(activeItem > 4 ? activeItem - 1 : 7) - } - break - case 'ArrowRight': - // row 1 - if (activeItem <= 3) { - setActiveItem(activeItem >= 3 ? 0 : activeItem + 1) - } - // row 2 - if (activeItem >= 4) { - setActiveItem(activeItem >= 7 ? 4 : activeItem + 1) - } - break - case 'ArrowDown': - setActiveItem(activeItem >= 4 ? activeItem - 4 : activeItem + 4) - break - case 'ArrowUp': - setActiveItem(activeItem <= 3 ? activeItem + 4 : activeItem - 4) - break - case 'Enter': - event.preventDefault() - event.target?.click() - break - case 'Tab': - event.preventDefault() - } - } - - const handleFocus = () => { - if (divRef) { - if (activeItem <= -1) { - setActiveItem(0) - } - } - } - - useEffect(() => { - if (divRef) { - if (apps.length && activeItem > -1) { - divRef.current?.children[activeItem]?.focus() - } - } - }, [activeItem, apps.length]) +const MIN_APPS_NUM = GRID_ITEMS_LENGTH +function HomeView({ apps, commands, shortcuts }) { + const { baseUrl } = useConfig() + const { + filter, + setCurrentView, + highlightedIndex, + setHighlightedIndex, + activeSection, + setActiveSection, + } = useCommandPaletteContext() + const filteredItems = apps.concat(commands, shortcuts) + const topApps = apps?.slice(0, 8) return ( -
- {filter.length > 0 && ( - - )} - {/* normal view */} - {filter.length < 1 && apps.length > 0 && ( + <> + {filter.length > 0 ? ( + + ) : ( <> - + {apps.length > 0 && ( + <> + +
+ {topApps.map( + ( + { + displayName, + name, + defaultAction, + icon, + }, + idx + ) => ( + { + setActiveSection('grid') + setHighlightedIndex(idx) + }} + /> + ) + )} + +
+ + )} + {/* actions menu */} +
- {apps - .slice(0, 8) - .map( - ( - { displayName, name, defaultAction, icon }, - idx - ) => ( - MIN_APPS_NUM ? ( + } + onClickHandler={() => { + setCurrentView('apps') + setHighlightedIndex(0) + }} + dataTest="headerbar-browse-apps" + highlighted={ + activeSection === 'actions' && + highlightedIndex === 0 + } + handleMouseEnter={() => { + setActiveSection('actions') + setHighlightedIndex(0) + }} + /> + ) : null} + {commands?.length > 0 ? ( + + } + onClickHandler={() => { + setCurrentView('commands') + setHighlightedIndex(0) + }} + dataTest="headerbar-browse-commands" + highlighted={ + activeSection === 'actions' && + highlightedIndex === 1 + } + handleMouseEnter={() => { + setActiveSection('actions') + setHighlightedIndex(1) + }} + /> + ) : null} + {shortcuts?.length > 0 ? ( + } + onClickHandler={() => { + setCurrentView('shortcuts') + setHighlightedIndex(0) + }} + dataTest="headerbar-browse-shortcuts" + highlighted={ + activeSection === 'actions' && + highlightedIndex === 2 + } + handleMouseEnter={() => { + setActiveSection('actions') + setHighlightedIndex(2) + }} + /> + ) : null} + } + onClickHandler={async () => { + await clearSensitiveCaches() + window.location.assign( + joinPath( + baseUrl, + 'dhis-web-commons-security/logout.action' + ) ) + }} + href={joinPath( + baseUrl, + 'dhis-web-commons-security/logout.action' )} - - + handleMouseEnter={() => { + setActiveSection('actions') + setHighlightedIndex(3) + }} + />
)} -
+ ) } HomeView.propTypes = { apps: PropTypes.array, commands: PropTypes.array, - filter: PropTypes.string, shortcuts: PropTypes.array, } diff --git a/components/header-bar/src/command-palette/views/list-view.js b/components/header-bar/src/command-palette/views/list-view.js index 9371761a6e..d62882ebbd 100644 --- a/components/header-bar/src/command-palette/views/list-view.js +++ b/components/header-bar/src/command-palette/views/list-view.js @@ -1,36 +1,64 @@ import PropTypes from 'prop-types' import React from 'react' +import i18n from '../../locales/index.js' +import { useCommandPaletteContext } from '../context/command-palette-context.js' import Heading from '../sections/heading.js' import List from '../sections/list.js' -import { escapeRegExpCharacters } from '../utils/escapeCharacters.js' +import { EmptySearchResults } from '../sections/search-results.js' -function ListView({ heading, itemsArray, filter, type }) { - const filteredItems = itemsArray.filter(({ displayName, name }) => { - const itemName = displayName || name - const formattedItemName = itemName.toLowerCase() - const formattedFilter = escapeRegExpCharacters(filter).toLowerCase() +export function BrowseApps({ apps }) { + return +} - return filter.length > 0 - ? formattedItemName.match(formattedFilter) - : true - }) +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 = { - filter: PropTypes.string, + filteredItems: PropTypes.array, heading: PropTypes.string, - itemsArray: PropTypes.array, type: PropTypes.string, } diff --git a/components/header-bar/src/command-palette/views/search-results.js b/components/header-bar/src/command-palette/views/search-results.js deleted file mode 100644 index 892b79d3a5..0000000000 --- a/components/header-bar/src/command-palette/views/search-results.js +++ /dev/null @@ -1,47 +0,0 @@ -import { colors } from '@dhis2/ui-constants' -import PropTypes from 'prop-types' -import React from 'react' -import i18n from '../../locales/index.js' -import Heading from '../sections/heading.js' -import List from '../sections/list.js' - -function SearchResults({ filter, filteredItems }) { - return ( - <> - {filteredItems.length > 0 ? ( -
- - -
- ) : ( -
- -
- )} - - - ) -} - -SearchResults.propTypes = { - filter: PropTypes.string, - filteredItems: PropTypes.array, -} - -export default SearchResults diff --git a/components/header-bar/src/header-bar.js b/components/header-bar/src/header-bar.js index 1a2854046b..47271f93d6 100755 --- a/components/header-bar/src/header-bar.js +++ b/components/header-bar/src/header-bar.js @@ -3,6 +3,7 @@ import { colors } from '@dhis2/ui-constants' import PropTypes from 'prop-types' import React, { useMemo } from 'react' import CommandPalette from './command-palette/command-palette.js' +import { CommandPaletteContextProvider } from './command-palette/context/command-palette-context.js' import { HeaderBarContextProvider } from './header-bar-context.js' import { joinPath } from './join-path.js' import i18n from './locales/index.js' @@ -128,12 +129,13 @@ export const HeaderBar = ({ } userAuthorities={data.user.authorities} /> - - + + + Date: Wed, 6 Nov 2024 03:08:25 +0300 Subject: [PATCH 18/21] feat: handle navigation of actions menu items - Retrieve respective actions based on the availability of apps, shortcuts, and commands - Track active index of the highlighted action from the dynamically rendered actions menu - Only show and select grid as active section if apps are available - Highlight first item in the list across all views, i.e. full lists, and search results --- .../src/command-palette/command-palette.js | 6 + .../context/command-palette-context.js | 2 +- .../src/command-palette/hooks/use-actions.js | 49 ++++ .../command-palette/hooks/use-navigation.js | 245 ++++++++++-------- .../src/command-palette/views/home-view.js | 139 ++++------ 5 files changed, 243 insertions(+), 198 deletions(-) create mode 100644 components/header-bar/src/command-palette/hooks/use-actions.js diff --git a/components/header-bar/src/command-palette/command-palette.js b/components/header-bar/src/command-palette/command-palette.js index b07a24238a..d1da6c2795 100755 --- a/components/header-bar/src/command-palette/command-palette.js +++ b/components/header-bar/src/command-palette/command-palette.js @@ -4,6 +4,7 @@ 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' @@ -27,6 +28,8 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { [setFilter] ) + const actionsArray = useAvailableActions({ apps, shortcuts, commands }) + const { filteredApps, filteredCommands, @@ -38,6 +41,8 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { setShow, itemsArray: currentViewItemsArray, show, + showGrid: apps?.length > 0, + actionsLength: actionsArray?.length, }) useEffect(() => { @@ -106,6 +111,7 @@ const CommandPalette = ({ apps, commands, shortcuts }) => { apps={filteredApps} commands={filteredCommands} shortcuts={filteredShortcuts} + actions={actionsArray} /> )} {currentView === 'apps' && ( 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 a237680377..b465ae4f04 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 @@ -8,7 +8,7 @@ export const CommandPaletteContextProvider = ({ children }) => { const [highlightedIndex, setHighlightedIndex] = useState(0) const [currentView, setCurrentView] = useState('home') // home view sections - const [activeSection, setActiveSection] = useState('grid') + const [activeSection, setActiveSection] = useState(null) return ( { + 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-navigation.js b/components/header-bar/src/command-palette/hooks/use-navigation.js index d584dc857a..2068e66b91 100644 --- a/components/header-bar/src/command-palette/hooks/use-navigation.js +++ b/components/header-bar/src/command-palette/hooks/use-navigation.js @@ -1,10 +1,18 @@ -import { useCallback, useRef } from 'react' +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 }) => { +export const useNavigation = ({ + setShow, + itemsArray, + show, + showGrid, + actionsLength, +}) => { const modalRef = useRef(null) + const { activeSection, currentView, @@ -16,16 +24,29 @@ export const useNavigation = ({ setShow, itemsArray, show }) => { 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('grid') + setActiveSection(defaultSection) setHighlightedIndex(0) - }, [setActiveSection, setCurrentView, setFilter, setHighlightedIndex]) + }, [ + setActiveSection, + setCurrentView, + setFilter, + setHighlightedIndex, + defaultSection, + ]) const handleListViewNavigation = useCallback( - (event) => { - const lastIndex = itemsArray.length - 1 + ({ event, listLength }) => { + const lastIndex = listLength - 1 switch (event.key) { case 'ArrowDown': setHighlightedIndex( @@ -45,113 +66,124 @@ export const useNavigation = ({ setShow, itemsArray, show }) => { break } }, - [ - goToDefaultView, - highlightedIndex, - itemsArray.length, - setHighlightedIndex, - ] + [goToDefaultView, highlightedIndex, setHighlightedIndex] ) const handleHomeViewNavigation = useCallback( (event) => { // grid - const gridRowLength = GRID_ITEMS_LENGTH / 2 - const topRowLastIndex = gridRowLength - 1 - const lastRowFirstIndex = gridRowLength - const lastRowLastIndex = GRID_ITEMS_LENGTH - 1 + const gridRowLength = GRID_ITEMS_LENGTH / 2 // 4 + const topRowLastIndex = gridRowLength - 1 // 3 + const lastRowFirstIndex = gridRowLength // 4 + const lastRowLastIndex = GRID_ITEMS_LENGTH - 1 // 7 - switch (event.key) { - case 'ArrowLeft': - if (activeSection === 'grid') { - // row 1 - if (highlightedIndex <= topRowLastIndex) { - setHighlightedIndex( - highlightedIndex > 0 - ? highlightedIndex - 1 - : topRowLastIndex - ) - } - // row 2 - if (highlightedIndex >= lastRowFirstIndex) { - setHighlightedIndex( - highlightedIndex > lastRowFirstIndex - ? highlightedIndex - 1 - : lastRowLastIndex - ) - } - } - break - case 'ArrowRight': - if (activeSection === 'grid') { - // row 1 - if (highlightedIndex <= topRowLastIndex) { - setHighlightedIndex( - highlightedIndex >= topRowLastIndex - ? 0 - : highlightedIndex + 1 - ) - } - // row 2 - if (highlightedIndex >= lastRowFirstIndex) { - setHighlightedIndex( - highlightedIndex >= lastRowLastIndex - ? lastRowFirstIndex - : highlightedIndex + 1 - ) - } - } - break - case 'ArrowDown': - if (activeSection === 'grid') { - if (highlightedIndex >= lastRowFirstIndex) { - setActiveSection('actions') - setHighlightedIndex(0) - } else { - setHighlightedIndex( - highlightedIndex + gridRowLength - ) + // actions + const lastActionIndex = actionsLength - 1 + + if (showGrid) { + switch (event.key) { + case 'ArrowLeft': + if (activeSection === 'grid') { + // row 1 + if (highlightedIndex <= topRowLastIndex) { + setHighlightedIndex( + highlightedIndex > 0 + ? highlightedIndex - 1 + : topRowLastIndex + ) + } + // row 2 + if (highlightedIndex >= lastRowFirstIndex) { + setHighlightedIndex( + highlightedIndex > lastRowFirstIndex + ? highlightedIndex - 1 + : lastRowLastIndex + ) + } } - } else if (activeSection === 'actions') { - if (highlightedIndex >= 3) { - setActiveSection('grid') - setHighlightedIndex(0) - } else { - setHighlightedIndex(highlightedIndex + 1) + break + case 'ArrowRight': + if (activeSection === 'grid') { + // row 1 + if (highlightedIndex <= topRowLastIndex) { + setHighlightedIndex( + highlightedIndex >= topRowLastIndex + ? 0 + : highlightedIndex + 1 + ) + } + // row 2 + if (highlightedIndex >= lastRowFirstIndex) { + setHighlightedIndex( + highlightedIndex >= lastRowLastIndex + ? lastRowFirstIndex + : highlightedIndex + 1 + ) + } } - } - break - case 'ArrowUp': - if (activeSection === 'grid') { - if (highlightedIndex < lastRowFirstIndex) { - setActiveSection('actions') - setHighlightedIndex(3) - } else { - setHighlightedIndex( - highlightedIndex - gridRowLength - ) + break + case 'ArrowDown': + 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) + } } - } else if (activeSection === 'actions') { - if (highlightedIndex <= 0) { - setActiveSection('grid') - setHighlightedIndex(lastRowFirstIndex) - } else { - setHighlightedIndex(highlightedIndex - 1) + break + case 'ArrowUp': + 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 - case 'Escape': - event.preventDefault() - setShow(false) - setActiveSection('grid') - setHighlightedIndex(0) - break - default: - break + 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, @@ -166,13 +198,19 @@ export const useNavigation = ({ setShow, itemsArray, show }) => { if (currentView === 'home') { if (filter.length > 0) { // search mode - handleListViewNavigation(event) + handleListViewNavigation({ + event, + listLength: itemsArray.length, + }) } else { handleHomeViewNavigation(event) } } else { setActiveSection(null) - handleListViewNavigation(event) + handleListViewNavigation({ + event, + listLength: itemsArray.length, + }) } if ((event.metaKey || event.ctrlKey) && event.key === '/') { @@ -181,9 +219,7 @@ export const useNavigation = ({ setShow, itemsArray, show }) => { } if (event.key === 'Enter') { - if (activeSection === 'grid') { - window.open(itemsArray[highlightedIndex]?.['defaultAction']) - } else if (activeSection === 'actions') { + if (activeSection === 'actions') { modal ?.querySelector('.actions-menu') ?.childNodes?.[highlightedIndex]?.click() @@ -206,6 +242,7 @@ export const useNavigation = ({ setShow, itemsArray, show }) => { setActiveSection, setShow, show, + showGrid, ] ) 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 33b03da76d..89ef449d6b 100644 --- a/components/header-bar/src/command-palette/views/home-view.js +++ b/components/header-bar/src/command-palette/views/home-view.js @@ -1,25 +1,16 @@ import { clearSensitiveCaches, useConfig } from '@dhis2/app-runtime' -import { spacers, colors } from '@dhis2/ui-constants' -import { - IconApps16, - IconLogOut16, - IconRedo16, - IconTerminalWindow16, -} from '@dhis2/ui-icons' +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 { GRID_ITEMS_LENGTH } from '../hooks/use-navigation.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' -const MIN_APPS_NUM = GRID_ITEMS_LENGTH - -function HomeView({ apps, commands, shortcuts }) { +function HomeView({ apps, commands, shortcuts, actions }) { const { baseUrl } = useConfig() const { filter, @@ -87,93 +78,54 @@ function HomeView({ apps, commands, shortcuts }) { className="actions-menu" data-test="headerbar-actions-menu" > - {apps?.length > MIN_APPS_NUM ? ( - } - onClickHandler={() => { - setCurrentView('apps') - setHighlightedIndex(0) - }} - dataTest="headerbar-browse-apps" - highlighted={ - activeSection === 'actions' && - highlightedIndex === 0 - } - handleMouseEnter={() => { - setActiveSection('actions') - setHighlightedIndex(0) - }} - /> - ) : null} - {commands?.length > 0 ? ( - - } - onClickHandler={() => { - setCurrentView('commands') - setHighlightedIndex(0) - }} - dataTest="headerbar-browse-commands" - highlighted={ - activeSection === 'actions' && - highlightedIndex === 1 + {actions.map( + ({ dataTest, icon, title, type }, index) => { + const logoutActionHandler = async () => { + await clearSensitiveCaches() + window.location.assign( + joinPath( + baseUrl, + 'dhis-web-commons-security/logout.action' + ) + ) } - handleMouseEnter={() => { - setActiveSection('actions') - setHighlightedIndex(1) - }} - /> - ) : null} - {shortcuts?.length > 0 ? ( - } - onClickHandler={() => { - setCurrentView('shortcuts') + + const viewActionHandler = () => { + setCurrentView(type) setHighlightedIndex(0) - }} - dataTest="headerbar-browse-shortcuts" - highlighted={ - activeSection === 'actions' && - highlightedIndex === 2 } - handleMouseEnter={() => { - setActiveSection('actions') - setHighlightedIndex(2) - }} - /> - ) : null} - } - onClickHandler={async () => { - await clearSensitiveCaches() - window.location.assign( - joinPath( - baseUrl, - 'dhis-web-commons-security/logout.action' - ) + + return ( + { + setActiveSection('actions') + setHighlightedIndex(index) + }} + /> ) - }} - href={joinPath( - baseUrl, - 'dhis-web-commons-security/logout.action' - )} - dataTest="headerbar-logout" - highlighted={ - activeSection === 'actions' && - highlightedIndex === 3 } - handleMouseEnter={() => { - setActiveSection('actions') - setHighlightedIndex(3) - }} - /> + )}
)} @@ -182,6 +134,7 @@ function HomeView({ apps, commands, shortcuts }) { } HomeView.propTypes = { + actions: PropTypes.array, apps: PropTypes.array, commands: PropTypes.array, shortcuts: PropTypes.array, From 5421520e1c7291049ce715534be643662876d578 Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Wed, 6 Nov 2024 03:33:25 +0300 Subject: [PATCH 19/21] test: add navigation tests across different views - Add tests for the Up and Down arrow key navigation in the list views - Add tests for the Right and Left arrow key navigation in the app grid - Add tests for the search and highlighting functionality in the command palette --- .../__tests__/command-palette.test.js | 496 ++++++++++++++++-- 1 file changed, 443 insertions(+), 53 deletions(-) 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 a723851572..5a4841c64d 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,6 +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' const CommandPaletteProviderWrapper = ({ children }) => { return ( @@ -23,18 +24,19 @@ const render = (ui, options) => describe('Command Palette Component', () => { const headerBarIconTest = 'headerbar-apps-icon' const modalTest = 'headerbar-menu' + const minAppsNum = MIN_APPS_NUM // 8 - const apps = new Array(9).fill({ - name: 'Test App', - displayName: 'Test App', + const apps = new Array(minAppsNum + 1).fill(null).map((_, index) => ({ + name: `Test App ${index + 1}`, + displayName: `Test App ${index + 1}`, icon: '', defaultAction: '', - }) + })) const commands = [ { - name: 'Test Command', - displayName: 'Test Command', + name: 'Test Command 1', + displayName: 'Test Command 1', icon: '', defaultAction: '', }, @@ -42,8 +44,8 @@ describe('Command Palette Component', () => { const shortcuts = [ { - name: 'Test Shortcut', - displayName: 'Test Shortcut', + name: 'Test Shortcut 1', + displayName: 'Test Shortcut 1', icon: '', defaultAction: '', }, @@ -72,7 +74,7 @@ describe('Command Palette Component', () => { // Actions menu expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument() - // since apps < 8 + // since apps < MIN_APPS_NUM (8) expect(queryByTestId('headerbar-browse-apps')).not.toBeInTheDocument() // since commands < 1 expect( @@ -82,6 +84,7 @@ describe('Command Palette Component', () => { expect( queryByTestId('headerbar-browse-shortcuts') ).not.toBeInTheDocument() + // default action: logout expect(queryByTestId('headerbar-logout')).toBeInTheDocument() // click outside modal @@ -89,45 +92,16 @@ describe('Command Palette Component', () => { expect(queryByTestId(modalTest)).not.toBeInTheDocument() }) - it('opens and closes Command Palette using meta/ctrl + /', () => { - const { queryByTestId } = render( - - ) - // modal not rendered yet - expect(queryByTestId(modalTest)).not.toBeInTheDocument() - - // ctrl + / - // open modal - userEvent.keyboard('{ctrl}/') - expect(queryByTestId(modalTest)).toBeInTheDocument() - // close modal - userEvent.keyboard('{ctrl}/') - expect(queryByTestId(modalTest)).not.toBeInTheDocument() - }) - - it('closes Command Palette using Esc key', () => { - const { queryByTestId } = render( - - ) - // modal not rendered yet - expect(queryByTestId(modalTest)).not.toBeInTheDocument() - - // open modal - userEvent.keyboard('{ctrl}/') - expect(queryByTestId(modalTest)).toBeInTheDocument() - // Esc key closes the modal - userEvent.keyboard('{esc}') - expect(queryByTestId(modalTest)).not.toBeInTheDocument() - }) - it('shows the full default view upon opening the Command Palette', () => { const { getByTestId, queryByTestId, getAllByText, getByPlaceholderText, + queryAllByText, queryByText, getAllByRole, + queryAllByTestId, } = render( { commands={commands} /> ) - + // headerbar icon button userEvent.click(getByTestId(headerBarIconTest)) + // Search field const searchField = getByPlaceholderText( 'Search apps, shortcuts, commands' @@ -145,41 +120,51 @@ describe('Command Palette Component', () => { // Top Apps expect(queryByTestId('headerbar-top-apps-list')).toBeInTheDocument() - expect(getAllByText('Test App')).toHaveLength(8) + expect(getAllByText(/Test App/)).toHaveLength(8) // Actions menu - // since apps > 8 + // 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() - // search for command - userEvent.type(searchField, 'Command') - expect(searchField).toHaveValue('Command') + // full search across apps, shortcuts, commands + userEvent.type(searchField, 'Test') + expect(searchField).toHaveValue('Test') expect(queryByTestId('headerbar-top-apps-list')).not.toBeInTheDocument() - expect(queryByText(/Results for "Command"/i)).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 App/)).not.toBeInTheDocument() + expect(queryByText(/Test Shortcut/)).toBeInTheDocument() // clear field const clearButton = getAllByRole('button')[1] userEvent.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('renders Browse Apps View', () => { const { + getAllByRole, getByTestId, queryByTestId, getByPlaceholderText, queryByText, getByLabelText, + queryAllByTestId, queryAllByText, } = render() // open command palette @@ -197,12 +182,40 @@ describe('Command Palette Component', () => { expect(backButton).toBeInTheDocument() expect(queryByText(/All Apps/i)).toBeInTheDocument() - expect(queryAllByText(/Test App/)).toHaveLength(9) + + let listItems = queryAllByTestId('headerbar-list-item') + // first item highlighted + expect(listItems[0].querySelector('span')).toHaveTextContent( + 'Test App 1' + ) + expect(listItems[0]).toHaveClass('highlighted') + + // search across apps + userEvent.type(searchField, '6') + expect(searchField).toHaveValue('6') + + expect(queryByText(/Results for "6"/i)).toBeInTheDocument() + + listItems = queryAllByTestId('headerbar-list-item') + expect(listItems.length).toBe(1) + expect(queryAllByText(/Test App/).length).toBe(1) + + expect(listItems[0].querySelector('span')).toHaveTextContent( + 'Test App 6' + ) + expect(listItems[0]).toHaveClass('highlighted') + + // clear field + const clearButton = getAllByRole('button')[1] + userEvent.click(clearButton) + expect(searchField).toHaveValue('') + expect(queryByText(/All Apps/i)).toBeInTheDocument() + expect(queryByText(/Results for "6"/i)).not.toBeInTheDocument() // go back to default view - userEvent.click(backButton) - expect(queryByText(/All Apps/i)).not.toBeInTheDocument() + userEvent.click(getByLabelText('Back Button')) expect(queryByText(/Top Apps/i)).toBeInTheDocument() + expect(queryByText(/Actions/i)).toBeInTheDocument() }) it('renders Browse Commands View', () => { @@ -230,7 +243,13 @@ describe('Command Palette Component', () => { expect(backButton).toBeInTheDocument() expect(queryByText(/All Commands/i)).toBeInTheDocument() - expect(queryByText(/Test Command/)).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 userEvent.keyboard('{esc}') @@ -263,7 +282,18 @@ describe('Command Palette Component', () => { expect(backButton).toBeInTheDocument() expect(queryByText(/All Shortcuts/i)).toBeInTheDocument() - expect(queryByText(/Test Shortcut/)).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 + userEvent.click(getByLabelText('Back Button')) + expect(queryByText(/All Shortcuts/i)).not.toBeInTheDocument() + expect(queryByText(/Actions/i)).toBeInTheDocument() }) it('shows empty search results if no match is made', () => { @@ -290,4 +320,364 @@ describe('Command Palette Component', () => { expect(queryByTestId('headerbar-empty-search')).toBeInTheDocument() expect(queryByText(/Nothing found for "abc"/i)).toBeInTheDocument() }) + + it('opens and closes Command Palette using meta/ctrl + /', () => { + const { queryByTestId } = render( + + ) + // modal not rendered yet + expect(queryByTestId(modalTest)).not.toBeInTheDocument() + + // ctrl + / + // open modal + userEvent.keyboard('{ctrl}/') + expect(queryByTestId(modalTest)).toBeInTheDocument() + // close modal + userEvent.keyboard('{ctrl}/') + expect(queryByTestId(modalTest)).not.toBeInTheDocument() + }) + + it('closes Command Palette using Esc key', () => { + const { queryByTestId } = render( + + ) + // modal not rendered yet + expect(queryByTestId(modalTest)).not.toBeInTheDocument() + + // open modal + userEvent.keyboard('{ctrl}/') + expect(queryByTestId(modalTest)).toBeInTheDocument() + // Esc key closes the modal + userEvent.keyboard('{esc}') + expect(queryByTestId(modalTest)).not.toBeInTheDocument() + }) + + it('handles right arrow navigation in the grid on the home view', () => { + const { getAllByRole } = render( + + ) + + // open modal + userEvent.keyboard('{ctrl}/') + + // topApps + const appLinks = getAllByRole('link') + const firstAppLink = appLinks[0] + expect(appLinks.length).toBe(minAppsNum) + + // first highlighted item + expect(firstAppLink).toHaveClass('highlighted') + expect(firstAppLink.querySelector('span')).toHaveTextContent( + 'Test App 1' + ) + + // move right through the first row of items (0 - 3) + for ( + let prevIndex = 0; + prevIndex < appLinks.length / 2 - 1; + prevIndex++ + ) { + const activeIndex = prevIndex + 1 + expect(appLinks[prevIndex]).toHaveClass('highlighted') + + // move to next item + userEvent.keyboard('{ArrowRight}') + expect(appLinks[prevIndex]).not.toHaveClass('highlighted') + expect(appLinks[activeIndex]).toHaveClass('highlighted') + expect( + appLinks[activeIndex].querySelector('span') + ).toHaveTextContent(`Test App ${activeIndex + 1}`) + } + + // loops back to the first item + userEvent.keyboard('{ArrowRight}') + expect(firstAppLink).toHaveClass('highlighted') + }) + + it('handles left arrow navigation in the grid on the home view', () => { + const { getAllByRole } = render( + + ) + + // open modal + userEvent.keyboard('{ctrl}/') + + // topApps + const appLinks = getAllByRole('link') + const firstAppLink = appLinks[0] + const lastAppLinkFirstRow = appLinks[3] + expect(appLinks.length).toBe(minAppsNum) + + // first highlighted item + expect(firstAppLink).toHaveClass('highlighted') + expect(firstAppLink.querySelector('span')).toHaveTextContent( + 'Test App 1' + ) + + // loops to last item in the row + userEvent.keyboard('{ArrowLeft}') + expect(firstAppLink).not.toHaveClass('highlighted') + expect(lastAppLinkFirstRow).toHaveClass('highlighted') + expect(lastAppLinkFirstRow.querySelector('span')).toHaveTextContent( + 'Test App 4' + ) + + // move left through the first row of items (3 - 0) + for ( + let prevIndex = appLinks.length / 2 - 1; + prevIndex > 0; + prevIndex-- + ) { + const activeIndex = prevIndex - 1 + expect(appLinks[prevIndex]).toHaveClass('highlighted') + + // move to next item + userEvent.keyboard('{ArrowLeft}') + expect(appLinks[prevIndex]).not.toHaveClass('highlighted') + expect(appLinks[activeIndex]).toHaveClass('highlighted') + expect( + appLinks[activeIndex].querySelector('span') + ).toHaveTextContent(`Test App ${activeIndex + 1}`) + } + }) + + it('handles down arrow navigation on the home view', () => { + const { getAllByRole, queryByTestId } = render( + + ) + + // open modal + userEvent.keyboard('{ctrl}/') + + // topApps + const appLinks = getAllByRole('link') + const rowOneFirstAppLink = appLinks[0] + const rowTwoFirstAppLink = appLinks[4] + + // first highlighted item + expect(rowOneFirstAppLink).toHaveClass('highlighted') + expect(rowOneFirstAppLink.querySelector('span')).toHaveTextContent( + 'Test App 1' + ) + + userEvent.keyboard('{ArrowDown}') + expect(rowOneFirstAppLink).not.toHaveClass('highlighted') + expect(rowTwoFirstAppLink).toHaveClass('highlighted') + expect(rowTwoFirstAppLink.querySelector('span')).toHaveTextContent( + 'Test App ' + ) + + // actions menu + userEvent.keyboard('{ArrowDown}') + expect(queryByTestId('headerbar-browse-apps')).toHaveClass( + 'highlighted' + ) + + userEvent.keyboard('{ArrowDown}') + expect(queryByTestId('headerbar-browse-commands')).toHaveClass( + 'highlighted' + ) + + userEvent.keyboard('{ArrowDown}') + expect(queryByTestId('headerbar-browse-shortcuts')).toHaveClass( + 'highlighted' + ) + + userEvent.keyboard('{ArrowDown}') + expect(queryByTestId('headerbar-logout')).toHaveClass('highlighted') + + // loop back to grid + userEvent.keyboard('{ArrowDown}') + expect(rowOneFirstAppLink).toHaveClass('highlighted') + }) + + it('handles up arrow navigation on the home view', () => { + const { getAllByRole, queryByTestId } = render( + + ) + + // open modal + userEvent.keyboard('{ctrl}/') + + // topApps + const appLinks = getAllByRole('link') + const rowOneFirstAppLink = appLinks[0] + const rowTwoFirstAppLink = appLinks[4] + + // first highlighted item + expect(rowOneFirstAppLink).toHaveClass('highlighted') + expect(rowOneFirstAppLink.querySelector('span')).toHaveTextContent( + 'Test App 1' + ) + + // goes to bottom of actions menu + userEvent.keyboard('{ArrowUp}') + expect(rowOneFirstAppLink).not.toHaveClass('highlighted') + expect(queryByTestId('headerbar-logout')).toHaveClass('highlighted') + + userEvent.keyboard('{ArrowUp}') + expect(queryByTestId('headerbar-browse-shortcuts')).toHaveClass( + 'highlighted' + ) + + userEvent.keyboard('{ArrowUp}') + expect(queryByTestId('headerbar-browse-commands')).toHaveClass( + 'highlighted' + ) + + userEvent.keyboard('{ArrowUp}') + expect(queryByTestId('headerbar-browse-apps')).toHaveClass( + 'highlighted' + ) + + // moves to grid + userEvent.keyboard('{ArrowUp}') + expect(rowTwoFirstAppLink).toHaveClass('highlighted') + expect(rowTwoFirstAppLink.querySelector('span')).toHaveTextContent( + 'Test App 5' + ) + + userEvent.keyboard('{ArrowUp}') + expect(rowOneFirstAppLink).toHaveClass('highlighted') + }) + + it('handles navigation and hover state in list view - browse apps', () => { + const { + getAllByRole, + queryByTestId, + getByPlaceholderText, + queryAllByTestId, + queryByText, + } = render( + + ) + // open modal + userEvent.keyboard('{ctrl}/') + + //open browse apps view + userEvent.click(queryByTestId('headerbar-browse-apps')) + + // no filter view + const searchField = getByPlaceholderText('Search apps') + expect(searchField).toHaveValue('') + 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' + ) + + userEvent.keyboard('{ArrowDown}') + expect(listItems[0]).not.toHaveClass('highlighted') + expect(listItems[1]).toHaveClass('highlighted') + expect(listItems[1].querySelector('span')).toHaveTextContent( + 'Test App 2' + ) + + userEvent.keyboard('{ArrowDown}') + expect(listItems[1]).not.toHaveClass('highlighted') + expect(listItems[2]).toHaveClass('highlighted') + expect(listItems[2].querySelector('span')).toHaveTextContent( + 'Test App 3' + ) + + userEvent.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 + userEvent.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 + userEvent.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] + userEvent.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' + ) + }) + + it('handles a list with one item', () => { + const { getByPlaceholderText, queryAllByTestId } = render( + + ) + // open modal + userEvent.keyboard('{ctrl}/') + + // Search field + const searchField = getByPlaceholderText( + 'Search apps, shortcuts, commands' + ) + expect(searchField).toHaveValue('') + + // one item result + userEvent.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') + + userEvent.keyboard('{ArrowUp}') + expect(listItems[0]).toHaveClass('highlighted') + + userEvent.keyboard('{ArrowDown}') + expect(listItems[0]).toHaveClass('highlighted') + }) }) From f6a5c9d15a1e431da18ebd4c4c4ff5cfcb67c230 Mon Sep 17 00:00:00 2001 From: Diana Nanyanzi Date: Wed, 6 Nov 2024 03:38:25 +0300 Subject: [PATCH 20/21] refactor(cleanup): remove hardcoded shortcuts and commands arrays --- components/header-bar/src/header-bar.js | 40 +++++-------------------- 1 file changed, 8 insertions(+), 32 deletions(-) diff --git a/components/header-bar/src/header-bar.js b/components/header-bar/src/header-bar.js index 47271f93d6..e14e915995 100755 --- a/components/header-bar/src/header-bar.js +++ b/components/header-bar/src/header-bar.js @@ -56,39 +56,11 @@ export const HeaderBar = ({ })) }, [data, baseUrl]) - const commands = [ - { - description: 'Search for and open a visualisation, chart, or table', - displayName: 'Open', - icon: 'https://domain.tld/api/../icons/open.png', - name: 'open', - }, - { - description: 'Copy debug information to the clipboard', - displayName: 'Debug', - icon: 'https://domain.tld/api/../icons/debug.png', - name: 'debug', - }, - { - description: 'Empty system cache', - displayName: 'Clear cache', - icon: 'https://domain.tld/api/../icons/clear-cache.png', - name: 'clear-cache', - }, - ] + // fetch commands + const commands = [] - const shortcuts = [ - { - displayName: 'Data elements overview', - icon: 'https://domain.tld/api/../icons/dhis-web-dashboard.png', - name: 'Data elements overview', - }, - { - displayName: 'Data sets', - icon: 'https://domain.tld/api/../icons/dhis-web-dashboard.png', - name: 'Data sets', - }, - ] + // fetch shortcuts + const shortcuts = [] // See https://jira.dhis2.org/browse/LIBS-180 if (!loading && !error) { @@ -134,6 +106,10 @@ export const HeaderBar = ({ apps={apps} commands={commands} shortcuts={shortcuts} + // apps={[]} + + // commands={[]} + // shortcuts={[]} /> Date: Wed, 6 Nov 2024 19:58:18 +0300 Subject: [PATCH 21/21] refactor: split test file into multiple files for readability --- .../__tests__/browse-apps-view.test.js | 142 ++++ .../__tests__/browse-commands-view.test.js | 49 ++ .../__tests__/browse-shortcuts-view.test.js | 49 ++ .../__tests__/command-palette.test.js | 618 ++---------------- .../__tests__/home-view.test.js | 282 ++++++++ .../__tests__/search-results.test.js | 69 ++ .../command-palette/hooks/use-navigation.js | 6 + components/header-bar/src/header-bar.js | 4 - 8 files changed, 636 insertions(+), 583 deletions(-) create mode 100644 components/header-bar/src/command-palette/__tests__/browse-apps-view.test.js create mode 100644 components/header-bar/src/command-palette/__tests__/browse-commands-view.test.js create mode 100644 components/header-bar/src/command-palette/__tests__/browse-shortcuts-view.test.js create mode 100644 components/header-bar/src/command-palette/__tests__/home-view.test.js create mode 100644 components/header-bar/src/command-palette/__tests__/search-results.test.js 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..42354c87a5 --- /dev/null +++ b/components/header-bar/src/command-palette/__tests__/browse-apps-view.test.js @@ -0,0 +1,142 @@ +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', () => { + const { + getByTestId, + queryByTestId, + getByPlaceholderText, + queryByText, + getByLabelText, + queryAllByTestId, + } = render( + + ) + // open command palette + userEvent.click(getByTestId(headerBarIconTest)) + + expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument() + userEvent.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 + userEvent.click(getByLabelText('Back Button')) + expect(queryByText(/Top Apps/i)).toBeInTheDocument() + expect(queryByText(/Actions/i)).toBeInTheDocument() + }) + + it('handles navigation and hover state of list items', () => { + const { + getAllByRole, + queryByTestId, + getByPlaceholderText, + queryAllByTestId, + queryByText, + } = render( + + ) + // open modal + userEvent.keyboard('{ctrl}/') + + //open browse apps view + userEvent.click(queryByTestId('headerbar-browse-apps')) + + // no filter view + const searchField = getByPlaceholderText('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' + ) + + userEvent.keyboard('{ArrowDown}') + expect(listItems[0]).not.toHaveClass('highlighted') + expect(listItems[1]).toHaveClass('highlighted') + expect(listItems[1].querySelector('span')).toHaveTextContent( + 'Test App 2' + ) + + userEvent.keyboard('{ArrowDown}') + expect(listItems[1]).not.toHaveClass('highlighted') + expect(listItems[2]).toHaveClass('highlighted') + expect(listItems[2].querySelector('span')).toHaveTextContent( + 'Test App 3' + ) + + userEvent.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 + userEvent.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 + userEvent.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] + userEvent.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..57bb2cb8fd --- /dev/null +++ b/components/header-bar/src/command-palette/__tests__/browse-commands-view.test.js @@ -0,0 +1,49 @@ +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', () => { + const { + getByTestId, + queryByTestId, + getByPlaceholderText, + queryByText, + getByLabelText, + } = render( + + ) + // open command palette + userEvent.click(getByTestId(headerBarIconTest)) + + expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument() + userEvent.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 + userEvent.keyboard('{esc}') + 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..6154eb4690 --- /dev/null +++ b/components/header-bar/src/command-palette/__tests__/browse-shortcuts-view.test.js @@ -0,0 +1,49 @@ +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', () => { + const { + getByTestId, + queryByTestId, + getByPlaceholderText, + queryByText, + getByLabelText, + } = render( + + ) + // open command palette + userEvent.click(getByTestId(headerBarIconTest)) + + expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument() + userEvent.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 + userEvent.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 index 5a4841c64d..419b0f9225 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 @@ -18,39 +18,41 @@ CommandPaletteProviderWrapper.propTypes = { children: PropTypes.node, } -const render = (ui, options) => +export const render = (ui, options) => originalRender(ui, { wrapper: CommandPaletteProviderWrapper, ...options }) -describe('Command Palette Component', () => { - const headerBarIconTest = 'headerbar-apps-icon' - const modalTest = 'headerbar-menu' - const minAppsNum = MIN_APPS_NUM // 8 +export const headerBarIconTest = 'headerbar-apps-icon' +export const modalTest = 'headerbar-menu' +export const minAppsNum = MIN_APPS_NUM // 8 - const apps = new Array(minAppsNum + 1).fill(null).map((_, index) => ({ +export const testApps = new Array(minAppsNum + 1) + .fill(null) + .map((_, index) => ({ name: `Test App ${index + 1}`, displayName: `Test App ${index + 1}`, icon: '', defaultAction: '', })) - const commands = [ - { - name: 'Test Command 1', - displayName: 'Test Command 1', - icon: '', - defaultAction: '', - }, - ] +export const testCommands = [ + { + name: 'Test Command 1', + displayName: 'Test Command 1', + icon: '', + defaultAction: '', + }, +] - const shortcuts = [ - { - name: 'Test Shortcut 1', - displayName: 'Test Shortcut 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', () => { const { getByTestId, queryByTestId, getByPlaceholderText } = render( @@ -92,236 +94,7 @@ describe('Command Palette Component', () => { expect(queryByTestId(modalTest)).not.toBeInTheDocument() }) - it('shows the full default view upon opening the Command Palette', () => { - const { - getByTestId, - queryByTestId, - getAllByText, - getByPlaceholderText, - queryAllByText, - queryByText, - getAllByRole, - queryAllByTestId, - } = render( - - ) - // headerbar icon button - userEvent.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 - userEvent.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] - userEvent.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('renders Browse Apps View', () => { - const { - getAllByRole, - getByTestId, - queryByTestId, - getByPlaceholderText, - queryByText, - getByLabelText, - queryAllByTestId, - queryAllByText, - } = render() - // open command palette - userEvent.click(getByTestId(headerBarIconTest)) - - expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument() - userEvent.click(getByTestId('headerbar-browse-apps')) - - // Browse Apps View - // Search field - const searchField = getByPlaceholderText('Search apps') - expect(searchField).toHaveValue('') - - const backButton = getByLabelText('Back Button') - expect(backButton).toBeInTheDocument() - - expect(queryByText(/All Apps/i)).toBeInTheDocument() - - let listItems = queryAllByTestId('headerbar-list-item') - // first item highlighted - expect(listItems[0].querySelector('span')).toHaveTextContent( - 'Test App 1' - ) - expect(listItems[0]).toHaveClass('highlighted') - - // search across apps - userEvent.type(searchField, '6') - expect(searchField).toHaveValue('6') - - expect(queryByText(/Results for "6"/i)).toBeInTheDocument() - - listItems = queryAllByTestId('headerbar-list-item') - expect(listItems.length).toBe(1) - expect(queryAllByText(/Test App/).length).toBe(1) - - expect(listItems[0].querySelector('span')).toHaveTextContent( - 'Test App 6' - ) - expect(listItems[0]).toHaveClass('highlighted') - - // clear field - const clearButton = getAllByRole('button')[1] - userEvent.click(clearButton) - expect(searchField).toHaveValue('') - expect(queryByText(/All Apps/i)).toBeInTheDocument() - expect(queryByText(/Results for "6"/i)).not.toBeInTheDocument() - - // go back to default view - userEvent.click(getByLabelText('Back Button')) - expect(queryByText(/Top Apps/i)).toBeInTheDocument() - expect(queryByText(/Actions/i)).toBeInTheDocument() - }) - - it('renders Browse Commands View', () => { - const { - getByTestId, - queryByTestId, - getByPlaceholderText, - queryByText, - getByLabelText, - } = render( - - ) - // open command palette - userEvent.click(getByTestId(headerBarIconTest)) - - expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument() - userEvent.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 - userEvent.keyboard('{esc}') - expect(queryByText(/All Commands/i)).not.toBeInTheDocument() - expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument() - }) - - it('renders Browse Shortcuts View', () => { - const { - getByTestId, - queryByTestId, - getByPlaceholderText, - queryByText, - getByLabelText, - } = render( - - ) - // open command palette - userEvent.click(getByTestId(headerBarIconTest)) - - expect(queryByTestId('headerbar-actions-menu')).toBeInTheDocument() - userEvent.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 - userEvent.click(getByLabelText('Back Button')) - expect(queryByText(/All Shortcuts/i)).not.toBeInTheDocument() - expect(queryByText(/Actions/i)).toBeInTheDocument() - }) - - it('shows empty search results if no match is made', () => { - const { - getByTestId, - getByPlaceholderText, - queryByText, - queryByTestId, - } = render( - - ) - // open command palette - userEvent.click(getByTestId(headerBarIconTest)) - - // Search field - const searchField = getByPlaceholderText( - 'Search apps, shortcuts, commands' - ) - expect(searchField).toHaveValue('') - - userEvent.type(searchField, 'abc') - expect(searchField).toHaveValue('abc') - - expect(queryByTestId('headerbar-empty-search')).toBeInTheDocument() - expect(queryByText(/Nothing found for "abc"/i)).toBeInTheDocument() - }) - - it('opens and closes Command Palette using meta/ctrl + /', () => { + it('opens and closes Command Palette using ctrl + /', () => { const { queryByTestId } = render( ) @@ -337,347 +110,34 @@ describe('Command Palette Component', () => { expect(queryByTestId(modalTest)).not.toBeInTheDocument() }) - it('closes Command Palette using Esc key', () => { + it('opens and closes Command Palette using meta + /', () => { const { queryByTestId } = render( ) // modal not rendered yet expect(queryByTestId(modalTest)).not.toBeInTheDocument() + // meta + / // open modal - userEvent.keyboard('{ctrl}/') + userEvent.keyboard('{meta}/') expect(queryByTestId(modalTest)).toBeInTheDocument() - // Esc key closes the modal - userEvent.keyboard('{esc}') + // close modal + userEvent.keyboard('{meta}/') expect(queryByTestId(modalTest)).not.toBeInTheDocument() }) - it('handles right arrow navigation in the grid on the home view', () => { - const { getAllByRole } = render( - - ) - - // open modal - userEvent.keyboard('{ctrl}/') - - // topApps - const appLinks = getAllByRole('link') - const firstAppLink = appLinks[0] - expect(appLinks.length).toBe(minAppsNum) - - // first highlighted item - expect(firstAppLink).toHaveClass('highlighted') - expect(firstAppLink.querySelector('span')).toHaveTextContent( - 'Test App 1' - ) - - // move right through the first row of items (0 - 3) - for ( - let prevIndex = 0; - prevIndex < appLinks.length / 2 - 1; - prevIndex++ - ) { - const activeIndex = prevIndex + 1 - expect(appLinks[prevIndex]).toHaveClass('highlighted') - - // move to next item - userEvent.keyboard('{ArrowRight}') - expect(appLinks[prevIndex]).not.toHaveClass('highlighted') - expect(appLinks[activeIndex]).toHaveClass('highlighted') - expect( - appLinks[activeIndex].querySelector('span') - ).toHaveTextContent(`Test App ${activeIndex + 1}`) - } - - // loops back to the first item - userEvent.keyboard('{ArrowRight}') - expect(firstAppLink).toHaveClass('highlighted') - }) - - it('handles left arrow navigation in the grid on the home view', () => { - const { getAllByRole } = render( - - ) - - // open modal - userEvent.keyboard('{ctrl}/') - - // topApps - const appLinks = getAllByRole('link') - const firstAppLink = appLinks[0] - const lastAppLinkFirstRow = appLinks[3] - expect(appLinks.length).toBe(minAppsNum) - - // first highlighted item - expect(firstAppLink).toHaveClass('highlighted') - expect(firstAppLink.querySelector('span')).toHaveTextContent( - 'Test App 1' - ) - - // loops to last item in the row - userEvent.keyboard('{ArrowLeft}') - expect(firstAppLink).not.toHaveClass('highlighted') - expect(lastAppLinkFirstRow).toHaveClass('highlighted') - expect(lastAppLinkFirstRow.querySelector('span')).toHaveTextContent( - 'Test App 4' - ) - - // move left through the first row of items (3 - 0) - for ( - let prevIndex = appLinks.length / 2 - 1; - prevIndex > 0; - prevIndex-- - ) { - const activeIndex = prevIndex - 1 - expect(appLinks[prevIndex]).toHaveClass('highlighted') - - // move to next item - userEvent.keyboard('{ArrowLeft}') - expect(appLinks[prevIndex]).not.toHaveClass('highlighted') - expect(appLinks[activeIndex]).toHaveClass('highlighted') - expect( - appLinks[activeIndex].querySelector('span') - ).toHaveTextContent(`Test App ${activeIndex + 1}`) - } - }) - - it('handles down arrow navigation on the home view', () => { - const { getAllByRole, queryByTestId } = render( - - ) - - // open modal - userEvent.keyboard('{ctrl}/') - - // topApps - const appLinks = getAllByRole('link') - const rowOneFirstAppLink = appLinks[0] - const rowTwoFirstAppLink = appLinks[4] - - // first highlighted item - expect(rowOneFirstAppLink).toHaveClass('highlighted') - expect(rowOneFirstAppLink.querySelector('span')).toHaveTextContent( - 'Test App 1' - ) - - userEvent.keyboard('{ArrowDown}') - expect(rowOneFirstAppLink).not.toHaveClass('highlighted') - expect(rowTwoFirstAppLink).toHaveClass('highlighted') - expect(rowTwoFirstAppLink.querySelector('span')).toHaveTextContent( - 'Test App ' - ) - - // actions menu - userEvent.keyboard('{ArrowDown}') - expect(queryByTestId('headerbar-browse-apps')).toHaveClass( - 'highlighted' - ) - - userEvent.keyboard('{ArrowDown}') - expect(queryByTestId('headerbar-browse-commands')).toHaveClass( - 'highlighted' - ) - - userEvent.keyboard('{ArrowDown}') - expect(queryByTestId('headerbar-browse-shortcuts')).toHaveClass( - 'highlighted' - ) - - userEvent.keyboard('{ArrowDown}') - expect(queryByTestId('headerbar-logout')).toHaveClass('highlighted') - - // loop back to grid - userEvent.keyboard('{ArrowDown}') - expect(rowOneFirstAppLink).toHaveClass('highlighted') - }) - - it('handles up arrow navigation on the home view', () => { - const { getAllByRole, queryByTestId } = render( - - ) - - // open modal - userEvent.keyboard('{ctrl}/') - - // topApps - const appLinks = getAllByRole('link') - const rowOneFirstAppLink = appLinks[0] - const rowTwoFirstAppLink = appLinks[4] - - // first highlighted item - expect(rowOneFirstAppLink).toHaveClass('highlighted') - expect(rowOneFirstAppLink.querySelector('span')).toHaveTextContent( - 'Test App 1' - ) - - // goes to bottom of actions menu - userEvent.keyboard('{ArrowUp}') - expect(rowOneFirstAppLink).not.toHaveClass('highlighted') - expect(queryByTestId('headerbar-logout')).toHaveClass('highlighted') - - userEvent.keyboard('{ArrowUp}') - expect(queryByTestId('headerbar-browse-shortcuts')).toHaveClass( - 'highlighted' - ) - - userEvent.keyboard('{ArrowUp}') - expect(queryByTestId('headerbar-browse-commands')).toHaveClass( - 'highlighted' - ) - - userEvent.keyboard('{ArrowUp}') - expect(queryByTestId('headerbar-browse-apps')).toHaveClass( - 'highlighted' - ) - - // moves to grid - userEvent.keyboard('{ArrowUp}') - expect(rowTwoFirstAppLink).toHaveClass('highlighted') - expect(rowTwoFirstAppLink.querySelector('span')).toHaveTextContent( - 'Test App 5' - ) - - userEvent.keyboard('{ArrowUp}') - expect(rowOneFirstAppLink).toHaveClass('highlighted') - }) - - it('handles navigation and hover state in list view - browse apps', () => { - const { - getAllByRole, - queryByTestId, - getByPlaceholderText, - queryAllByTestId, - queryByText, - } = render( - - ) - // open modal - userEvent.keyboard('{ctrl}/') - - //open browse apps view - userEvent.click(queryByTestId('headerbar-browse-apps')) - - // no filter view - const searchField = getByPlaceholderText('Search apps') - expect(searchField).toHaveValue('') - 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' - ) - - userEvent.keyboard('{ArrowDown}') - expect(listItems[0]).not.toHaveClass('highlighted') - expect(listItems[1]).toHaveClass('highlighted') - expect(listItems[1].querySelector('span')).toHaveTextContent( - 'Test App 2' - ) - - userEvent.keyboard('{ArrowDown}') - expect(listItems[1]).not.toHaveClass('highlighted') - expect(listItems[2]).toHaveClass('highlighted') - expect(listItems[2].querySelector('span')).toHaveTextContent( - 'Test App 3' - ) - - userEvent.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 - userEvent.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 - userEvent.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] - userEvent.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' + it('closes Command Palette using Esc key', () => { + const { queryByTestId } = render( + ) - }) + // modal not rendered yet + expect(queryByTestId(modalTest)).not.toBeInTheDocument() - it('handles a list with one item', () => { - const { getByPlaceholderText, queryAllByTestId } = render( - - ) // open modal userEvent.keyboard('{ctrl}/') - - // Search field - const searchField = getByPlaceholderText( - 'Search apps, shortcuts, commands' - ) - expect(searchField).toHaveValue('') - - // one item result - userEvent.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') - - userEvent.keyboard('{ArrowUp}') - expect(listItems[0]).toHaveClass('highlighted') - - userEvent.keyboard('{ArrowDown}') - expect(listItems[0]).toHaveClass('highlighted') + expect(queryByTestId(modalTest)).toBeInTheDocument() + // Esc key closes the modal + userEvent.keyboard('{esc}') + 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..95e258e544 --- /dev/null +++ b/components/header-bar/src/command-palette/__tests__/home-view.test.js @@ -0,0 +1,282 @@ +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', () => { + const { + getByTestId, + queryByTestId, + getAllByText, + getByPlaceholderText, + queryAllByText, + queryByText, + getAllByRole, + queryAllByTestId, + } = render( + + ) + // headerbar icon button + userEvent.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 + userEvent.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] + userEvent.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', () => { + const { getAllByRole } = render( + + ) + + // open modal + userEvent.keyboard('{ctrl}/') + + // topApps + const appLinks = getAllByRole('link') + const firstAppLink = appLinks[0] + expect(appLinks.length).toBe(minAppsNum) + + // first highlighted item + expect(firstAppLink).toHaveClass('highlighted') + expect(firstAppLink.querySelector('span')).toHaveTextContent( + 'Test App 1' + ) + + // move right through the first row of items (0 - 3) + for ( + let prevIndex = 0; + prevIndex < appLinks.length / 2 - 1; + prevIndex++ + ) { + const activeIndex = prevIndex + 1 + expect(appLinks[prevIndex]).toHaveClass('highlighted') + + // move to next item + userEvent.keyboard('{ArrowRight}') + expect(appLinks[prevIndex]).not.toHaveClass('highlighted') + expect(appLinks[activeIndex]).toHaveClass('highlighted') + expect( + appLinks[activeIndex].querySelector('span') + ).toHaveTextContent(`Test App ${activeIndex + 1}`) + } + + // loops back to the first item + userEvent.keyboard('{ArrowRight}') + expect(firstAppLink).toHaveClass('highlighted') + }) + + it('handles left arrow navigation in the grid on the home view', () => { + const { getAllByRole } = render( + + ) + + // open modal + userEvent.keyboard('{ctrl}/') + + // topApps + const appLinks = getAllByRole('link') + const firstAppLink = appLinks[0] + const lastAppLinkFirstRow = appLinks[3] + expect(appLinks.length).toBe(minAppsNum) + + // first highlighted item + expect(firstAppLink).toHaveClass('highlighted') + expect(firstAppLink.querySelector('span')).toHaveTextContent( + 'Test App 1' + ) + + // loops to last item in the row + userEvent.keyboard('{ArrowLeft}') + expect(firstAppLink).not.toHaveClass('highlighted') + expect(lastAppLinkFirstRow).toHaveClass('highlighted') + expect(lastAppLinkFirstRow.querySelector('span')).toHaveTextContent( + 'Test App 4' + ) + + // move left through the first row of items (3 - 0) + for ( + let prevIndex = appLinks.length / 2 - 1; + prevIndex > 0; + prevIndex-- + ) { + const activeIndex = prevIndex - 1 + expect(appLinks[prevIndex]).toHaveClass('highlighted') + + // move to next item + userEvent.keyboard('{ArrowLeft}') + expect(appLinks[prevIndex]).not.toHaveClass('highlighted') + expect(appLinks[activeIndex]).toHaveClass('highlighted') + expect( + appLinks[activeIndex].querySelector('span') + ).toHaveTextContent(`Test App ${activeIndex + 1}`) + } + }) + + it('handles down arrow navigation on the home view', () => { + const { getAllByRole, queryByTestId } = render( + + ) + + // open modal + userEvent.keyboard('{ctrl}/') + + // topApps + const appLinks = getAllByRole('link') + const rowOneFirstAppLink = appLinks[0] + const rowTwoFirstAppLink = appLinks[4] + + // first highlighted item + expect(rowOneFirstAppLink).toHaveClass('highlighted') + expect(rowOneFirstAppLink.querySelector('span')).toHaveTextContent( + 'Test App 1' + ) + + userEvent.keyboard('{ArrowDown}') + expect(rowOneFirstAppLink).not.toHaveClass('highlighted') + expect(rowTwoFirstAppLink).toHaveClass('highlighted') + expect(rowTwoFirstAppLink.querySelector('span')).toHaveTextContent( + 'Test App ' + ) + + // actions menu + userEvent.keyboard('{ArrowDown}') + expect(queryByTestId('headerbar-browse-apps')).toHaveClass( + 'highlighted' + ) + + userEvent.keyboard('{ArrowDown}') + expect(queryByTestId('headerbar-browse-commands')).toHaveClass( + 'highlighted' + ) + + userEvent.keyboard('{ArrowDown}') + expect(queryByTestId('headerbar-browse-shortcuts')).toHaveClass( + 'highlighted' + ) + + userEvent.keyboard('{ArrowDown}') + expect(queryByTestId('headerbar-logout')).toHaveClass('highlighted') + + // loop back to grid + userEvent.keyboard('{ArrowDown}') + expect(rowOneFirstAppLink).toHaveClass('highlighted') + }) + + it('handles up arrow navigation on the home view', () => { + const { getAllByRole, queryByTestId } = render( + + ) + + // open modal + userEvent.keyboard('{ctrl}/') + + // topApps + const appLinks = getAllByRole('link') + const rowOneFirstAppLink = appLinks[0] + const rowTwoFirstAppLink = appLinks[4] + + // first highlighted item + expect(rowOneFirstAppLink).toHaveClass('highlighted') + expect(rowOneFirstAppLink.querySelector('span')).toHaveTextContent( + 'Test App 1' + ) + + // goes to bottom of actions menu + userEvent.keyboard('{ArrowUp}') + expect(rowOneFirstAppLink).not.toHaveClass('highlighted') + expect(queryByTestId('headerbar-logout')).toHaveClass('highlighted') + + userEvent.keyboard('{ArrowUp}') + expect(queryByTestId('headerbar-browse-shortcuts')).toHaveClass( + 'highlighted' + ) + + userEvent.keyboard('{ArrowUp}') + expect(queryByTestId('headerbar-browse-commands')).toHaveClass( + 'highlighted' + ) + + userEvent.keyboard('{ArrowUp}') + expect(queryByTestId('headerbar-browse-apps')).toHaveClass( + 'highlighted' + ) + + // moves to grid + userEvent.keyboard('{ArrowUp}') + expect(rowTwoFirstAppLink).toHaveClass('highlighted') + expect(rowTwoFirstAppLink.querySelector('span')).toHaveTextContent( + 'Test App 5' + ) + + userEvent.keyboard('{ArrowUp}') + expect(rowOneFirstAppLink).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..c7cab4cf1d --- /dev/null +++ b/components/header-bar/src/command-palette/__tests__/search-results.test.js @@ -0,0 +1,69 @@ +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', () => { + const { getByPlaceholderText, queryAllByTestId } = render( + + ) + // open modal + userEvent.keyboard('{ctrl}/') + + // Search field + const searchField = getByPlaceholderText( + 'Search apps, shortcuts, commands' + ) + expect(searchField).toHaveValue('') + + // one item result + userEvent.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') + + userEvent.keyboard('{ArrowUp}') + expect(listItems[0]).toHaveClass('highlighted') + + userEvent.keyboard('{ArrowDown}') + expect(listItems[0]).toHaveClass('highlighted') + }) + + it('shows empty search results if no match is made', () => { + const { + getByTestId, + getByPlaceholderText, + queryByText, + queryByTestId, + } = render( + + ) + // open command palette + userEvent.click(getByTestId(headerBarIconTest)) + + // Search field + const searchField = getByPlaceholderText( + 'Search apps, shortcuts, commands' + ) + expect(searchField).toHaveValue('') + + userEvent.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/hooks/use-navigation.js b/components/header-bar/src/command-palette/hooks/use-navigation.js index 2068e66b91..f5d9e5e781 100644 --- a/components/header-bar/src/command-palette/hooks/use-navigation.js +++ b/components/header-bar/src/command-palette/hooks/use-navigation.js @@ -49,11 +49,13 @@ export const useNavigation = ({ 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 ) @@ -83,6 +85,7 @@ export const useNavigation = ({ if (showGrid) { switch (event.key) { case 'ArrowLeft': + event.preventDefault() if (activeSection === 'grid') { // row 1 if (highlightedIndex <= topRowLastIndex) { @@ -103,6 +106,7 @@ export const useNavigation = ({ } break case 'ArrowRight': + event.preventDefault() if (activeSection === 'grid') { // row 1 if (highlightedIndex <= topRowLastIndex) { @@ -123,6 +127,7 @@ export const useNavigation = ({ } break case 'ArrowDown': + event.preventDefault() if (activeSection === 'grid') { if (highlightedIndex >= lastRowFirstIndex) { setActiveSection('actions') @@ -142,6 +147,7 @@ export const useNavigation = ({ } break case 'ArrowUp': + event.preventDefault() if (activeSection === 'grid') { if (highlightedIndex < lastRowFirstIndex) { setActiveSection('actions') diff --git a/components/header-bar/src/header-bar.js b/components/header-bar/src/header-bar.js index e14e915995..94fe5b5384 100755 --- a/components/header-bar/src/header-bar.js +++ b/components/header-bar/src/header-bar.js @@ -106,10 +106,6 @@ export const HeaderBar = ({ apps={apps} commands={commands} shortcuts={shortcuts} - // apps={[]} - - // commands={[]} - // shortcuts={[]} />