diff --git a/components/header-bar/src/command-palette/apps.js b/components/header-bar/src/command-palette/apps.js deleted file mode 100755 index 49ab2ff3d..000000000 --- a/components/header-bar/src/command-palette/apps.js +++ /dev/null @@ -1,174 +0,0 @@ -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/command-palette.js b/components/header-bar/src/command-palette/command-palette.js new file mode 100755 index 000000000..6e5569148 --- /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 82fe8ca93..000000000 --- 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 000000000..6bb49ed11 --- /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 000000000..80cca36e4 --- /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 000000000..ae8a5df53 --- /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 000000000..65288e8b8 --- /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 000000000..d3b7eda61 --- /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 000000000..2c91d37db --- /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 000000000..722057588 --- /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 000000000..26ed7e12f --- /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 427639936..a713d3f91 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 000000000..9371761a6 --- /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 b0b9ff9eb..d63cc02a3 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'