From 552616368677aed228ef3f53eef30d02ec183d72 Mon Sep 17 00:00:00 2001 From: Hendrik de Graaf Date: Mon, 19 Jun 2023 15:32:11 +0200 Subject: [PATCH] feat: toolbar UI update with hoverable menu (#1478) BREAKING CHANGE The `FileMenu` is now using the new `HoverMenuBar` components which makes this version of the `FileMenu` incompatible with the previous version. Apps will be need to update their toolbar and file menu before using this version of analytics. --- .github/workflows/node-publish.yml | 2 +- .github/workflows/node-test.yml | 2 +- config/setupTestingLibrary.js | 5 + i18n/en.pot | 7 +- jest.config.js | 5 +- package.json | 2 + src/__demo__/FileMenu.stories.js | 29 +- src/__demo__/Toolbar.stories.js | 78 +++ src/components/FileMenu/FileMenu.js | 326 +++++------- .../FileMenu/__tests__/FileMenu.spec.js | 503 +++++++++--------- .../Options/VisualizationOptions.js | 10 +- .../Toolbar/HoverMenuBar/HoverMenuBar.js | 118 ++++ .../Toolbar/HoverMenuBar/HoverMenuDropdown.js | 49 ++ .../Toolbar/HoverMenuBar/HoverMenuList.js | 97 ++++ .../Toolbar/HoverMenuBar/HoverMenuListItem.js | 95 ++++ .../HoverMenuBar/HoverMenuListItem.styles.js | 91 ++++ .../__tests__/HoverMenuBar.spec.js | 256 +++++++++ .../__tests__/HoverMenuDropdown.spec.js | 20 + .../__tests__/HoverMenuList.spec.js | 56 ++ .../__tests__/HoverMenuListItem.spec.js | 39 ++ src/components/Toolbar/HoverMenuBar/index.js | 4 + .../InterpretationsAndDetailsToggler.js | 34 ++ src/components/Toolbar/MenuButton.styles.js | 37 ++ src/components/Toolbar/Toolbar.js | 29 + src/components/Toolbar/ToolbarSidebar.js | 31 ++ src/components/Toolbar/UpdateButton.js | 39 ++ .../InterpretationsAndDetailsToggler.spec.js | 50 ++ .../Toolbar/__tests__/Toolbar.spec.js | 18 + .../Toolbar/__tests__/ToolbarSidebar.spec.js | 23 + .../Toolbar/__tests__/UpdateButton.spec.js | 34 ++ src/components/Toolbar/index.js | 5 + src/index.js | 2 + yarn.lock | 202 ++++++- 33 files changed, 1841 insertions(+), 457 deletions(-) create mode 100644 config/setupTestingLibrary.js create mode 100644 src/__demo__/Toolbar.stories.js create mode 100644 src/components/Toolbar/HoverMenuBar/HoverMenuBar.js create mode 100644 src/components/Toolbar/HoverMenuBar/HoverMenuDropdown.js create mode 100644 src/components/Toolbar/HoverMenuBar/HoverMenuList.js create mode 100644 src/components/Toolbar/HoverMenuBar/HoverMenuListItem.js create mode 100644 src/components/Toolbar/HoverMenuBar/HoverMenuListItem.styles.js create mode 100644 src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuBar.spec.js create mode 100644 src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuDropdown.spec.js create mode 100644 src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuList.spec.js create mode 100644 src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuListItem.spec.js create mode 100644 src/components/Toolbar/HoverMenuBar/index.js create mode 100644 src/components/Toolbar/InterpretationsAndDetailsToggler.js create mode 100644 src/components/Toolbar/MenuButton.styles.js create mode 100644 src/components/Toolbar/Toolbar.js create mode 100644 src/components/Toolbar/ToolbarSidebar.js create mode 100644 src/components/Toolbar/UpdateButton.js create mode 100644 src/components/Toolbar/__tests__/InterpretationsAndDetailsToggler.spec.js create mode 100644 src/components/Toolbar/__tests__/Toolbar.spec.js create mode 100644 src/components/Toolbar/__tests__/ToolbarSidebar.spec.js create mode 100644 src/components/Toolbar/__tests__/UpdateButton.spec.js create mode 100644 src/components/Toolbar/index.js diff --git a/.github/workflows/node-publish.yml b/.github/workflows/node-publish.yml index 5e04dd7ec..76aaddfaf 100644 --- a/.github/workflows/node-publish.yml +++ b/.github/workflows/node-publish.yml @@ -32,7 +32,7 @@ jobs: token: ${{env.GH_TOKEN}} - uses: actions/setup-node@v1 with: - node-version: 12.x + node-version: 16.x - name: Install run: yarn install --frozen-lockfile diff --git a/.github/workflows/node-test.yml b/.github/workflows/node-test.yml index 27930a8f7..efa5999b7 100644 --- a/.github/workflows/node-test.yml +++ b/.github/workflows/node-test.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v2 - uses: actions/setup-node@v1 with: - node-version: 12.x + node-version: 16.x - name: Install run: yarn install --frozen-lockfile diff --git a/config/setupTestingLibrary.js b/config/setupTestingLibrary.js new file mode 100644 index 000000000..446c378d5 --- /dev/null +++ b/config/setupTestingLibrary.js @@ -0,0 +1,5 @@ +import { configure } from '@testing-library/dom' + +configure({ + testIdAttribute: 'data-test', +}) diff --git a/i18n/en.pot b/i18n/en.pot index 549b42735..2ceaf2ec9 100644 --- a/i18n/en.pot +++ b/i18n/en.pot @@ -5,8 +5,8 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2023-04-18T08:41:27.838Z\n" -"PO-Revision-Date: 2023-04-18T08:41:27.838Z\n" +"POT-Creation-Date: 2023-05-24T12:55:52.925Z\n" +"PO-Revision-Date: 2023-05-24T12:55:52.925Z\n" msgid "view only" msgstr "view only" @@ -856,6 +856,9 @@ msgstr "Financial Years" msgid "Years" msgstr "Years" +msgid "Interpretations and details" +msgstr "Interpretations and details" + msgid "Translating to" msgstr "Translating to" diff --git a/jest.config.js b/jest.config.js index 83db82633..59289738e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,7 @@ module.exports = { testPathIgnorePatterns: ['/node_modules/', '/build/'], - setupFilesAfterEnv: ['/config/setupEnzyme.js'], + setupFilesAfterEnv: [ + '/config/setupEnzyme.js', + '/config/setupTestingLibrary.js', + ], } diff --git a/package.json b/package.json index a4db848d2..f6b84fd20 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,8 @@ "@storybook/addons": "^6.5.9", "@storybook/preset-create-react-app": "^3.1.7", "@storybook/react": "^6.1.14", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^12.1.5", "enzyme": "^3.9.0", "enzyme-adapter-react-16": "^1.15.6", "fs-extra": "^10.1.0", diff --git a/src/__demo__/FileMenu.stories.js b/src/__demo__/FileMenu.stories.js index f8b821002..98092feac 100644 --- a/src/__demo__/FileMenu.stories.js +++ b/src/__demo__/FileMenu.stories.js @@ -2,6 +2,7 @@ import { Provider } from '@dhis2/app-runtime' import { storiesOf } from '@storybook/react' import React from 'react' import { FileMenu } from '../components/FileMenu/FileMenu.js' +import { HoverMenuBar } from '../components/Toolbar/index.js' const configMock = { baseUrl: 'http://localhost:8080', @@ -61,24 +62,30 @@ const visReadonlyObject = { storiesOf('FileMenu', module) .add('Simple', () => ( - + + + )) .add('With AO', () => ( - + + + )) .add('With readonly AO', () => ( - + + + )) diff --git a/src/__demo__/Toolbar.stories.js b/src/__demo__/Toolbar.stories.js new file mode 100644 index 000000000..14d50fb34 --- /dev/null +++ b/src/__demo__/Toolbar.stories.js @@ -0,0 +1,78 @@ +import { storiesOf } from '@storybook/react' +import React, { useState } from 'react' +import { + HoverMenuBar, + HoverMenuDropdown, + HoverMenuList, + HoverMenuListItem, + InterpretationsAndDetailsToggler, + Toolbar, + ToolbarSidebar, + UpdateButton, +} from '../components/Toolbar/index.js' + +function ToolbarWithState() { + const [isHidden, setIsHidden] = useState(false) + const [isSidebarShowing, setIsSidebarShowing] = useState(false) + return ( + + + Toolbar side bar + setIsHidden(true)} + > + click to hide + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setIsSidebarShowing((current) => !current)} + /> + + ) +} + +storiesOf('Toolbar', module).add('default', () => { + return +}) diff --git a/src/components/FileMenu/FileMenu.js b/src/components/FileMenu/FileMenu.js index 588639a2d..bc0c3766a 100644 --- a/src/components/FileMenu/FileMenu.js +++ b/src/components/FileMenu/FileMenu.js @@ -9,19 +9,19 @@ import { IconDelete24, SharingDialog, colors, - FlyoutMenu, - Layer, - MenuItem, MenuDivider, - Popper, } from '@dhis2/ui' import PropTypes from 'prop-types' -import React, { createRef, useState } from 'react' +import React, { useState } from 'react' import i18n from '../../locales/index.js' import { OpenFileDialog } from '../OpenFileDialog/OpenFileDialog.js' +import { + HoverMenuListItem, + HoverMenuList, + HoverMenuDropdown, +} from '../Toolbar/index.js' import { TranslationDialog } from '../TranslationDialog/index.js' import { DeleteDialog } from './DeleteDialog.js' -import { fileMenuStyles } from './FileMenu.styles.js' import { GetLinkDialog } from './GetLinkDialog.js' import { RenameDialog } from './RenameDialog.js' import { SaveAsDialog } from './SaveAsDialog.js' @@ -43,21 +43,11 @@ export const FileMenu = ({ onError, onTranslate, }) => { - const [menuIsOpen, setMenuIsOpen] = useState(false) const [currentDialog, setCurrentDialog] = useState(null) - - // Escape key press closes the menu - const onKeyDown = (e) => { - if (e?.keyCode === 27) { - setMenuIsOpen(false) - } - } const onMenuItemClick = (dialogToOpen) => () => { - setMenuIsOpen(false) setCurrentDialog(dialogToOpen) } const onDialogClose = () => setCurrentDialog(null) - const toggleMenu = () => setMenuIsOpen(!menuIsOpen) const onDeleteConfirm = () => { // The dialog must be closed before calling the callback // otherwise the fileObject is changed to null before the @@ -67,8 +57,6 @@ export const FileMenu = ({ onDelete() } - const buttonRef = createRef() - const renderDialog = () => { switch (currentDialog) { case 'rename': @@ -138,17 +126,7 @@ export const FileMenu = ({ const iconInactiveColor = colors.grey500 return ( -
- -
- -
+ <> - {menuIsOpen && ( - - - - } - onClick={() => { - toggleMenu() - onNew() - }} - dataTest="file-menu-new" - /> - - } - onClick={onMenuItemClick('open')} - dataTest="file-menu-open" - /> - - } - disabled={ + + + } + onClick={onNew} + dataTest="file-menu-new" + /> + + } + onClick={onMenuItemClick('open')} + dataTest="file-menu-open" + /> + { - toggleMenu() - onSave() - } - : onMenuItemClick('saveas') - } - dataTest="file-menu-save" /> - + } + disabled={ + !onSave || + !(!fileObject?.id || fileObject?.access?.update) + } + onClick={ + fileObject?.id ? onSave : onMenuItemClick('saveas') + } + dataTest="file-menu-save" + /> + - - } - disabled={ - !( - fileObject?.id && - fileObject?.access?.update - ) + } + disabled={!(onSaveAs && fileObject?.id)} + onClick={onMenuItemClick('saveas')} + dataTest="file-menu-saveas" + /> + - - } - disabled={ - !( - fileObject?.id && - fileObject?.access?.update - ) + } + disabled={ + !(fileObject?.id && fileObject?.access?.update) + } + onClick={onMenuItemClick('rename')} + dataTest="file-menu-rename" + /> + - - - } - disabled={ - !( - fileObject?.id && - fileObject?.access?.manage - ) + } + disabled={ + !(fileObject?.id && fileObject?.access?.update) + } + onClick={onMenuItemClick('translate')} + dataTest="file-menu-translate" + /> + + - + } + disabled={ + !(fileObject?.id && fileObject?.access?.manage) + } + onClick={onMenuItemClick('sharing')} + dataTest="file-menu-sharing" + /> + - - - } - disabled={ - !( - fileObject?.id && - fileObject?.access?.delete - ) + } + disabled={!fileObject?.id} + onClick={onMenuItemClick('getlink')} + dataTest="file-menu-getlink" + /> + + - - - - )} + } + disabled={ + !(fileObject?.id && fileObject?.access?.delete) + } + onClick={onMenuItemClick('delete')} + dataTest="file-menu-delete" + /> + + {renderDialog()} -
+ ) } diff --git a/src/components/FileMenu/__tests__/FileMenu.spec.js b/src/components/FileMenu/__tests__/FileMenu.spec.js index 3e56fdacc..fefe5b5a3 100644 --- a/src/components/FileMenu/__tests__/FileMenu.spec.js +++ b/src/components/FileMenu/__tests__/FileMenu.spec.js @@ -1,18 +1,24 @@ -import { SharingDialog } from '@dhis2/ui' -import { shallow } from 'enzyme' +import { CustomDataProvider } from '@dhis2/app-runtime' +import { render, fireEvent, screen, getByText } from '@testing-library/react' +import '@testing-library/jest-dom' import React from 'react' -import { OpenFileDialog } from '../../OpenFileDialog/OpenFileDialog.js' -import { TranslationDialog } from '../../TranslationDialog/index.js' -import { DeleteDialog } from '../DeleteDialog.js' +import { HoverMenuBar } from '../../Toolbar/index.js' import { FileMenu } from '../FileMenu.js' -import { GetLinkDialog } from '../GetLinkDialog.js' -import { RenameDialog } from '../RenameDialog.js' -import { SaveAsDialog } from '../SaveAsDialog.js' -describe('The FileMenu component ', () => { - let shallowFileMenu - let props +jest.mock( + '../../TranslationDialog/TranslationModal/useTranslationsResults.js', + () => ({ + /* This will keep the translation dialog in + * a loading state, which prevents it from + * throwing other errors */ + useTranslationsResults: () => ({ + translationsData: undefined, + fetching: true, + }), + }) +) +describe('The FileMenu component ', () => { const onDelete = jest.fn() const onError = jest.fn() const onNew = jest.fn() @@ -23,308 +29,317 @@ describe('The FileMenu component ', () => { const onShare = jest.fn() const onTranslate = jest.fn() - const getFileMenuComponent = (props) => { - if (!shallowFileMenu) { - shallowFileMenu = shallow() - } - return shallowFileMenu + const baseProps = { + currentUser: { id: 'u1', displayName: 'Test user' }, + fileType: 'visualization', + fileObject: undefined, + onDelete, + onError, + onNew, + onOpen, + onRename, + onSave, + onSaveAs, + onShare, + onTranslate, } - beforeEach(() => { - shallowFileMenu = undefined - props = { - currentUser: { id: 'u1', displayName: 'Test user' }, - fileType: 'visualization', - fileObject: undefined, - onDelete, - onError, - onNew, - onOpen, - onRename, - onSave, - onSaveAs, - onShare, - onTranslate, - } - }) - - it('renders a button', () => { - expect(getFileMenuComponent(props).find('button')).toHaveLength(1) - }) - - it('renders some enabled buttons regardless of the access settings', () => { - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') - - const buttonLabels = ['New', 'Open…'] - - buttonLabels.forEach((buttonLabel) => - expect( - fileMenuComponent - .findWhere((n) => n.prop('label') === buttonLabel) - .prop('disabled') - ).toBe(undefined) - ) - }) - - it('renders some disabled buttons when no fileObject is present', () => { - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') - - const buttonLabels = [ - 'Save as…', - 'Rename…', - 'Translate…', - 'Share…', - 'Get link…', - 'Delete', - ] - - buttonLabels.forEach((buttonLabel) => - expect( - fileMenuComponent - .findWhere((n) => n.prop('label') === buttonLabel) - .prop('disabled') - ).toBe(true) - ) - }) - - it('renders some enabled buttons when update access is granted', () => { - props.fileObject = { + const fullAccessProps = { + fileObject: { id: 'test', access: { - delete: false, - manage: false, + delete: true, + manage: true, update: true, }, + href: 'http://dhis2.org', + }, + } + + const renderFileMenu = (customProps = {}) => { + const props = { ...baseProps, ...customProps } + const providerData = { + translations: { + translations: {}, + }, + sharing: { + meta: { + allowPublicAccess: true, + }, + object: { + userAccesses: [], + userGroupAccesses: [], + }, + }, } - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') + return render( + + + + + + ) + } - const buttonLabels = ['Save', 'Rename…', 'Translate…'] + const openDropdown = async () => { + fireEvent.click(screen.getByTestId('dhis2-analytics-hovermenudropdown')) + expect(await screen.findByTestId('file-menu-container')).toBeVisible() + } - buttonLabels.forEach((buttonLabel) => - expect( - fileMenuComponent - .findWhere((n) => n.prop('label') === buttonLabel) - .prop('disabled') - ).toBe(false) - ) - }) + const MENU_ITEMS = { + NEW: { testId: 'file-menu-new', text: 'New' }, + OPEN: { testId: 'file-menu-open', text: 'Open…' }, + SAVE: { testId: 'file-menu-save', text: 'Save' }, + SAVE_AS: { testId: 'file-menu-saveas', text: 'Save as…' }, + RENAME: { testId: 'file-menu-rename', text: 'Rename…' }, + TRANSLATE: { testId: 'file-menu-translate', text: 'Translate…' }, + SHARE: { testId: 'file-menu-sharing', text: 'Share…' }, + GET_LINK: { testId: 'file-menu-getlink', text: 'Get link…' }, + DELETE: { testId: 'file-menu-delete', text: 'Delete' }, + } - it('renders enabled Delete button when delete access is granted', () => { - props.fileObject = { - id: 'test', - access: { - delete: true, - manage: false, - update: false, - }, - } + const assertMenuItemsDisabledState = (menuItems) => { + for (const menuTitem of menuItems) { + const li = screen.getByTestId(menuTitem.testId) + expect(getByText(li, menuTitem.text)).toBeVisible() - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') + if (menuTitem.disabled) { + expect(li).toHaveClass('disabled') + } else { + expect(li).not.toHaveClass('disabled') + } + } + } + it('renders a button', () => { + renderFileMenu() expect( - fileMenuComponent - .findWhere((n) => n.prop('label') === 'Delete') - .prop('disabled') - ).toBe(false) - }) + screen.getAllByTestId('dhis2-analytics-hovermenudropdown') + ).toHaveLength(1) - it('renders enabled Share button when manage access is granted', () => { - props.fileObject = { - id: 'test', - access: { - delete: false, - manage: true, - update: false, - }, - } + const button = screen.getByTestId('dhis2-analytics-hovermenudropdown') + expect(button).toBeVisible() + expect(button).toHaveTextContent('File') + }) - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') + it('opens when clicking the button', async () => { + renderFileMenu() expect( - fileMenuComponent - .findWhere((n) => n.prop('label') === 'Share…') - .prop('disabled') - ).toBe(false) + screen.queryByTestId('file-menu-container') + ).not.toBeInTheDocument() + + await openDropdown() + + expect(await screen.findByTestId('file-menu-container')).toBeVisible() }) - it('renders the OpenFileDialog component when the Open button is clicked', () => { - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') + it('renders some enabled buttons regardless of the access settings', async () => { + renderFileMenu() + await openDropdown() - fileMenuComponent - .findWhere((n) => n.prop('label') === 'Open…') - .simulate('click') + assertMenuItemsDisabledState([ + { ...MENU_ITEMS.NEW, disabled: false }, + { ...MENU_ITEMS.OPEN, disabled: false }, + ]) + }) - expect(fileMenuComponent.find(OpenFileDialog)).toHaveLength(1) + it('renders some disabled buttons when no fileObject is present', async () => { + renderFileMenu() + await openDropdown() + + assertMenuItemsDisabledState([ + { ...MENU_ITEMS.SAVE_AS, disabled: true }, + { ...MENU_ITEMS.RENAME, disabled: true }, + { ...MENU_ITEMS.TRANSLATE, disabled: true }, + { ...MENU_ITEMS.SHARE, disabled: true }, + { ...MENU_ITEMS.GET_LINK, disabled: true }, + { ...MENU_ITEMS.DELETE, disabled: true }, + ]) }) - it('renders the RenameDialog when the Rename button is clicked', () => { - props.fileObject = { - id: 'test', - access: { - delete: true, - manage: true, - update: true, + it('renders some enabled buttons when update access is granted', async () => { + const customProps = { + fileObject: { + id: 'test', + access: { + delete: false, + manage: false, + update: true, + }, }, } - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') + renderFileMenu(customProps) + await openDropdown() - fileMenuComponent - .findWhere((n) => n.prop('label') === 'Rename…') - .simulate('click') - - expect(fileMenuComponent.find(RenameDialog)).toHaveLength(1) + assertMenuItemsDisabledState([ + { ...MENU_ITEMS.SAVE, disabled: false }, + { ...MENU_ITEMS.RENAME, disabled: false }, + { ...MENU_ITEMS.TRANSLATE, disabled: false }, + ]) }) - it('renders the TranslationDialog when the Translate button is clicked', () => { - props.fileObject = { - id: 'test', - access: { - delete: true, - manage: true, - update: true, + it('renders enabled Delete button when delete access is granted', async () => { + const customProps = { + fileObject: { + id: 'test', + access: { + delete: true, + manage: false, + update: false, + }, }, } - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') - - fileMenuComponent - .findWhere((n) => n.prop('label') === 'Translate…') - .simulate('click') + renderFileMenu(customProps) + await openDropdown() - expect(fileMenuComponent.find(TranslationDialog)).toHaveLength(1) + assertMenuItemsDisabledState([ + { ...MENU_ITEMS.DELETE, disabled: false }, + ]) }) - it('renders the SharingDialog when the Share button is clicked', () => { - props.fileObject = { - id: 'test', - access: { - delete: true, - manage: true, - update: true, + it('renders enabled Share button when manage access is granted', async () => { + const customProps = { + fileObject: { + id: 'test', + access: { + delete: false, + manage: true, + update: false, + }, }, } - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') + renderFileMenu(customProps) + await openDropdown() + + assertMenuItemsDisabledState([{ ...MENU_ITEMS.SHARE, disabled: false }]) + }) - fileMenuComponent - .findWhere((n) => n.prop('label') === 'Share…') - .simulate('click') + it('renders the OpenFileDialog component when the Open button is clicked', async () => { + renderFileMenu() + await openDropdown() + fireEvent.click(screen.getByTestId(MENU_ITEMS.OPEN.testId)) - expect(fileMenuComponent.find(SharingDialog)).toHaveLength(1) + expect( + await screen.findByText('Open a visualization', { selector: 'h1' }) + ).toBeVisible() }) - it('renders the GetLinkDialog when the Get link button is clicked', () => { - props.fileObject = { - id: 'test', - access: { - delete: true, - manage: true, - update: true, - }, - } + it('renders the RenameDialog when the Rename button is clicked', async () => { + renderFileMenu(fullAccessProps) + await openDropdown() + fireEvent.click(screen.getByTestId(MENU_ITEMS.RENAME.testId)) - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') + expect( + await screen.findByText('Rename visualization', { selector: 'h1' }) + ).toBeVisible() + }) - fileMenuComponent - .findWhere((n) => n.prop('label') === 'Get link…') - .simulate('click') + it('renders the TranslationDialog when the Translate button is clicked', async () => { + renderFileMenu(fullAccessProps) + await openDropdown() + fireEvent.click(screen.getByTestId(MENU_ITEMS.TRANSLATE.testId)) - expect(fileMenuComponent.find(GetLinkDialog)).toHaveLength(1) + expect( + await screen.findByText('Translate', { + exact: false, + selector: 'h1', + }) + ).toBeVisible() }) - it('renders the DeleteDialog when the Delete button is clicked', () => { - props.fileObject = { - id: 'delete-test', - access: { - delete: true, - manage: true, - update: true, - }, - } + it('renders the SharingDialog when the Share button is clicked', async () => { + renderFileMenu(fullAccessProps) + await openDropdown() + fireEvent.click(screen.getByTestId(MENU_ITEMS.SHARE.testId)) - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') + expect( + await screen.findByText('Sharing and access', { selector: 'h1' }) + ).toBeVisible() + }) - fileMenuComponent - .findWhere((n) => n.prop('label') === 'Delete') - .simulate('click') + it('renders the GetLinkDialog when the Get link button is clicked', async () => { + const url = 'http://localhost/dhis-web-data-visualizer/#/test' - expect(fileMenuComponent.find(DeleteDialog)).toHaveLength(1) + renderFileMenu(fullAccessProps) + await openDropdown() + fireEvent.click(screen.getByTestId(MENU_ITEMS.GET_LINK.testId)) + + expect(await screen.findByTestId('dhis2-uicore-modal')).toBeVisible() + expect(screen.getByRole('link', { name: url })).toHaveAttribute( + 'href', + url + ) }) - it('renders the SaveAsDialog when the Save as… button is clicked', () => { - props.fileObject = { - id: 'test', - access: { - delete: true, - manage: true, - update: true, + it('renders the DeleteDialog when the Delete button is clicked', async () => { + const customProps = { + fileObject: { + id: 'delete-test', + access: { + delete: true, + manage: true, + update: true, + }, }, } - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') - - fileMenuComponent - .findWhere((n) => n.prop('label') === 'Save as…') - .simulate('click') + renderFileMenu(customProps) + await openDropdown() + fireEvent.click(screen.getByTestId(MENU_ITEMS.DELETE.testId)) - expect(fileMenuComponent.find(SaveAsDialog)).toHaveLength(1) + expect( + await screen.findByText('Delete visualization', { selector: 'h1' }) + ).toBeVisible() }) - it('renders the SaveAsDialog when the Save… button is clicked but no fileObject is present', () => { - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') - - fileMenuComponent - .findWhere((n) => n.prop('label') === 'Save…') - .simulate('click') + it('renders the SaveAsDialog when the Save as… button is clicked', async () => { + renderFileMenu(fullAccessProps) + await openDropdown() + fireEvent.click(screen.getByTestId(MENU_ITEMS.SAVE_AS.testId)) - expect(fileMenuComponent.find(SaveAsDialog)).toHaveLength(1) + expect( + await screen.findByText('Save visualization as', { selector: 'h1' }) + ).toBeVisible() }) - it('calls the onSave callback when the Save button is clicked and a fileObject is present', () => { - props.fileObject = { - id: 'test', - access: { - delete: true, - manage: true, - update: true, + it('renders the SaveAsDialog when the Save… button is clicked but no fileObject is present', async () => { + const customProps = { + fileObject: { + // NOTE: no `id` field + access: { + update: true, + }, }, } + renderFileMenu(customProps) + await openDropdown() + fireEvent.click(screen.getByTestId(MENU_ITEMS.SAVE.testId)) - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') + expect( + await screen.findByText('Save visualization as', { selector: 'h1' }) + ).toBeVisible() + }) - fileMenuComponent - .findWhere((n) => n.prop('label') === 'Save') - .simulate('click') + it('calls the onSave callback when the Save button is clicked and a fileObject is present', async () => { + renderFileMenu(fullAccessProps) + await openDropdown() + fireEvent.click(screen.getByTestId(MENU_ITEMS.SAVE.testId)) - expect(fileMenuComponent.find(OpenFileDialog).prop('open')).toBe(false) - expect(onSave).toHaveBeenCalled() + expect(screen.queryByText('Open a visualization')).not.toBeVisible() + expect(onSave).toHaveBeenCalledTimes(1) }) - it('calls the onNew callback when the New button is clicked', () => { - const fileMenuComponent = getFileMenuComponent(props) - fileMenuComponent.find('button').simulate('click') - - fileMenuComponent - .findWhere((n) => n.prop('label') === 'New') - .simulate('click') + it('calls the onNew callback when the New button is clicked', async () => { + renderFileMenu() + await openDropdown() + fireEvent.click(screen.getByTestId(MENU_ITEMS.NEW.testId)) - expect(fileMenuComponent.find(OpenFileDialog).prop('open')).toBe(false) - expect(onNew).toHaveBeenCalled() + expect(screen.queryByText('Open a visualization')).not.toBeVisible() + expect(onNew).toHaveBeenCalledTimes(1) }) }) diff --git a/src/components/Options/VisualizationOptions.js b/src/components/Options/VisualizationOptions.js index 7edc2c2f1..3bb3e3174 100644 --- a/src/components/Options/VisualizationOptions.js +++ b/src/components/Options/VisualizationOptions.js @@ -30,8 +30,13 @@ import { tabSectionOptionIcon, } from './styles/VisualizationOptions.style.js' -const VisualizationOptions = ({ optionsConfig, onClose, onUpdate }) => { - const [activeTabKey, setActiveTabKey] = useState() +const VisualizationOptions = ({ + initiallyActiveTabKey, + optionsConfig, + onClose, + onUpdate, +}) => { + const [activeTabKey, setActiveTabKey] = useState(initiallyActiveTabKey) const generateTabContent = (sections) => sections.map(({ key, label, content, helpText }) => ( @@ -144,6 +149,7 @@ const VisualizationOptions = ({ optionsConfig, onClose, onUpdate }) => { VisualizationOptions.propTypes = { optionsConfig: PropTypes.array.isRequired, + initiallyActiveTabKey: PropTypes.string, onClose: PropTypes.func, onUpdate: PropTypes.func, } diff --git a/src/components/Toolbar/HoverMenuBar/HoverMenuBar.js b/src/components/Toolbar/HoverMenuBar/HoverMenuBar.js new file mode 100644 index 000000000..055694e78 --- /dev/null +++ b/src/components/Toolbar/HoverMenuBar/HoverMenuBar.js @@ -0,0 +1,118 @@ +import PropTypes from 'prop-types' +import React, { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react' + +const throwErrorIfNotInitialized = () => { + throw new Error('`HoverMenubarContext` has not been initialised') +} + +const HoverMenubarContext = createContext({ + closeMenu: throwErrorIfNotInitialized, + onDropDownButtonClick: throwErrorIfNotInitialized, + onDropDownButtonMouseOver: throwErrorIfNotInitialized, + setLastHoveredSubMenuEl: throwErrorIfNotInitialized, + openedDropdownEl: null, +}) + +const useHoverMenubarContext = () => useContext(HoverMenubarContext) + +const HoverMenuBar = ({ children, dataTest }) => { + const [openedDropdownEl, setOpenedDropdownEl] = useState(null) + const [lastHoveredSubMenuEl, setLastHoveredSubMenuEl] = useState(null) + const [isInHoverMode, setIsInHoverMode] = useState(false) + + const closeMenu = useCallback(() => { + setIsInHoverMode(false) + setOpenedDropdownEl(null) + }, []) + + const onDocumentClick = useCallback( + (event) => { + const isClickOnOpenedSubMenuAnchor = + lastHoveredSubMenuEl && + (lastHoveredSubMenuEl === event.target || + lastHoveredSubMenuEl.contains(event.target)) + + if (!isClickOnOpenedSubMenuAnchor) { + closeMenu() + } + }, + [closeMenu, lastHoveredSubMenuEl] + ) + + const onDropDownButtonClick = useCallback( + (event) => { + if (!isInHoverMode) { + setIsInHoverMode(true) + setOpenedDropdownEl(event.currentTarget) + } else { + closeMenu() + } + }, + [closeMenu, isInHoverMode] + ) + + const onDropDownButtonMouseOver = useCallback( + (event) => { + if (isInHoverMode) { + setOpenedDropdownEl(event.currentTarget) + } + }, + [isInHoverMode] + ) + + const closeMenuWithEsc = useCallback( + (event) => { + if (event.keyCode === 27) { + closeMenu() + } + }, + [closeMenu] + ) + + useEffect(() => { + if (isInHoverMode) { + document.addEventListener('click', onDocumentClick, { + once: true, + }) + } + + return () => { + document.removeEventListener('click', onDocumentClick) + } + }, [onDocumentClick, isInHoverMode]) + + return ( + +
+ {children} + +
+
+ ) +} + +HoverMenuBar.defaultProps = { + dataTest: 'dhis2-analytics-hovermenubar', +} + +HoverMenuBar.propTypes = { + children: PropTypes.node.isRequired, + dataTest: PropTypes.string, +} +export { HoverMenuBar, useHoverMenubarContext } diff --git a/src/components/Toolbar/HoverMenuBar/HoverMenuDropdown.js b/src/components/Toolbar/HoverMenuBar/HoverMenuDropdown.js new file mode 100644 index 000000000..1664d1473 --- /dev/null +++ b/src/components/Toolbar/HoverMenuBar/HoverMenuDropdown.js @@ -0,0 +1,49 @@ +import { Popper } from '@dhis2-ui/popper' +import { Portal } from '@dhis2-ui/portal' +import PropTypes from 'prop-types' +import React, { useRef } from 'react' +import menuButtonStyles from '../MenuButton.styles.js' +import { useHoverMenubarContext } from './HoverMenuBar.js' + +export const HoverMenuDropdown = ({ children, label, dataTest, disabled }) => { + const buttonRef = useRef() + const { + onDropDownButtonClick, + onDropDownButtonMouseOver, + openedDropdownEl, + } = useHoverMenubarContext() + const isOpen = openedDropdownEl === buttonRef.current + + return ( + <> + + {isOpen && ( + + + {children} + + + )} + + ) +} + +HoverMenuDropdown.defaultProps = { + dataTest: 'dhis2-analytics-hovermenudropdown', +} + +HoverMenuDropdown.propTypes = { + children: PropTypes.node.isRequired, + label: PropTypes.node.isRequired, + dataTest: PropTypes.string, + disabled: PropTypes.bool, +} diff --git a/src/components/Toolbar/HoverMenuBar/HoverMenuList.js b/src/components/Toolbar/HoverMenuBar/HoverMenuList.js new file mode 100644 index 000000000..a9ae02b84 --- /dev/null +++ b/src/components/Toolbar/HoverMenuBar/HoverMenuList.js @@ -0,0 +1,97 @@ +import { colors, elevations, spacers } from '@dhis2/ui-constants' +import PropTypes from 'prop-types' +import React, { createContext, useCallback, useContext, useState } from 'react' +import { useHoverMenubarContext } from './HoverMenuBar.js' + +const throwErrorIfNotInitialized = () => { + throw new Error('`HoverMenuListContext` has not been initialised') +} + +const HoverMenuListContext = createContext({ + onSubmenuAnchorMouseEnter: throwErrorIfNotInitialized, + onMenuItemMouseEnter: throwErrorIfNotInitialized, + openedSubMenuEl: null, + dense: false, +}) + +const useHoverMenuListContext = () => useContext(HoverMenuListContext) + +const HoverMenuList = ({ + children, + className, + dataTest, + dense, + maxHeight, + maxWidth, +}) => { + const { setLastHoveredSubMenuEl } = useHoverMenubarContext() + const [openedSubMenuEl, setOpenedSubMenuEl] = useState(null) + + const onSubmenuAnchorMouseEnter = useCallback( + (event) => { + if (openedSubMenuEl !== event.currentTarget) { + setOpenedSubMenuEl(event.currentTarget) + setLastHoveredSubMenuEl(event.currentTarget) + } + }, + [openedSubMenuEl, setLastHoveredSubMenuEl] + ) + + const onMenuItemMouseEnter = useCallback(() => { + setOpenedSubMenuEl(null) + setLastHoveredSubMenuEl(null) + }, [setLastHoveredSubMenuEl]) + + return ( + +
    + {children} + +
+
+ ) +} + +HoverMenuList.defaultProps = { + dataTest: 'dhis2-analytics-hovermenulist', + maxWidth: '380px', + maxHeight: 'auto', +} + +HoverMenuList.propTypes = { + /** Typically `MenuItem`, `MenuDivider`, and `MenuSectionHeader` */ + children: PropTypes.node, + className: PropTypes.string, + dataTest: PropTypes.string, + /** Gives all HoverMenuListItem children a dense style */ + dense: PropTypes.bool, + maxHeight: PropTypes.string, + maxWidth: PropTypes.string, +} + +export { HoverMenuList, useHoverMenuListContext } diff --git a/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.js b/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.js new file mode 100644 index 000000000..a99bd4cc5 --- /dev/null +++ b/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.js @@ -0,0 +1,95 @@ +import { Popper } from '@dhis2-ui/popper' +import { Portal } from '@dhis2-ui/portal' +import { IconChevronRight24 } from '@dhis2/ui-icons' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React, { useRef } from 'react' +import { HoverMenuList, useHoverMenuListContext } from './HoverMenuList.js' +import styles from './HoverMenuListItem.styles.js' + +const HoverMenuListItem = ({ + onClick, + children, + icon, + className, + destructive, + disabled, + dataTest, + label, +}) => { + const ref = useRef() + const { + onSubmenuAnchorMouseEnter, + onMenuItemMouseEnter, + openedSubMenuEl, + dense, + } = useHoverMenuListContext() + + const isSubMenuOpen = openedSubMenuEl === ref.current + + return ( + <> +
  • + {icon && {icon}} + + {label} + + {!!children && ( + + + + )} + + +
  • + {children && isSubMenuOpen && ( + + + {children} + + + )} + + ) +} + +HoverMenuListItem.defaultProps = { + dataTest: 'dhis2-uicore-hovermenulistitem', +} + +HoverMenuListItem.propTypes = { + // Nested menu items become submenus + children: PropTypes.node, + className: PropTypes.string, + dataTest: PropTypes.string, + destructive: PropTypes.bool, + disabled: PropTypes.bool, + /** An icon for the left side of the menu item */ + icon: PropTypes.node, + /** Text in the menu item */ + label: PropTypes.node, + /** Click handler */ + onClick: PropTypes.func, +} + +export { HoverMenuListItem } diff --git a/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.styles.js b/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.styles.js new file mode 100644 index 000000000..31748d2a5 --- /dev/null +++ b/src/components/Toolbar/HoverMenuBar/HoverMenuListItem.styles.js @@ -0,0 +1,91 @@ +import { colors, spacers } from '@dhis2/ui-constants' +import css from 'styled-jsx/css' + +export default css` + li { + display: flex; + align-items: center; + padding: 0px ${spacers.dp24}; + cursor: pointer; + list-style: none; + background-color: ${colors.white}; + color: ${colors.grey900}; + fill: ${colors.grey900}; + font-size: 14px; + line-height: 16px; + user-select: none; + } + + li:hover { + background-color: ${colors.grey200}; + } + + li:active, + li.active { + background-color: ${colors.grey300}; + } + + li.destructive { + color: ${colors.red700}; + fill: ${colors.red600}; + } + + li.destructive:hover { + background-color: ${colors.red050}; + } + + li.destructive:active, + li.destructive.active { + background-color: ${colors.red100}; + } + + li.disabled { + cursor: not-allowed; + color: ${colors.grey500}; + fill: ${colors.grey500}; + } + + li.disabled:hover { + background-color: ${colors.white}; + } + + .label { + flex-grow: 1; + padding: ${spacers.dp12} 0; + } + + li.dense .label { + padding: ${spacers.dp8} 0; + } + + .icon { + flex-grow: 0; + margin-right: ${spacers.dp12}; + width: 24px; + height: 24px; + } + + .chevron { + display: flex; + align-items: center; + flex-grow: 0; + margin-left: ${spacers.dp24}; + } + + li.dense .icon { + margin-right: ${spacers.dp8}; + width: 16px; + height: 16px; + } + + li .icon > :global(svg) { + width: 24px; + height: 24px; + } + + li.dense .icon > :global(svg), + li .chevron > :global(svg) { + width: 16px; + height: 16px; + } +` diff --git a/src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuBar.spec.js b/src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuBar.spec.js new file mode 100644 index 000000000..9ae361ca4 --- /dev/null +++ b/src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuBar.spec.js @@ -0,0 +1,256 @@ +import '@testing-library/jest-dom' +import { render, fireEvent, screen } from '@testing-library/react' +import { shallow } from 'enzyme' +import React from 'react' +import { + HoverMenuBar, + HoverMenuDropdown, + HoverMenuList, + HoverMenuListItem, +} from '../index.js' + +describe('', () => { + it('renders children', () => { + const childNode = 'text node' + const wrapper = shallow({childNode}) + + expect(wrapper.containsMatchingElement(childNode)).toBe(true) + }) + it('accepts a `dataTest` prop', () => { + const dataTest = 'test' + const wrapper = shallow( + children + ) + + expect(wrapper.find('div').prop('data-test')).toBe(dataTest) + }) + + describe('mouse interactions', () => { + it('does not open on hover before a dropdown anchor is clicked', async () => { + createFullMenuBarWrapper() + fireEvent.mouseOver(screen.getByText('Menu A')) + + await expectMenuItemsInDocument([ + ['Menu item A.1', false], + ['Menu item A.2', false], + ['Menu item A.3', false], + ]) + }) + it('does not open when a disabled dropdown anchor is clicked', async () => { + createFullMenuBarWrapper() + fireEvent.click(screen.getByText('Menu C')) + + await expectMenuItemsInDocument([ + ['Menu item A.1', false], + ['Menu item A.2', false], + ['Menu item A.3', false], + ]) + }) + it('opens menu list when clicked', async () => { + createFullMenuBarWrapper() + fireEvent.click(screen.getByText('Menu A')) + + await expectMenuItemsInDocument([ + ['Menu item A.1', true], + ['Menu item B.1', false], + ['Menu item C.1', false], + ]) + }) + it('responds to hover once open', async () => { + createFullMenuBarWrapper() + fireEvent.click(screen.getByText('Menu A')) + fireEvent.mouseOver(screen.getByText('Menu B')) + + await expectMenuItemsInDocument([ + ['Menu item A.1', false], + ['Menu item B.1', true], + ['Menu item C.1', false], + ]) + }) + it('does not open disabled dropdown on hover in hover mode', async () => { + createFullMenuBarWrapper() + fireEvent.click(screen.getByText('Menu B')) + fireEvent.mouseOver(screen.getByText('Menu C')) + + await expectMenuItemsInDocument([ + ['Menu item B.1', true], + ['Menu item C.1', false], + ]) + }) + it('opens submenus when in hover mode', async () => { + createFullMenuBarWrapper() + fireEvent.click(screen.getByText('Menu B')) + fireEvent.mouseOver(screen.getByText('Menu item B.1')) + + await expectMenuItemsInDocument([ + ['Menu item B.1.1', true], + ['Menu item B.1.2', true], + ['Menu item B.1.3', true], + ['Menu item B.2.1', false], + ['Menu item B.2.2', false], + ['Menu item B.2.3', false], + ]) + + fireEvent.mouseOver(screen.getByText('Menu item B.2')) + + await expectMenuItemsInDocument([ + ['Menu item B.1.1', false], + ['Menu item B.1.2', false], + ['Menu item B.1.3', false], + ['Menu item B.2.1', true], + ['Menu item B.2.2', true], + ['Menu item B.2.3', true], + ]) + }) + it('does not open disabled submenus when in hover mode', async () => { + createFullMenuBarWrapper() + fireEvent.click(screen.getByText('Menu B')) + fireEvent.mouseOver(screen.getByText('Menu item B.2')) + + await expectMenuItemsInDocument([ + ['Menu item B.2.1', true], + ['Menu item B.2.2', true], + ['Menu item B.2.3', true], + ['Menu item B.3.1', false], + ['Menu item B.3.2', false], + ['Menu item B.3.3', false], + ]) + + fireEvent.mouseOver(screen.getByText('Menu item B.3')) + + await expectMenuItemsInDocument([ + ['Menu item B.2.1', true], + ['Menu item B.2.2', true], + ['Menu item B.2.3', true], + ['Menu item B.3.1', false], + ['Menu item B.3.2', false], + ['Menu item B.3.3', false], + ]) + }) + it('closes when clicking on then document', async () => { + createFullMenuBarWrapper() + fireEvent.click(screen.getByText('Menu A')) + + await expectMenuItemsInDocument([['Menu item A.1', true]]) + + fireEvent.click(document) + + await expectMenuItemsInDocument([['Menu item A.1', false]]) + }) + it('stays open when clicking a open submenu anchor', async () => { + createFullMenuBarWrapper() + fireEvent.click(screen.getByText('Menu B')) + + await expectMenuItemsInDocument([['Menu item B.1', true]]) + + fireEvent.mouseOver(screen.getByText('Menu item B.1')) + + await expectMenuItemsInDocument([ + ['Menu item B.1', true], + ['Menu item B.1.1', true], + ['Menu item B.1.2', true], + ['Menu item B.1.3', true], + ]) + + fireEvent.click(screen.getByText('Menu item B.1')) + + await expectMenuItemsInDocument([ + ['Menu item B.1', true], + ['Menu item B.1.1', true], + ['Menu item B.1.2', true], + ['Menu item B.1.3', true], + ]) + }) + it('calls the onClick of the menu item and closes when clicking a menu item', async () => { + const menuItemOnClickSpy = jest.fn() + createFullMenuBarWrapper({ menuItemOnClickSpy }) + fireEvent.click(screen.getByText('Menu A')) + + await expectMenuItemsInDocument([['Menu item A.1', true]]) + + fireEvent.click(screen.getByText('Menu item A.1')) + + expect(menuItemOnClickSpy).toHaveBeenCalledTimes(1) + await expectMenuItemsInDocument([['Menu item A.1', false]]) + }) + + it('calls the onClick of the menu item and closes when clicking a submenu item', async () => { + const subMenuItemOnClickSpy = jest.fn() + createFullMenuBarWrapper({ subMenuItemOnClickSpy }) + + fireEvent.click(screen.getByText('Menu B')) + await expectMenuItemsInDocument([['Menu item B.1', true]]) + + fireEvent.mouseOver(screen.getByText('Menu item B.1')) + await expectMenuItemsInDocument([['Menu item B.1.1', true]]) + + fireEvent.click(screen.getByText('Menu item B.1.1')) + + expect(subMenuItemOnClickSpy).toHaveBeenCalledTimes(1) + await expectMenuItemsInDocument([ + ['Menu item B.1', false], + ['Menu item B.1.1', false], + ['Menu item B.1.1', false], + ]) + }) + }) +}) + +function createFullMenuBarWrapper({ + menuItemOnClickSpy, + subMenuItemOnClickSpy, +} = {}) { + return render( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +async function expectMenuItemsInDocument(items) { + for (const [text, inDocument] of items) { + if (inDocument) { + expect(await screen.findByText(text)).toBeInTheDocument() + } else { + expect(screen.queryByText(text)).not.toBeInTheDocument() + } + } +} diff --git a/src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuDropdown.spec.js b/src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuDropdown.spec.js new file mode 100644 index 000000000..fa050d1f4 --- /dev/null +++ b/src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuDropdown.spec.js @@ -0,0 +1,20 @@ +import { shallow } from 'enzyme' +import React from 'react' +import { HoverMenuDropdown } from '../index.js' + +describe('', () => { + /* Most of the props for this component are included + * in the mouse interaction tests for the HoverMenuBar. + * Only the `dataTest` prop needs to be verified here. */ + + it('accepts a `dataTest` prop', () => { + const dataTest = 'test' + const wrapper = shallow( + + children + + ) + + expect(wrapper.find('button').prop('data-test')).toBe(dataTest) + }) +}) diff --git a/src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuList.spec.js b/src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuList.spec.js new file mode 100644 index 000000000..9b79d9222 --- /dev/null +++ b/src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuList.spec.js @@ -0,0 +1,56 @@ +import { shallow, mount } from 'enzyme' +import React from 'react' +import { HoverMenuList, HoverMenuListItem } from '../index.js' + +describe('', () => { + const dataTest = 'test' + const childNode = 'children' + + it('renders children', () => { + const wrapper = shallow({childNode}) + expect(wrapper.containsMatchingElement(childNode)).toBe(true) + }) + it('accept a `className` prop', () => { + const className = 'className' + const wrapper = shallow( + {childNode} + ) + expect(wrapper.find('ul')).toHaveClassName(className) + }) + + it('accepts a `dataTest` prop', () => { + const wrapper = shallow( + {childNode} + ) + + expect(wrapper.find('ul').prop('data-test')).toBe(dataTest) + }) + + it('accept a `dense` prop', () => { + const wrapper = mount( + + + + + ) + + expect(wrapper.find('li').first()).toHaveClassName('dense') + expect(wrapper.find('li').last()).toHaveClassName('dense') + }) + it('accept a `maxHeight` prop', () => { + const maxHeight = '100000px' + const wrapper = shallow( + {childNode} + ) + expect(wrapper.find('style').text()).toContain( + `max-height: ${maxHeight}` + ) + }) + it('accept a `maxWidth` prop', () => { + const maxWidth = '100000px' + const wrapper = shallow( + {childNode} + ) + expect(wrapper.find('style').text()).toContain(`max-width: ${maxWidth}`) + }) +}) diff --git a/src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuListItem.spec.js b/src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuListItem.spec.js new file mode 100644 index 000000000..4c2503619 --- /dev/null +++ b/src/components/Toolbar/HoverMenuBar/__tests__/HoverMenuListItem.spec.js @@ -0,0 +1,39 @@ +import { shallow } from 'enzyme' +import React from 'react' +import { HoverMenuListItem } from '../index.js' + +describe('', () => { + /* Some of the props for this component are included + * in the mouse interaction tests for the HoverMenuBar. + * Only the `className`, `dataTest`, `destructive` and + * `icon` prop need to be verified here. */ + + it('accepts a `className` prop', () => { + const className = 'className' + const wrapper = shallow() + + expect(wrapper.find('li')).toHaveClassName(className) + }) + + it('accepts a `dataTest` prop', () => { + const dataTest = 'test' + const wrapper = shallow() + + expect(wrapper.find('li').prop('data-test')).toBe(dataTest) + }) + + it('accepts a `destructive` prop', () => { + const wrapper = shallow() + + expect(wrapper.find('li')).toHaveClassName('destructive') + }) + it('accepts an `icon` prop', () => { + const iconText = 'I am an icon' + const icon = {iconText} + const wrapper = shallow() + + expect(wrapper.find('span.icon')).toExist() + expect(wrapper.find('span#testicon')).toExist() + expect(wrapper.find('span#testicon').text()).toBe(iconText) + }) +}) diff --git a/src/components/Toolbar/HoverMenuBar/index.js b/src/components/Toolbar/HoverMenuBar/index.js new file mode 100644 index 000000000..8fb29dba3 --- /dev/null +++ b/src/components/Toolbar/HoverMenuBar/index.js @@ -0,0 +1,4 @@ +export { HoverMenuBar } from './HoverMenuBar.js' +export { HoverMenuDropdown } from './HoverMenuDropdown.js' +export { HoverMenuList } from './HoverMenuList.js' +export { HoverMenuListItem } from './HoverMenuListItem.js' diff --git a/src/components/Toolbar/InterpretationsAndDetailsToggler.js b/src/components/Toolbar/InterpretationsAndDetailsToggler.js new file mode 100644 index 000000000..8bb8abeb3 --- /dev/null +++ b/src/components/Toolbar/InterpretationsAndDetailsToggler.js @@ -0,0 +1,34 @@ +import i18n from '@dhis2/d2-i18n' +import { IconChevronRight24, IconChevronLeft24 } from '@dhis2/ui' +import PropTypes from 'prop-types' +import React from 'react' +import menuButtonStyles from './MenuButton.styles.js' + +export const InterpretationsAndDetailsToggler = ({ + onClick, + dataTest, + disabled, + isShowing, +}) => ( + +) + +InterpretationsAndDetailsToggler.defaultProps = { + dataTest: 'dhis2-analytics-interpretationsanddetailstoggler', +} + +InterpretationsAndDetailsToggler.propTypes = { + onClick: PropTypes.func.isRequired, + dataTest: PropTypes.string, + disabled: PropTypes.bool, + isShowing: PropTypes.bool, +} diff --git a/src/components/Toolbar/MenuButton.styles.js b/src/components/Toolbar/MenuButton.styles.js new file mode 100644 index 000000000..7522ca1d7 --- /dev/null +++ b/src/components/Toolbar/MenuButton.styles.js @@ -0,0 +1,37 @@ +import { colors, spacers, theme } from '@dhis2/ui-constants' +import css from 'styled-jsx/css' + +export default css` + button { + all: unset; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 14px; + padding: 0 ${spacers.dp12}; + color: ${colors.grey900}; + transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms; + cursor: pointer; + } + + button:hover:enabled, + button:active { + background-color: ${colors.grey200}; + } + + button:focus { + outline: 3px solid ${theme.focus}; + outline-offset: -3px; + } + + /* Prevent focus styles when mouse clicking */ + button:focus:not(:focus-visible) { + outline: none; + } + + button:disabled { + color: ${colors.grey500}; + cursor: not-allowed; + } +` diff --git a/src/components/Toolbar/Toolbar.js b/src/components/Toolbar/Toolbar.js new file mode 100644 index 000000000..f6dcce5e7 --- /dev/null +++ b/src/components/Toolbar/Toolbar.js @@ -0,0 +1,29 @@ +import { colors } from '@dhis2/ui-constants' +import PropTypes from 'prop-types' +import React from 'react' + +export const Toolbar = ({ children, dataTest }) => ( +
    + {children} + +
    +) + +Toolbar.defaultProps = { + dataTest: 'dhis2-analytics-toolbar', +} + +Toolbar.propTypes = { + children: PropTypes.node, + dataTest: PropTypes.string, +} diff --git a/src/components/Toolbar/ToolbarSidebar.js b/src/components/Toolbar/ToolbarSidebar.js new file mode 100644 index 000000000..948a9fbed --- /dev/null +++ b/src/components/Toolbar/ToolbarSidebar.js @@ -0,0 +1,31 @@ +import { colors } from '@dhis2/ui-constants' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React from 'react' + +export const ToolbarSidebar = ({ children, dataTest, isHidden }) => ( +
    + {children} + +
    +) + +ToolbarSidebar.defaultProps = { + dataTest: 'dhis2-analytics-toolbarsidebar', +} + +ToolbarSidebar.propTypes = { + children: PropTypes.node, + dataTest: PropTypes.string, + isHidden: PropTypes.bool, +} diff --git a/src/components/Toolbar/UpdateButton.js b/src/components/Toolbar/UpdateButton.js new file mode 100644 index 000000000..fdfaa06e7 --- /dev/null +++ b/src/components/Toolbar/UpdateButton.js @@ -0,0 +1,39 @@ +import { CircularLoader } from '@dhis2-ui/loader' +import i18n from '@dhis2/d2-i18n' +import { colors } from '@dhis2/ui-constants' +import { IconSync16 } from '@dhis2/ui-icons' +import PropTypes from 'prop-types' +import React from 'react' +import menuButtonStyles from './MenuButton.styles.js' + +export const UpdateButton = ({ onClick, disabled, loading, dataTest }) => ( + +) + +UpdateButton.defaultProps = { + dataTest: 'dhis2-analytics-updatebutton', +} + +UpdateButton.propTypes = { + onClick: PropTypes.func.isRequired, + dataTest: PropTypes.string, + disabled: PropTypes.bool, + loading: PropTypes.bool, +} diff --git a/src/components/Toolbar/__tests__/InterpretationsAndDetailsToggler.spec.js b/src/components/Toolbar/__tests__/InterpretationsAndDetailsToggler.spec.js new file mode 100644 index 000000000..4477e19d8 --- /dev/null +++ b/src/components/Toolbar/__tests__/InterpretationsAndDetailsToggler.spec.js @@ -0,0 +1,50 @@ +import { shallow } from 'enzyme' +import React from 'react' +import { InterpretationsAndDetailsToggler } from '../index.js' + +describe('', () => { + const noop = () => {} + + it('accepts an `onClick` prop', () => { + const onClick = jest.fn() + const wrapper = shallow( + + ) + + wrapper.simulate('click') + + expect(onClick).toHaveBeenCalledTimes(1) + }) + it('accepts a `dataTest` prop', () => { + const dataTest = 'test' + const wrapper = shallow( + + ) + + expect(wrapper.prop('data-test')).toBe(dataTest) + }) + it('accepts a `disabled` prop', () => { + const wrapper = shallow( + + ) + + expect(wrapper.find('button').prop('disabled')).toEqual(true) + }) + it('accepts an `isShowing` prop', () => { + const wrapper = shallow( + + ) + const wrapperWithIsShowing = shallow( + + ) + + expect(wrapper.find('SvgChevronRight24')).toHaveLength(0) + expect(wrapper.find('SvgChevronLeft24')).toHaveLength(1) + + expect(wrapperWithIsShowing.find('SvgChevronRight24')).toHaveLength(1) + expect(wrapperWithIsShowing.find('SvgChevronLeft24')).toHaveLength(0) + }) +}) diff --git a/src/components/Toolbar/__tests__/Toolbar.spec.js b/src/components/Toolbar/__tests__/Toolbar.spec.js new file mode 100644 index 000000000..bfd65785a --- /dev/null +++ b/src/components/Toolbar/__tests__/Toolbar.spec.js @@ -0,0 +1,18 @@ +import { shallow } from 'enzyme' +import React from 'react' +import { Toolbar } from '../index.js' + +describe('', () => { + it('renders children', () => { + const childNode = 'text node' + const wrapper = shallow({childNode}) + + expect(wrapper.containsMatchingElement(childNode)).toBe(true) + }) + it('accepts a `dataTest` prop', () => { + const dataTest = 'test' + const wrapper = shallow() + + expect(wrapper.prop('data-test')).toBe(dataTest) + }) +}) diff --git a/src/components/Toolbar/__tests__/ToolbarSidebar.spec.js b/src/components/Toolbar/__tests__/ToolbarSidebar.spec.js new file mode 100644 index 000000000..7801ec550 --- /dev/null +++ b/src/components/Toolbar/__tests__/ToolbarSidebar.spec.js @@ -0,0 +1,23 @@ +import { shallow } from 'enzyme' +import React from 'react' +import { ToolbarSidebar } from '../index.js' + +describe('', () => { + it('renders children', () => { + const childNode = 'text node' + const wrapper = shallow({childNode}) + + expect(wrapper.containsMatchingElement(childNode)).toBe(true) + }) + it('accepts a `dataTest` prop', () => { + const dataTest = 'test' + const wrapper = shallow() + + expect(wrapper.prop('data-test')).toBe(dataTest) + }) + it('accepts a `isHidden` prop', () => { + const wrapper = shallow() + + expect(wrapper.find('div').hasClass('isHidden')).toEqual(true) + }) +}) diff --git a/src/components/Toolbar/__tests__/UpdateButton.spec.js b/src/components/Toolbar/__tests__/UpdateButton.spec.js new file mode 100644 index 000000000..3be73c6b4 --- /dev/null +++ b/src/components/Toolbar/__tests__/UpdateButton.spec.js @@ -0,0 +1,34 @@ +import { shallow } from 'enzyme' +import React from 'react' +import { UpdateButton } from '../index.js' + +describe('', () => { + const noop = () => {} + + it('accepts an `onClick` prop', () => { + const onClick = jest.fn() + const wrapper = shallow() + + wrapper.simulate('click') + + expect(onClick).toHaveBeenCalledTimes(1) + }) + it('accepts a `dataTest` prop', () => { + const dataTest = 'test' + const wrapper = shallow( + + ) + + expect(wrapper.prop('data-test')).toBe(dataTest) + }) + it('accepts a `disabled` prop', () => { + const wrapper = shallow() + + expect(wrapper.find('button').prop('disabled')).toEqual(true) + }) + it('accepts an `loading` prop', () => { + const wrapper = shallow() + + expect(wrapper.find('CircularLoader')).toHaveLength(1) + }) +}) diff --git a/src/components/Toolbar/index.js b/src/components/Toolbar/index.js new file mode 100644 index 000000000..86d6df30a --- /dev/null +++ b/src/components/Toolbar/index.js @@ -0,0 +1,5 @@ +export { InterpretationsAndDetailsToggler } from './InterpretationsAndDetailsToggler.js' +export { Toolbar } from './Toolbar.js' +export { ToolbarSidebar } from './ToolbarSidebar.js' +export { UpdateButton } from './UpdateButton.js' +export * from './HoverMenuBar/index.js' diff --git a/src/index.js b/src/index.js index e21a38df7..069656b77 100644 --- a/src/index.js +++ b/src/index.js @@ -32,6 +32,8 @@ export { default as AboutAOUnit } from './components/AboutAOUnit/AboutAOUnit.js' export { InterpretationsUnit } from './components/Interpretations/InterpretationsUnit/InterpretationsUnit.js' export { InterpretationModal } from './components/Interpretations/InterpretationModal/InterpretationModal.js' +export * from './components/Toolbar/index.js' + export { TranslationDialog } from './components/TranslationDialog/index.js' export { OfflineTooltip } from './components/OfflineTooltip.js' diff --git a/yarn.lock b/yarn.lock index 77e2352c9..496096711 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,11 @@ # yarn lockfile v1 +"@adobe/css-tools@^4.0.1": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.2.0.tgz#e1a84fca468f4b337816fcb7f0964beb620ba855" + integrity sha512-E09FiIft46CmH5Qnjb0wsW54/YQd69LsxeKUOWawmws1XWvyFGURnAChH0mlr7YPFR1ofwvUQfcL0J3lMxXqPA== + "@ampproject/remapping@^2.1.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" @@ -2658,6 +2663,13 @@ "@types/node" "*" jest-mock "^27.5.1" +"@jest/expect-utils@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.5.0.tgz#f74fad6b6e20f924582dc8ecbf2cb800fe43a036" + integrity sha512-fmKzsidoXQT2KwnrwE0SQq3uj8Z763vzR8LnLBwC2qYWEFpjX8daRsk6rHUM1QvNlEW/UJXNXm59ztmJJWs2Mg== + dependencies: + jest-get-type "^29.4.3" + "@jest/fake-timers@^24.9.0": version "24.9.0" resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-24.9.0.tgz#ba3e6bf0eecd09a636049896434d306636540c93" @@ -2772,6 +2784,13 @@ terminal-link "^2.0.0" v8-to-istanbul "^8.1.0" +"@jest/schemas@^29.4.3": + version "29.4.3" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.4.3.tgz#39cf1b8469afc40b6f5a2baaa146e332c4151788" + integrity sha512-VLYKXQmtmuEz6IxJsrZwzG9NvtkQsWNnWMsKxqWNu3+CnfzJQhp0WDDKWLVV9hLKr0l3SLLFRqcYHjhtyuDVxg== + dependencies: + "@sinclair/typebox" "^0.25.16" + "@jest/source-map@^24.9.0": version "24.9.0" resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-24.9.0.tgz#0e263a94430be4b41da683ccc1e6bffe2a191714" @@ -2944,6 +2963,18 @@ "@types/yargs" "^16.0.0" chalk "^4.0.0" +"@jest/types@^29.5.0": + version "29.5.0" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.5.0.tgz#f59ef9b031ced83047c67032700d8c807d6e1593" + integrity sha512-qbu7kN6czmVRc3xWFQcAN03RAUamgppVUdXrvl1Wr3jlNF93o9mJbGcDWrwGB6ht44u7efB1qCFgVQmca24Uog== + dependencies: + "@jest/schemas" "^29.4.3" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + "@jridgewell/gen-mapping@^0.1.0": version "0.1.1" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" @@ -3157,6 +3188,11 @@ "@storybook/react" "^5.3.3" uuid "^3.1.0" +"@sinclair/typebox@^0.25.16": + version "0.25.24" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.25.24.tgz#8c7688559979f7079aacaf31aa881c3aa410b718" + integrity sha512-XJfwUVUKDHF5ugKwIcxEgc9k8b7HbznCp6eUfWgu710hMPNIO4aw4/zB5RogDQz8nd6gyCDpU9O/m6qYEWY6yQ== + "@sindresorhus/is@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" @@ -4291,6 +4327,21 @@ lz-string "^1.4.4" pretty-format "^27.0.2" +"@testing-library/jest-dom@^5.16.5": + version "5.16.5" + resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz#3912846af19a29b2dbf32a6ae9c31ef52580074e" + integrity sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA== + dependencies: + "@adobe/css-tools" "^4.0.1" + "@babel/runtime" "^7.9.2" + "@types/testing-library__jest-dom" "^5.9.1" + aria-query "^5.0.0" + chalk "^3.0.0" + css.escape "^1.5.1" + dom-accessibility-api "^0.5.6" + lodash "^4.17.15" + redent "^3.0.0" + "@testing-library/react@^12.1.2": version "12.1.2" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.2.tgz#f1bc9a45943461fa2a598bb4597df1ae044cfc76" @@ -4299,6 +4350,15 @@ "@babel/runtime" "^7.12.5" "@testing-library/dom" "^8.0.0" +"@testing-library/react@^12.1.5": + version "12.1.5" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" + integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^8.0.0" + "@types/react-dom" "<18.0.0" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -4460,6 +4520,14 @@ dependencies: "@types/istanbul-lib-report" "*" +"@types/jest@*": + version "29.5.1" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.1.tgz#83c818aa9a87da27d6da85d3378e5a34d2f31a47" + integrity sha512-tEuVcHrpaixS36w7hpsfLBLpjtMRJUE09/MHXn923LOVojDwyC14cWcfc0rDs0VEfUyYmt/+iX1kxxp+gZMcaQ== + dependencies: + expect "^29.0.0" + pretty-format "^29.0.0" + "@types/json-schema@*", "@types/json-schema@^7.0.3", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8": version "7.0.11" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" @@ -4568,6 +4636,13 @@ "@types/react" "*" "@types/reactcss" "*" +"@types/react-dom@<18.0.0": + version "17.0.20" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.20.tgz#e0c8901469d732b36d8473b40b679ad899da1b53" + integrity sha512-4pzIjSxDueZZ90F52mU3aPoogkHIoSIDG+oQ+wQK7Cy2B9S+MvOqY0uEA/qawKz381qrEDkvpwyt8Bm31I8sbA== + dependencies: + "@types/react" "^17" + "@types/react-syntax-highlighter@11.0.4": version "11.0.4" resolved "https://registry.yarnpkg.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-11.0.4.tgz#d86d17697db62f98046874f62fdb3e53a0bbc4cd" @@ -4590,6 +4665,15 @@ "@types/prop-types" "*" csstype "^2.2.0" +"@types/react@^17": + version "17.0.60" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.60.tgz#a4a97dcdbebad76612c188fc06440e4995fd8ad2" + integrity sha512-pCH7bqWIfzHs3D+PDs3O/COCQJka+Kcw3RnO9rFA2zalqoXg7cNjJDh6mZ7oRtY1wmY4LVwDdAbA1F7Z8tv3BQ== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/reactcss@*": version "1.2.3" resolved "https://registry.yarnpkg.com/@types/reactcss/-/reactcss-1.2.3.tgz#af28ae11bbb277978b99d04d1eedfd068ca71834" @@ -4611,6 +4695,11 @@ dependencies: "@types/node" "*" +"@types/scheduler@*": + version "0.16.3" + resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.3.tgz#cef09e3ec9af1d63d2a6cc5b383a737e24e6dcf5" + integrity sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ== + "@types/source-list-map@*": version "0.1.2" resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9" @@ -4631,6 +4720,13 @@ resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.6.tgz#a9ca4b70a18b270ccb2bc0aaafefd1d486b7ea74" integrity sha512-W+bw9ds02rAQaMvaLYxAbJ6cvguW/iJXNT6lTssS1ps6QdrMKttqEAMEG/b5CR8TZl3/L7/lH0ZV5nNR1LXikA== +"@types/testing-library__jest-dom@^5.9.1": + version "5.14.6" + resolved "https://registry.yarnpkg.com/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.6.tgz#4887f6e1af11215428ab02777873bcede98a53b0" + integrity sha512-FkHXCb+ikSoUP4Y4rOslzTdX5sqYwMxfefKh1GmZ8ce1GOkEHntSp6b5cGadmNfp5e4BMEWOMx+WSKd5/MqlDA== + dependencies: + "@types/jest" "*" + "@types/trusted-types@^2.0.2": version "2.0.2" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756" @@ -4700,6 +4796,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yargs@^17.0.8": + version "17.0.24" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.24.tgz#b3ef8d50ad4aa6aecf6ddc97c580a00f5aa11902" + integrity sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw== + dependencies: + "@types/yargs-parser" "*" + "@typescript-eslint/eslint-plugin@^4.5.0": version "4.13.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-4.13.0.tgz#5f580ea520fa46442deb82c038460c3dd3524bb6" @@ -7863,6 +7966,11 @@ css-what@^3.2.1: resolved "https://registry.yarnpkg.com/css-what/-/css-what-3.2.1.tgz#f4a8f12421064621b456755e34a03a2c22df5da1" integrity sha512-WwOrosiQTvyms+Ti5ZC5vGEK0Vod3FTt1ca+payZqvKuGJF+dq7bG63DstxtN0dpm6FxY27a/zS3Wten+gEtGw== +css.escape@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" + integrity sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg== + css@^2.0.0: version "2.2.4" resolved "https://registry.yarnpkg.com/css/-/css-2.2.4.tgz#c646755c73971f2bba6a601e2cf2fd71b1298929" @@ -7992,6 +8100,11 @@ csstype@^2.2.0, csstype@^2.5.7: resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.9.tgz#05141d0cd557a56b8891394c1911c40c8a98d098" integrity sha512-xz39Sb4+OaTsULgUERcCk+TJj8ylkL4aSVDQiX/ksxbELSqwkgt4d4RD7fovIdgJGSuNYqwZEiVjYY5l0ask+Q== +csstype@^3.0.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.2.tgz#1d4bf9d572f11c14031f0436e1c10bc1f571f50b" + integrity sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ== + cyclist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" @@ -8284,6 +8397,11 @@ diff-sequences@^27.5.1: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-27.5.1.tgz#eaecc0d327fd68c8d9672a1e64ab8dccb2ef5327" integrity sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ== +diff-sequences@^29.4.3: + version "29.4.3" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.4.3.tgz#9314bc1fabe09267ffeca9cbafc457d8499a13f2" + integrity sha512-ofrBgwpPhCD85kMKtE9RYFFq6OC1A89oW2vvgWZNCwxrUpRUILopY7lsYyMDSjc8g6U6aiO0Qubg6r4Wgt5ZnA== + diff@3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12" @@ -8367,6 +8485,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-accessibility-api@^0.5.6: + version "0.5.16" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz#5a7429e6066eb3664d911e33fb0e45de8eb08453" + integrity sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg== + dom-accessibility-api@^0.5.9: version "0.5.11" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.11.tgz#79d5846c4f90eba3e617d9031e921de9324f84ed" @@ -9347,6 +9470,17 @@ expect@^27.5.1: jest-matcher-utils "^27.5.1" jest-message-util "^27.5.1" +expect@^29.0.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.5.0.tgz#68c0509156cb2a0adb8865d413b137eeaae682f7" + integrity sha512-yM7xqUrCO2JdpFo4XpM82t+PJBFybdqoQuJLDGeDX2ij8NZzqRHyu3Hp188/JX7SWqud+7t4MUdvcgGBICMHZg== + dependencies: + "@jest/expect-utils" "^29.5.0" + jest-get-type "^29.4.3" + jest-matcher-utils "^29.5.0" + jest-message-util "^29.5.0" + jest-util "^29.5.0" + express@^4.17.0, express@^4.17.1: version "4.17.1" resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" @@ -11943,6 +12077,16 @@ jest-diff@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" +jest-diff@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.5.0.tgz#e0d83a58eb5451dcc1fa61b1c3ee4e8f5a290d63" + integrity sha512-LtxijLLZBduXnHSniy0WMdaHjmQnt3g5sa16W4p0HqukYTTsyTW3GD1q41TyGl5YFXj/5B2U6dlh5FM1LIMgxw== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.4.3" + jest-get-type "^29.4.3" + pretty-format "^29.5.0" + jest-docblock@^26.0.0: version "26.0.0" resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-26.0.0.tgz#3e2fa20899fc928cb13bd0ff68bd3711a36889b5" @@ -12067,6 +12211,11 @@ jest-get-type@^27.5.1: resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1" integrity sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw== +jest-get-type@^29.4.3: + version "29.4.3" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.4.3.tgz#1ab7a5207c995161100b5187159ca82dd48b3dd5" + integrity sha512-J5Xez4nRRMjk8emnTpWrlkyb9pfRQQanDrvWHhsR1+VUfbwxi30eVcZFlcdGInRibU4G5LwHXpI7IRHU0CY+gg== + jest-haste-map@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-24.9.0.tgz#b38a5d64274934e21fa417ae9a9fbeb77ceaac7d" @@ -12210,6 +12359,16 @@ jest-matcher-utils@^27.5.1: jest-get-type "^27.5.1" pretty-format "^27.5.1" +jest-matcher-utils@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.5.0.tgz#d957af7f8c0692c5453666705621ad4abc2c59c5" + integrity sha512-lecRtgm/rjIK0CQ7LPQwzCs2VwW6WAahA55YBuI+xqmhm7LAaxokSB8C97yJeYyT+HvQkH741StzpU41wohhWw== + dependencies: + chalk "^4.0.0" + jest-diff "^29.5.0" + jest-get-type "^29.4.3" + pretty-format "^29.5.0" + jest-message-util@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-24.9.0.tgz#527f54a1e380f5e202a8d1149b0ec872f43119e3" @@ -12254,6 +12413,21 @@ jest-message-util@^27.5.1: slash "^3.0.0" stack-utils "^2.0.3" +jest-message-util@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.5.0.tgz#1f776cac3aca332ab8dd2e3b41625435085c900e" + integrity sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA== + dependencies: + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.5.0" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.5.0" + slash "^3.0.0" + stack-utils "^2.0.3" + jest-mock@^24.9.0: version "24.9.0" resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-24.9.0.tgz#c22835541ee379b908673ad51087a2185c13f1c6" @@ -12586,6 +12760,18 @@ jest-util@^27.5.1: graceful-fs "^4.2.9" picomatch "^2.2.3" +jest-util@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.5.0.tgz#24a4d3d92fc39ce90425311b23c27a6e0ef16b8f" + integrity sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ== + dependencies: + "@jest/types" "^29.5.0" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + jest-validate@^26.6.2: version "26.6.2" resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-26.6.2.tgz#23d380971587150467342911c3d7b4ac57ab20ec" @@ -15722,6 +15908,15 @@ pretty-format@^27.0.2, pretty-format@^27.5.1: ansi-styles "^5.0.0" react-is "^17.0.1" +pretty-format@^29.0.0, pretty-format@^29.5.0: + version "29.5.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.5.0.tgz#283134e74f70e2e3e7229336de0e4fce94ccde5a" + integrity sha512-V2mGkI31qdttvTFX7Mt4efOqHXqJWMu4/r66Xh3Z3BwZaPfPJgp6/gbwoujRpPUtfEF6AUUWx3Jim3GCw5g/Qw== + dependencies: + "@jest/schemas" "^29.4.3" + ansi-styles "^5.0.0" + react-is "^18.0.0" + pretty-hrtime@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" @@ -16279,6 +16474,11 @@ react-is@^17.0.1: resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" integrity sha512-NAnt2iGDXohE5LI7uBnLnqvLQMtzhkiAOLXTmv+qnF9Ky7xAPcX8Up/xWIhxvLVGJvuLiNc4xQLtuqDRzb4fSA== +react-is@^18.0.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" + integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== + react-lifecycles-compat@^3.0.0, react-lifecycles-compat@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" @@ -19362,10 +19562,8 @@ watchpack@^1.7.4: resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-1.7.5.tgz#1267e6c55e0b9b5be44c2023aed5437a2c26c453" integrity sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ== dependencies: - chokidar "^3.4.1" graceful-fs "^4.1.2" neo-async "^2.5.0" - watchpack-chokidar2 "^2.0.1" optionalDependencies: chokidar "^3.4.1" watchpack-chokidar2 "^2.0.1"