diff --git a/collections/forms/i18n/en.pot b/collections/forms/i18n/en.pot index 29beb9169e..91c91a2ab1 100644 --- a/collections/forms/i18n/en.pot +++ b/collections/forms/i18n/en.pot @@ -98,4 +98,4 @@ msgid "Please provide a string" msgstr "Please provide a string" msgid "Please provide a valid url" -msgstr "Please provide a valid url" +msgstr "Please provide a valid url" \ No newline at end of file diff --git a/collections/ui/API.md b/collections/ui/API.md index fff8993261..67e6e4112a 100644 --- a/collections/ui/API.md +++ b/collections/ui/API.md @@ -663,6 +663,8 @@ import { HeaderBar } from '@dhis2/ui' |---|---|---|---|---| |appName|string|||| |className|string|||| +|updateAvailable|boolean|||| +|onApplyAvailableUpdate|function|||| ### Logo diff --git a/components/header-bar/API.md b/components/header-bar/API.md index c89cb40ad7..b8e636403c 100644 --- a/components/header-bar/API.md +++ b/components/header-bar/API.md @@ -16,3 +16,5 @@ import { HeaderBar } from '@dhis2-ui/header-bar' |---|---|---|---|---| |appName|string|||| |className|string|||| +|updateAvailable|boolean|||| +|onApplyAvailableUpdate|function|||| diff --git a/components/header-bar/i18n/en.pot b/components/header-bar/i18n/en.pot index 9dc159ba96..6b0d317213 100644 --- a/components/header-bar/i18n/en.pot +++ b/components/header-bar/i18n/en.pot @@ -5,12 +5,36 @@ 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: 2021-09-02T19:24:35.672Z\n" -"PO-Revision-Date: 2021-09-02T19:24:35.672Z\n" +"POT-Creation-Date: 2022-10-03T12:12:05.437Z\n" +"PO-Revision-Date: 2022-10-03T12:12:05.437Z\n" msgid "Search apps" msgstr "Search apps" +msgid "DHIS2 {{dhis2Version}}" +msgstr "DHIS2 {{dhis2Version}}" + +msgid "DHIS2 version unknown" +msgstr "DHIS2 version unknown" + +msgid "{{appName}} version unknown" +msgstr "{{appName}} version unknown" + +msgid "App {{appVersion}}" +msgstr "App {{appVersion}}" + +msgid "App version unknown" +msgstr "App version unknown" + +msgid "Debug info" +msgstr "Debug info" + +msgid "Close" +msgstr "Close" + +msgid "Copy debug info" +msgstr "Copy debug info" + msgid "Last online {{relativeTime}}" msgstr "Last online {{relativeTime}}" @@ -37,3 +61,12 @@ msgstr "About DHIS2" msgid "Logout" msgstr "Logout" + +msgid "New {{appName}} version available" +msgstr "New {{appName}} version available" + +msgid "New app version available" +msgstr "New app version available" + +msgid "Click to reload" +msgstr "Click to reload" diff --git a/components/header-bar/package.json b/components/header-bar/package.json index 891dc1265e..6bd80a19ff 100644 --- a/components/header-bar/package.json +++ b/components/header-bar/package.json @@ -33,8 +33,8 @@ "styled-jsx": "^4" }, "dependencies": { - "@dhis2/prop-types": "^3.1.2", "@dhis2-ui/box": "8.4.16", + "@dhis2-ui/button": "^8.4.16", "@dhis2-ui/card": "8.4.16", "@dhis2-ui/center": "8.4.16", "@dhis2-ui/divider": "8.4.16", @@ -43,7 +43,9 @@ "@dhis2-ui/loader": "8.4.16", "@dhis2-ui/logo": "8.4.16", "@dhis2-ui/menu": "8.4.16", + "@dhis2-ui/modal": "^8.4.16", "@dhis2-ui/user-avatar": "8.4.16", + "@dhis2/prop-types": "^3.1.2", "@dhis2/ui-constants": "8.4.16", "@dhis2/ui-icons": "8.4.16", "classnames": "^2.3.1", diff --git a/components/header-bar/src/__e2e__/header-bar.stories.e2e.js b/components/header-bar/src/__e2e__/header-bar.stories.e2e.js index 2594306f24..bf9844c9b0 100644 --- a/components/header-bar/src/__e2e__/header-bar.stories.e2e.js +++ b/components/header-bar/src/__e2e__/header-bar.stories.e2e.js @@ -1,10 +1,6 @@ -import { HeaderBar } from '../index.js' - -export default { - title: 'HeaderBarTesting', - component: HeaderBar, -} +import { HeaderBar as component } from '../index.js' +export default { title: 'HeaderBarTesting', component } export { Default } from './stories/default.js' export { ShowOnlineStatus } from './stories/show-online-status.js' export { PWAEnabled } from './stories/pwa-enabled.js' @@ -19,3 +15,13 @@ export { UserHasWebMessagingAuthority } from './stories/user-has-web-messaging-a export { UserHasNoAuthorities } from './stories/user-has-no-authorities.js' export { ZeroUnreadInterpretations } from './stories/zero-unread-interpretations.js' export { ZeroUnreadMessages } from './stories/zero-unread-messages.js' +export { + WithUpdateAvailableNotification, + WithUpdateAvailableNotificationNoAppName, +} from './stories/with-update-available-notification.js' +export { + WithUnknownInstanceVersion, + WithUnknownAppNameAndVersion, + WithUnknownAppName, + WithUnknownAppVersion, +} from './stories/with-debug-info-edge-cases.js' diff --git a/components/header-bar/src/__e2e__/stories/common.js b/components/header-bar/src/__e2e__/stories/common.js index 3d48d0a5c5..0386915459 100644 --- a/components/header-bar/src/__e2e__/stories/common.js +++ b/components/header-bar/src/__e2e__/stories/common.js @@ -1,6 +1,7 @@ /* eslint-disable react/display-name */ -import { CustomDataProvider, Provider } from '@dhis2/app-runtime' -import React from 'react' +import { CustomDataProvider, Provider, useAlerts } from '@dhis2/app-runtime' +import PropTypes from 'prop-types' +import React, { useEffect } from 'react' export const defaultModules = [ { @@ -312,37 +313,6 @@ export const modulesWithSpecialCharacters = [ export const applicationTitle = 'Foobar' export const dataProviderData = { - 'system/info': { - contextPath: 'https://debug.dhis2.org/dev', - userAgent: - 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', - calendar: 'iso8601', - dateFormat: 'yyyy-mm-dd', - serverDate: '2021-10-06T08:06:15.256', - serverTimeZoneId: 'Etc/UTC', - serverTimeZoneDisplayName: 'Coordinated Universal Time', - lastAnalyticsTableSuccess: '2021-09-18T10:24:03.536', - intervalSinceLastAnalyticsTableSuccess: '429 h, 42 m, 11 s', - lastAnalyticsTableRuntime: '520835', - lastSystemMonitoringSuccess: '2019-03-26T17:07:15.418', - version: '2.38-SNAPSHOT', - revision: '6607c3c', - buildTime: '2021-10-05T17:13:00.000', - jasperReportsVersion: '6.3.1', - environmentVariable: 'DHIS2_HOME', - databaseInfo: { - spatialSupport: true, - }, - encryption: false, - emailConfigured: false, - redisEnabled: false, - systemId: 'eed3d451-4ff5-4193-b951-ffcc68954299', - systemName: 'DHIS 2 Demo - Sierra Leone', - instanceBaseUrl: 'https://debug.dhis2.org/dev', - clusterHostname: '', - isMetadataVersionEnabled: true, - metadataSyncEnabled: false, - }, 'systemSettings/applicationTitle': { applicationTitle, }, @@ -379,10 +349,93 @@ export const createDecoratorCustomDataProviderHeaderBar = ( } export const providerConfig = { + appName: 'TestApp', + appVersion: { + full: '101.2.3-beta.4', + major: 101, + minor: 2, + patch: 3, + tag: 'beta.4', + }, + serverVersion: { + full: '2.39.2.1-SNAPSHOT', + major: 2, + minor: 39, + patch: 2, + hotfix: 1, + tag: 'SNAPSHOT', + }, + systemInfo: { + contextPath: 'https://debug.dhis2.org/dev', + userAgent: + 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/89.0.4389.90 Safari/537.36', + calendar: 'iso8601', + dateFormat: 'yyyy-mm-dd', + serverDate: '2021-10-06T08:06:15.256', + serverTimeZoneId: 'Etc/UTC', + serverTimeZoneDisplayName: 'Coordinated Universal Time', + lastAnalyticsTableSuccess: '2021-09-18T10:24:03.536', + intervalSinceLastAnalyticsTableSuccess: '429 h, 42 m, 11 s', + lastAnalyticsTableRuntime: '520835', + lastSystemMonitoringSuccess: '2019-03-26T17:07:15.418', + version: '2.39.2.1-SNAPSHOT', + revision: '6607c3c', + buildTime: '2021-10-05T17:13:00.000', + jasperReportsVersion: '6.3.1', + environmentVariable: 'DHIS2_HOME', + databaseInfo: { + spatialSupport: true, + }, + encryption: false, + emailConfigured: false, + redisEnabled: false, + systemId: 'eed3d451-4ff5-4193-b951-ffcc68954299', + systemName: 'DHIS 2 Demo - Sierra Leone', + instanceBaseUrl: 'https://debug.dhis2.org/dev', + clusterHostname: '', + isMetadataVersionEnabled: true, + metadataSyncEnabled: false, + }, baseUrl: 'https://domain.tld/', apiVersion: '', } +const MockAlert = ({ alert }) => { + useEffect(() => { + if (alert.options?.duration) { + setTimeout(() => alert.remove(), alert.options?.duration) + } + }, [alert]) + return ( +
+ {alert.message} +
+ ) +} +MockAlert.propTypes = { + alert: PropTypes.shape({ + message: PropTypes.string, + options: PropTypes.shape({ duration: PropTypes.number }), + remove: PropTypes.func, + }), +} +const MocklAlertStack = () => { + const alerts = useAlerts() + + return ( +
+ {alerts.map((alert) => ( + + ))} +
+ ) +} + export const createDecoratorProvider = (config) => { - return (fn) => {fn()} + return (fn) => ( + + {fn()} + + + ) } diff --git a/components/header-bar/src/__e2e__/stories/with-debug-info-edge-cases.js b/components/header-bar/src/__e2e__/stories/with-debug-info-edge-cases.js new file mode 100644 index 0000000000..013dd9a6e7 --- /dev/null +++ b/components/header-bar/src/__e2e__/stories/with-debug-info-edge-cases.js @@ -0,0 +1,51 @@ +import React from 'react' +import { HeaderBar } from '../../header-bar.js' +import { + createDecoratorCustomDataProviderHeaderBar, + createDecoratorProvider, + providerConfig, +} from './common.js' + +export const WithUnknownInstanceVersion = () => + +WithUnknownInstanceVersion.decorators = [ + createDecoratorCustomDataProviderHeaderBar(), + createDecoratorProvider({ + ...providerConfig, + systemInfo: { + ...providerConfig.systemInfo, + version: undefined, + }, + }), +] + +export const WithUnknownAppVersion = () => + +WithUnknownAppVersion.decorators = [ + createDecoratorCustomDataProviderHeaderBar(), + createDecoratorProvider({ + ...providerConfig, + appVersion: undefined, + }), +] + +export const WithUnknownAppName = () => + +WithUnknownAppName.decorators = [ + createDecoratorCustomDataProviderHeaderBar(), + createDecoratorProvider({ + ...providerConfig, + appName: undefined, + }), +] + +export const WithUnknownAppNameAndVersion = () => + +WithUnknownAppNameAndVersion.decorators = [ + createDecoratorCustomDataProviderHeaderBar(), + createDecoratorProvider({ + ...providerConfig, + appName: undefined, + appVersion: undefined, + }), +] diff --git a/components/header-bar/src/__e2e__/stories/with-update-available-notification.js b/components/header-bar/src/__e2e__/stories/with-update-available-notification.js new file mode 100644 index 0000000000..1ed4888cc4 --- /dev/null +++ b/components/header-bar/src/__e2e__/stories/with-update-available-notification.js @@ -0,0 +1,39 @@ +import React, { useState } from 'react' +import { HeaderBar } from '../../header-bar.js' +import { + createDecoratorCustomDataProviderHeaderBar, + createDecoratorProvider, + providerConfig, +} from './common.js' + +export const WithUpdateAvailableNotification = () => { + const [modalOpen, setModalOpen] = useState(false) + return ( + <> + setModalOpen(true)} + /> + {modalOpen &&
The callback was successful
} + + ) +} +WithUpdateAvailableNotification.decorators = [ + createDecoratorCustomDataProviderHeaderBar(), + createDecoratorProvider({ + ...providerConfig, + appName: 'Data Visualizer', + }), +] + +export const WithUpdateAvailableNotificationNoAppName = () => ( + +) + +WithUpdateAvailableNotificationNoAppName.decorators = [ + createDecoratorCustomDataProviderHeaderBar(), + createDecoratorProvider({ + ...providerConfig, + appName: undefined, + }), +] diff --git a/components/header-bar/src/apps.js b/components/header-bar/src/apps.js index c76a2db633..e6b4209569 100755 --- a/components/header-bar/src/apps.js +++ b/components/header-bar/src/apps.js @@ -215,7 +215,7 @@ const Apps = ({ apps }) => { useEffect(() => { document.addEventListener('click', onDocClick) return () => document.removeEventListener('click', onDocClick) - }, []) + }, [onDocClick]) return (
diff --git a/components/header-bar/src/debug-info/debug-info-menu-item.js b/components/header-bar/src/debug-info/debug-info-menu-item.js new file mode 100644 index 0000000000..5c99a23c67 --- /dev/null +++ b/components/header-bar/src/debug-info/debug-info-menu-item.js @@ -0,0 +1,72 @@ +import { MenuItem } from '@dhis2-ui/menu' +import { colors } from '@dhis2/ui-constants' +import PropTypes from 'prop-types' +import React from 'react' +import i18n from '../locales/index.js' +import { useDebugInfo } from './use-debug-info.js' + +export const DebugInfoMenuItem = ({ hideProfileMenu, showDebugInfoModal }) => { + const debugInfo = useDebugInfo() + + const openDebugModal = () => { + hideProfileMenu() + showDebugInfoModal() + } + + const debugInfoLabel = ( +
+
+ {debugInfo.dhis2_version + ? i18n.t('DHIS2 {{dhis2Version}}', { + dhis2Version: debugInfo.dhis2_version, + }) + : i18n.t('DHIS2 version unknown')} +
+
+ {debugInfo.app_name + ? debugInfo.app_version + ? `${debugInfo.app_name} ${debugInfo.app_version}` + : i18n.t('{{appName}} version unknown', { + appName: debugInfo.app_name, + }) + : debugInfo.app_version + ? i18n.t('App {{appVersion}}', { + appVersion: debugInfo.app_version, + }) + : i18n.t('App version unknown')} + {} +
+ +
+ ) + + return ( + + ) +} + +DebugInfoMenuItem.propTypes = { + hideProfileMenu: PropTypes.func.isRequired, + showDebugInfoModal: PropTypes.func.isRequired, +} diff --git a/components/header-bar/src/debug-info/debug-info-modal.js b/components/header-bar/src/debug-info/debug-info-modal.js new file mode 100644 index 0000000000..df0bb55a63 --- /dev/null +++ b/components/header-bar/src/debug-info/debug-info-modal.js @@ -0,0 +1,47 @@ +import { Button, ButtonStrip } from '@dhis2-ui/button' +import { Modal, ModalActions, ModalContent, ModalTitle } from '@dhis2-ui/modal' +import { useAlert } from '@dhis2/app-runtime' +import PropTypes from 'prop-types' +import React from 'react' +import i18n from '../locales/index.js' +import { DebugInfoTable } from './debug-info-table.js' +import { useFormattedDebugInfo } from './use-debug-info.js' + +export function DebugInfoModal({ onClose }) { + const debugInfo = useFormattedDebugInfo() + const { show: showClipboardAlert } = useAlert( + 'Debug information copied to clipboard', + { duration: 3000 } + ) + + const copyDebugInfo = () => { + navigator.clipboard.writeText(debugInfo) + onClose() + showClipboardAlert() + } + + return ( + + {i18n.t('Debug info')} + + + + + + + + + + + ) +} + +DebugInfoModal.propTypes = { + onClose: PropTypes.func.isRequired, +} diff --git a/components/header-bar/src/debug-info/debug-info-table.js b/components/header-bar/src/debug-info/debug-info-table.js new file mode 100644 index 0000000000..0ed5cccf00 --- /dev/null +++ b/components/header-bar/src/debug-info/debug-info-table.js @@ -0,0 +1,49 @@ +import { colors } from '@dhis2/ui-constants' +import React from 'react' +import { useDebugInfo } from './use-debug-info.js' + +const formatDebugInfoKey = (key) => { + const tokens = key.split('_') + return tokens + .map((token) => { + if (token.toLowerCase() === 'dhis2') { + return 'DHIS2' + } else { + return token[0].toUpperCase() + token.substr(1).toLowerCase() + } + }) + .join(' ') +} + +export function DebugInfoTable() { + const debugInfo = useDebugInfo() + return ( + + + {Object.keys(debugInfo).map((key) => ( + + + + + ))} + + +
+ {formatDebugInfoKey(key)} + {debugInfo[key]}
+ ) +} diff --git a/components/header-bar/src/debug-info/use-debug-info.js b/components/header-bar/src/debug-info/use-debug-info.js new file mode 100644 index 0000000000..20bbb96794 --- /dev/null +++ b/components/header-bar/src/debug-info/use-debug-info.js @@ -0,0 +1,15 @@ +import { useConfig } from '@dhis2/app-runtime' + +export const useDebugInfo = () => { + const { appName, appVersion, systemInfo } = useConfig() + + return { + app_name: appName || null, + app_version: appVersion?.full || null, + dhis2_version: systemInfo?.version || null, + dhis2_revision: systemInfo?.revision || null, + } +} + +export const useFormattedDebugInfo = () => + JSON.stringify(useDebugInfo(), undefined, 2) diff --git a/components/header-bar/src/features/the_headerbar_displays_instance_and_app_infos.feature b/components/header-bar/src/features/the_headerbar_displays_instance_and_app_infos.feature deleted file mode 100644 index 32a50f9715..0000000000 --- a/components/header-bar/src/features/the_headerbar_displays_instance_and_app_infos.feature +++ /dev/null @@ -1,29 +0,0 @@ -Feature: The HeaderBar displays instance and app infos - - Scenario: The HeaderBar displays both instance and app infos - Given the HeaderBar has been supplied with an app name and version - And the HeaderBar's profile menu is visible - And the profile menu has successfully loaded the instance infos - Then the instance infos should be displayed - And the app infos should be displayed - - Scenario: The HeaderBar displays app infos and an instance infos loading message - Given the HeaderBar has been supplied with an app name and version - And the HeaderBar's profile menu is visible - And the profile menu is loading the instance infos - Then the app infos should be displayed - And a message stating that the instance infos are being loaded should be displayed - - Scenario: The HeaderBar displays app infos and an instance infos error message - Given the HeaderBar has been supplied with an app name and version - And the HeaderBar's profile menu is visible - And the profile menu failed loading the instance infos - Then the app infos should be displayed - And a message stating that loading the instance infos has failed should be displayed - - Scenario: The HeaderBar displays only instance infos - Given the HeaderBar has not been supplied with an app version - And the HeaderBar's profile menu is visible - And the profile menu has successfully loaded the instance infos - Then the instance infos should be displayed - And the app infos should not be displayed diff --git a/components/header-bar/src/features/the_headerbar_displays_instance_and_app_infos/index.js b/components/header-bar/src/features/the_headerbar_displays_instance_and_app_infos/index.js deleted file mode 100644 index a96d88a322..0000000000 --- a/components/header-bar/src/features/the_headerbar_displays_instance_and_app_infos/index.js +++ /dev/null @@ -1,29 +0,0 @@ -import { Given, Then } from 'cypress-cucumber-preprocessor/steps' - -Given('the HeaderBar has been supplied with an app name and version', () => {}) - -Given('the HeaderBar has not been supplied with an app version', () => {}) - -Given("the HeaderBar's profile menu is visible", () => {}) - -Given('the profile menu failed loading the instance infos', () => {}) - -Given('the profile menu has successfully loaded the instance infos', () => {}) - -Given('the profile menu is loading the instance infos', () => {}) - -Then( - 'a message stating that loading the instance infos has failed should be displayed', - () => {} -) - -Then( - 'a message stating that the instance infos are being loaded should be displayed', - () => {} -) - -Then('the app infos should be displayed', () => {}) - -Then('the app infos should not be displayed', () => {}) - -Then('the instance infos should be displayed', () => {}) diff --git a/components/header-bar/src/features/the_headerbar_should_display_app_update_notification.feature b/components/header-bar/src/features/the_headerbar_should_display_app_update_notification.feature new file mode 100644 index 0000000000..9a61ed53c7 --- /dev/null +++ b/components/header-bar/src/features/the_headerbar_should_display_app_update_notification.feature @@ -0,0 +1,22 @@ +Feature: The HeaderBar should display app update notification + + Scenario: No app update is available + Given the HeaderBar is rendered without an available update + When the user opens the profile menu + Then the update notification should not be displayed + + Scenario: An app update is available + Given the HeaderBar is rendered with an available update + When the user opens the profile menu + Then the update notification should be displayed + + Scenario: A callback is executed when the user click on the update notification + Given the HeaderBar is rendered with an available update + When the user opens the profile menu + When the user clicks the update notification + Then a callback should display a test div + + Scenario: An app update is available but not app name was specified + Given the HeaderBar is rendered with no app name and an available update + When the user opens the profile menu + Then the update notification should be displayed without app name \ No newline at end of file diff --git a/components/header-bar/src/features/the_headerbar_should_display_app_update_notification/index.js b/components/header-bar/src/features/the_headerbar_should_display_app_update_notification/index.js new file mode 100644 index 0000000000..40e6122f18 --- /dev/null +++ b/components/header-bar/src/features/the_headerbar_should_display_app_update_notification/index.js @@ -0,0 +1,52 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given('the HeaderBar is rendered without an available update', () => { + cy.visitStory('HeaderBarTesting', 'default') +}) + +Given('the HeaderBar is rendered with an available update', () => { + cy.visitStory('HeaderBarTesting', 'With Update Available Notification') +}) + +Given( + 'the HeaderBar is rendered with no app name and an available update', + () => { + cy.visitStory( + 'HeaderBarTesting', + 'With Update Available Notification No App Name' + ) + } +) + +When('the user opens the profile menu', () => { + cy.get('[data-test="headerbar-profile"] > button').click() +}) + +Then('the update notification should not be displayed', () => { + cy.get('[data-test="dhis2-ui-headerbar-updatenotification"]').should( + 'not.exist' + ) +}) + +Then('the update notification should be displayed', () => { + cy.get('[data-test="dhis2-ui-headerbar-updatenotification"]') + .should('contain', 'New Data Visualizer version available') + .should('contain', 'Click to reload') +}) + +Then('the update notification should be displayed without app name', () => { + cy.get('[data-test="dhis2-ui-headerbar-updatenotification"]') + .should('contain', 'New app version available') + .should('contain', 'Click to reload') +}) + +When('the user clicks the update notification', () => { + cy.get('[data-test="dhis2-ui-headerbar-updatenotification"]').click() +}) + +Then('the profile menu should not be shown', () => { + cy.get('[data-test="headerbar-profile-menu"]').should('not.exist') +}) +Then('a callback should display a test div', () => { + cy.contains('The callback was successful').should('be.visible') +}) diff --git a/components/header-bar/src/features/the_headerbar_should_display_debug_version_infos.feature b/components/header-bar/src/features/the_headerbar_should_display_debug_version_infos.feature new file mode 100644 index 0000000000..1aacde9747 --- /dev/null +++ b/components/header-bar/src/features/the_headerbar_should_display_debug_version_infos.feature @@ -0,0 +1,52 @@ +Feature: The HeaderBar should display debug version infos + + Scenario: The debug version infos are displayed in the profile menu + Given the HeaderBar is rendered with an app name and app version in runtime context + When the user opens the profile menu + Then the instance version should be displayed + And the app's name and version should be displayed + + Scenario: The debug version info modal is displayed when clicking on the menu item + Given the HeaderBar is rendered with an app name and app version in runtime context + When the user opens the profile menu + When the user clicks the debug info menu item + Then the debug info modal should be shown + + Scenario: The debug version info modal displays debug info + Given the HeaderBar is rendered with an app name and app version in runtime context + When the user opens the profile menu + When the user clicks the debug info menu item + Then the debug info modal should contain debug info + + Scenario: The debug version info should be copied to clipboard + Given the HeaderBar is rendered with an app name and app version in runtime context + When the user opens the profile menu + When the user clicks the debug info menu item + When the user clicks the copy debug info button + Then the debug info should be copied to clipboard + And the debug info copied to clipboard alert should be shown + And the debug info modal should not be shown + + Scenario: The debug version infos are displayed with unknown dhis2 version in the profile menu + Given the HeaderBar is rendered without an instance version in runtime context + When the user opens the profile menu + Then the instance version should show as unknown + And the app's name and version should be displayed + + Scenario: The debug version infos are displayed with unknown app name and version in the profile menu + Given the HeaderBar is rendered without app name or app version in runtime context + When the user opens the profile menu + Then the instance version should be displayed + And the unknown app with unknown version should be displayed + + Scenario: The debug version infos are displayed with unknown app name in the profile menu + Given the HeaderBar is rendered without app name in runtime context + When the user opens the profile menu + Then the instance version should be displayed + And the unknown app with app's version should be displayed + + Scenario: The debug version infos are displayed with unknown app version in the profile menu + Given the HeaderBar is rendered with an app name but without app version in runtime context + When the user opens the profile menu + Then the instance version should be displayed + And the app's name with unknown version should be displayed \ No newline at end of file diff --git a/components/header-bar/src/features/the_headerbar_should_display_debug_version_infos/index.js b/components/header-bar/src/features/the_headerbar_should_display_debug_version_infos/index.js new file mode 100644 index 0000000000..b516685d84 --- /dev/null +++ b/components/header-bar/src/features/the_headerbar_should_display_debug_version_infos/index.js @@ -0,0 +1,130 @@ +import { Given, When, Then } from 'cypress-cucumber-preprocessor/steps' + +Given( + 'the HeaderBar is rendered without an instance version in runtime context', + () => { + cy.visitStory('HeaderBarTesting', 'With Unknown Instance Version') + } +) + +Given( + 'the HeaderBar is rendered with an app name and app version in runtime context', + () => { + cy.visitStory('HeaderBarTesting', 'default') + } +) + +Given('the HeaderBar is rendered without app name in runtime context', () => { + cy.visitStory('HeaderBarTesting', 'With Unknown App Name') +}) + +Given( + 'the HeaderBar is rendered with an app name but without app version in runtime context', + () => { + cy.visitStory('HeaderBarTesting', 'With Unknown App Version') + } +) + +Given( + 'the HeaderBar is rendered without app name or app version in runtime context', + () => { + cy.visitStory('HeaderBarTesting', 'With Unknown App Name And Version') + } +) + +When('the user opens the profile menu', () => { + cy.get('[data-test="headerbar-profile"] > button').click() +}) + +Then("the app's name and version should be displayed", () => { + cy.get('[data-test="dhis2-ui-headerbar-appinfo"]').should( + 'contain', + 'TestApp 101.2.3-beta.4' + ) +}) + +Then("the app's name with unknown version should be displayed", () => { + cy.get('[data-test="dhis2-ui-headerbar-appinfo"]').should( + 'contain', + 'TestApp version unknown' + ) +}) + +Then("the unknown app with app's version should be displayed", () => { + cy.get('[data-test="dhis2-ui-headerbar-appinfo"]').should( + 'contain', + 'App 101.2.3-beta.4' + ) +}) + +Then('the unknown app with unknown version should be displayed', () => { + cy.get('[data-test="dhis2-ui-headerbar-appinfo"]').should( + 'contain', + 'App version unknown' + ) +}) + +Then('the instance version should be displayed', () => { + cy.get('[data-test="dhis2-ui-headerbar-instanceinfo"]').should( + 'contain', + 'DHIS2 2.39.2.1-SNAPSHOT' + ) +}) + +Then('the instance version should show as unknown', () => { + cy.get('[data-test="dhis2-ui-headerbar-instanceinfo"]').should( + 'contain', + 'DHIS2 version unknown' + ) +}) + +When('the user clicks the debug info menu item', () => { + cy.get('[data-test="dhis2-ui-headerbar-debuginfo"] > a').click() +}) + +Then('the debug info modal should be shown', () => { + cy.get('[data-test="dhis2-ui-headerbar-debuginfomodal"]').should( + 'be.visible' + ) +}) +Then('the debug info modal should not be shown', () => { + cy.get('[data-test="dhis2-ui-headerbar-debuginfomodal"]').should( + 'not.exist' + ) +}) + +Then('the debug info modal should contain debug info', () => { + cy.get('[data-test="dhis2-ui-headerbar-debuginfotable"]') + .should( + 'contain', + '2.39.2.1-SNAPSHOT' // DHIS2 version + ) + .should( + 'contain', + '6607c3c' // Revision + ) + .should( + 'contain', + 'TestApp' // App name + ) + .should( + 'contain', + '101.2.3-beta.4' // App version + ) +}) + +When('the user clicks the copy debug info button', () => { + cy.contains('Copy debug info').click() +}) + +Then('the debug info should be copied to clipboard', () => { + cy.window().then((win) => { + win.navigator.clipboard.readText().then((text) => { + expect(text).to.contain('2.39.2.1-SNAPSHOT') + }) + }) +}) + +Then('the debug info copied to clipboard alert should be shown', () => { + cy.contains('Debug information copied to clipboard').should('exist') +}) diff --git a/components/header-bar/src/header-bar-context.js b/components/header-bar/src/header-bar-context.js new file mode 100644 index 0000000000..5fcc06e7c2 --- /dev/null +++ b/components/header-bar/src/header-bar-context.js @@ -0,0 +1,28 @@ +import PropTypes from 'prop-types' +import React, { createContext, useContext } from 'react' + +const headerBarContext = createContext({ + updateAvailable: false, + onApplyAvailableUpdate: () => {}, +}) + +export const HeaderBarContextProvider = ({ + updateAvailable, + onApplyAvailableUpdate, + children, +}) => { + return ( + + {children} + + ) +} +HeaderBarContextProvider.propTypes = { + children: PropTypes.node, + updateAvailable: PropTypes.bool, + onApplyAvailableUpdate: PropTypes.func, +} + +export const useHeaderBarContext = () => useContext(headerBarContext) diff --git a/components/header-bar/src/header-bar.js b/components/header-bar/src/header-bar.js index ae4f007f40..3ebb45b57e 100755 --- a/components/header-bar/src/header-bar.js +++ b/components/header-bar/src/header-bar.js @@ -3,6 +3,7 @@ import { colors } from '@dhis2/ui-constants' import PropTypes from 'prop-types' import React, { useMemo } from 'react' import Apps from './apps.js' +import { HeaderBarContextProvider } from './header-bar-context.js' import { joinPath } from './join-path.js' import i18n from './locales/index.js' import { Logo } from './logo.js' @@ -32,8 +33,14 @@ const query = { }, } -export const HeaderBar = ({ appName, className }) => { +export const HeaderBar = ({ + appName, + className, + updateAvailable, + onApplyAvailableUpdate, +}) => { const { + appName: configAppName, baseUrl, pwaEnabled, headerbar: { showOnlineStatus } = {}, @@ -51,71 +58,88 @@ export const HeaderBar = ({ appName, className }) => { icon: getPath(app.icon), defaultAction: getPath(app.defaultAction), })) - }, [data]) + }, [data, baseUrl]) // See https://jira.dhis2.org/browse/LIBS-180 if (!loading && !error) { - // TODO: This will run every render which is probably wrong! Also, setting the global locale shouldn't be done in the headerbar + // TODO: This will run every render which is probably wrong! + // Also, setting the global locale shouldn't be done in the headerbar const locale = data.user.settings.keyUiLocale || 'en' i18n.setDefaultNamespace('default') i18n.changeLanguage(locale) } return ( -
-
- {!loading && !error && ( - <> - - - <div className="right-control-spacer" /> - {(pwaEnabled || showOnlineStatus) && <OnlineStatus />} - <Notifications - interpretations={ - data.notifications.unreadInterpretations - } - messages={ - data.notifications.unreadMessageConversations - } - userAuthorities={data.user.authorities} - /> - <Apps apps={apps} /> - <Profile - name={data.user.name} - email={data.user.email} - avatarId={data.user.avatar?.id} - helpUrl={data.help.helpPageLink} - /> - </> + <HeaderBarContextProvider + updateAvailable={updateAvailable} + onApplyAvailableUpdate={onApplyAvailableUpdate} + > + <header className={className}> + <div className="main"> + {!loading && !error && ( + <> + <Logo /> + + <Title + app={appName || configAppName} + instance={data.title.applicationTitle} + /> + + <div className="right-control-spacer" /> + + {(pwaEnabled || showOnlineStatus) && ( + <OnlineStatus /> + )} + + <Notifications + interpretations={ + data.notifications.unreadInterpretations + } + messages={ + data.notifications + .unreadMessageConversations + } + userAuthorities={data.user.authorities} + /> + <Apps apps={apps} /> + + <Profile + name={data.user.name} + email={data.user.email} + avatarId={data.user.avatar?.id} + helpUrl={data.help.helpPageLink} + /> + </> + )} + </div> + + {(pwaEnabled || showOnlineStatus) && !loading && !error && ( + <OnlineStatus dense /> )} - </div> - {(pwaEnabled || showOnlineStatus) && !loading && !error && ( - <OnlineStatus dense /> - )} - <style jsx>{` - .main { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - background-color: #2c6693; - border-bottom: 1px solid rgba(32, 32, 32, 0.15); - color: ${colors.white}; - height: 48px; - } - .right-control-spacer { - margin-left: auto; - } - `}</style> - </header> + <style jsx>{` + .main { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + background-color: #2c6693; + border-bottom: 1px solid rgba(32, 32, 32, 0.15); + color: ${colors.white}; + height: 48px; + } + .right-control-spacer { + margin-left: auto; + } + `}</style> + </header> + </HeaderBarContextProvider> ) } HeaderBar.propTypes = { appName: PropTypes.string, className: PropTypes.string, + updateAvailable: PropTypes.bool, + onApplyAvailableUpdate: PropTypes.func, } diff --git a/components/header-bar/src/header-bar.stories.js b/components/header-bar/src/header-bar.stories.js index b1f055996b..4a04ca0047 100644 --- a/components/header-bar/src/header-bar.stories.js +++ b/components/header-bar/src/header-bar.stories.js @@ -1,5 +1,9 @@ -import { CustomDataProvider, Provider } from '@dhis2/app-runtime' +import { CustomDataProvider } from '@dhis2/app-runtime' import React from 'react' +import { + createDecoratorProvider, + providerConfig, +} from './__e2e__/stories/common.js' import { HeaderBar } from './header-bar.js' const subtitle = 'The common navigation bar used in all DHIS2 apps' @@ -18,21 +22,6 @@ import { HeaderBar } from '@dhis2/ui' \`\`\` ` -export default { - title: 'Header Bar', - component: HeaderBar, - parameters: { - componentSubtitle: subtitle, - docs: { description: { component: description } }, - }, - args: { appName: 'Example!' }, -} - -const mockConfig = { - baseUrl: 'https://debug.dhis2.org/dev/', - apiVersion: 33, -} - const customData = { 'systemSettings/applicationTitle': { applicationTitle: 'Foobar', @@ -176,71 +165,68 @@ const customAuthoritiesData = { }, } -export const Default = (args) => ( - <Provider config={mockConfig}> - <CustomDataProvider data={customData}> - <HeaderBar {...args} /> - </CustomDataProvider> - </Provider> +export default { + title: 'Header Bar', + component: HeaderBar, + parameters: { + componentSubtitle: subtitle, + docs: { description: { component: description } }, + }, + decorators: [createDecoratorProvider()], +} + +export const Default = () => ( + <CustomDataProvider data={customData}> + <HeaderBar appName="Example!" /> + </CustomDataProvider> ) -export const CustomLogoWideDimension = (args) => ( - <Provider config={mockConfig}> - <CustomDataProvider data={customLogoData}> - <HeaderBar {...args} /> - </CustomDataProvider> - </Provider> +export const CustomLogoWideDimension = () => ( + <CustomDataProvider data={customLogoData}> + <HeaderBar appName="Example!" /> + </CustomDataProvider> ) CustomLogoWideDimension.storyName = 'Custom Logo (wide dimension)' -export const NonEnglishUserLocale = (args) => ( - <Provider config={mockConfig}> - <CustomDataProvider data={customLocaleData}> - <HeaderBar {...args} /> - </CustomDataProvider> - </Provider> +export const NonEnglishUserLocale = () => ( + <CustomDataProvider data={customLocaleData}> + <HeaderBar appName="Exemple!" /> + </CustomDataProvider> ) -NonEnglishUserLocale.args = { appName: 'Exemple!' } NonEnglishUserLocale.storyName = 'Non-english user locale' -export const NoAuthorityForInterpretationsApp = (args) => ( - <Provider config={mockConfig}> - <CustomDataProvider data={customAuthoritiesData}> - <HeaderBar {...args} /> - </CustomDataProvider> - </Provider> +export const NoAuthorityForInterpretationsApp = () => ( + <CustomDataProvider data={customAuthoritiesData}> + <HeaderBar appName="Example!" /> + </CustomDataProvider> ) NoAuthorityForInterpretationsApp.storyName = 'No authority for interpretations app' -export const Loading = (args) => ( - <Provider config={mockConfig}> - <CustomDataProvider options={{ loadForever: true }}> - <HeaderBar {...args} /> - </CustomDataProvider> - </Provider> +export const Loading = () => ( + <CustomDataProvider options={{ loadForever: true }}> + <HeaderBar appName="Example!" /> + </CustomDataProvider> ) Loading.storyName = 'Loading...' -export const Error = (args) => ( - <Provider config={mockConfig}> - <CustomDataProvider data={{}}> - <HeaderBar {...args} /> - </CustomDataProvider> - </Provider> +export const Error = () => ( + <CustomDataProvider data={{}}> + <HeaderBar appName="Exemple!" /> + </CustomDataProvider> ) Error.storyName = 'Error!' -export const WithOnlineStatus = (args) => { - const config = { ...mockConfig, pwaEnabled: true } - return ( - <Provider config={config}> - <CustomDataProvider data={customData}> - <HeaderBar {...args} /> - </CustomDataProvider> - </Provider> - ) -} +export const WithOnlineStatus = () => ( + <CustomDataProvider data={customData}> + <HeaderBar appName="Exemple!" /> + </CustomDataProvider> +) + +WithOnlineStatus.decorators = [ + createDecoratorProvider({ ...providerConfig, pwaEnabled: true }), +] + WithOnlineStatus.parameters = { docs: { description: { @@ -252,20 +238,20 @@ WithOnlineStatus.parameters = { }, } -export const WithLastOnlineInfo = (args) => { - const config = { - ...mockConfig, +export const WithLastOnlineInfo = () => ( + <CustomDataProvider data={customData}> + <HeaderBar appName="Exemple!" /> + </CustomDataProvider> +) + +WithLastOnlineInfo.decorators = [ + createDecoratorProvider({ + ...providerConfig, pwaEnabled: true, headerbar: { onlineStatusInfo: 'LAST_ONLINE' }, - } - return ( - <Provider config={config}> - <CustomDataProvider data={customData}> - <HeaderBar {...args} /> - </CustomDataProvider> - </Provider> - ) -} + }), +] + WithLastOnlineInfo.parameters = { docs: { description: { @@ -274,3 +260,9 @@ WithLastOnlineInfo.parameters = { }, }, } + +export const WithUpdateNotification = () => ( + <CustomDataProvider data={customData}> + <HeaderBar appName="Data Visualizer" updateAvailable={true} /> + </CustomDataProvider> +) diff --git a/components/header-bar/src/profile-menu/index.js b/components/header-bar/src/profile-menu/index.js new file mode 100644 index 0000000000..01121daa22 --- /dev/null +++ b/components/header-bar/src/profile-menu/index.js @@ -0,0 +1 @@ +export * from './profile-menu.js' diff --git a/components/header-bar/src/profile/profile-header.js b/components/header-bar/src/profile-menu/profile-header.js similarity index 100% rename from components/header-bar/src/profile/profile-header.js rename to components/header-bar/src/profile-menu/profile-header.js diff --git a/components/header-bar/src/profile/profile-menu.js b/components/header-bar/src/profile-menu/profile-menu.js similarity index 83% rename from components/header-bar/src/profile/profile-menu.js rename to components/header-bar/src/profile-menu/profile-menu.js index ff6efb0c70..fdd3e619af 100755 --- a/components/header-bar/src/profile/profile-menu.js +++ b/components/header-bar/src/profile-menu/profile-menu.js @@ -3,8 +3,8 @@ import { Center } from '@dhis2-ui/center' import { Divider } from '@dhis2-ui/divider' import { Layer } from '@dhis2-ui/layer' import { CircularLoader } from '@dhis2-ui/loader' -import { MenuItem } from '@dhis2-ui/menu' -import { useConfig, clearSensitiveCaches } from '@dhis2/app-runtime' +import { MenuDivider, MenuItem } from '@dhis2-ui/menu' +import { clearSensitiveCaches, useConfig } from '@dhis2/app-runtime' import { colors } from '@dhis2/ui-constants' import { IconSettings24, @@ -15,9 +15,11 @@ import { } from '@dhis2/ui-icons' import PropTypes from 'prop-types' import React, { useState } from 'react' +import { DebugInfoMenuItem } from '../debug-info/debug-info-menu-item.js' import { joinPath } from '../join-path.js' import i18n from '../locales/index.js' import { ProfileHeader } from './profile-header.js' +import { UpdateNotification } from './update-notification.js' const LoadingMask = () => ( <Layer @@ -31,7 +33,14 @@ const LoadingMask = () => ( </Layer> ) -const ProfileContents = ({ name, email, avatarId, helpUrl }) => { +const ProfileContents = ({ + name, + email, + avatarId, + helpUrl, + hideProfileMenu, + showDebugInfoModal, +}) => { const { baseUrl } = useConfig() const [loading, setLoading] = useState(false) @@ -99,6 +108,12 @@ const ProfileContents = ({ name, email, avatarId, helpUrl }) => { value="logout" icon={<IconLogOut24 color={colors.grey700} />} /> + <MenuDivider dense /> + <DebugInfoMenuItem + hideProfileMenu={hideProfileMenu} + showDebugInfoModal={showDebugInfoModal} + /> + <UpdateNotification hideProfileMenu={hideProfileMenu} /> </ul> </div> @@ -129,27 +144,24 @@ const ProfileContents = ({ name, email, avatarId, helpUrl }) => { } ProfileContents.propTypes = { + hideProfileMenu: PropTypes.func.isRequired, + showDebugInfoModal: PropTypes.func.isRequired, avatarId: PropTypes.string, email: PropTypes.string, helpUrl: PropTypes.string, name: PropTypes.string, } -export const ProfileMenu = ({ avatarId, name, email, helpUrl }) => ( +export const ProfileMenu = ({ ...props }) => ( <div data-test="headerbar-profile-menu"> - <ProfileContents - name={name} - email={email} - avatarId={avatarId} - helpUrl={helpUrl} - /> + <ProfileContents {...props} /> <style jsx>{` div { z-index: 10000; position: absolute; top: 34px; right: -6px; - width: 310px; + width: 320px; border-top: 4px solid transparent; } `}</style> @@ -157,6 +169,8 @@ export const ProfileMenu = ({ avatarId, name, email, helpUrl }) => ( ) ProfileMenu.propTypes = { + hideProfileMenu: PropTypes.func.isRequired, + showDebugInfoModal: PropTypes.func.isRequired, avatarId: PropTypes.string, email: PropTypes.string, helpUrl: PropTypes.string, diff --git a/components/header-bar/src/profile-menu/update-notification.js b/components/header-bar/src/profile-menu/update-notification.js new file mode 100644 index 0000000000..6058feec41 --- /dev/null +++ b/components/header-bar/src/profile-menu/update-notification.js @@ -0,0 +1,67 @@ +import { MenuItem } from '@dhis2-ui/menu' +import { useConfig } from '@dhis2/app-runtime' +import { colors } from '@dhis2/ui-constants' +import PropTypes from 'prop-types' +import React from 'react' +import { useHeaderBarContext } from '../header-bar-context.js' +import i18n from '../locales/index.js' + +export function UpdateNotification({ hideProfileMenu }) { + const { appName } = useConfig() + const { updateAvailable, onApplyAvailableUpdate } = useHeaderBarContext() + const onClick = () => { + hideProfileMenu() + onApplyAvailableUpdate?.() + } + + const updateNotificationLabel = ( + <div className="root"> + <div className="badge" /> + <div className="spacer" /> + <div className="message"> + {appName + ? i18n.t('New {{appName}} version available', { appName }) + : i18n.t('New app version available')} + <br /> + {i18n.t('Click to reload')} + </div> + <style jsx>{` + .root { + display: flex; + flex-direction: row; + align-items: center; + font-size: 14px; + line-height: 17px; + } + .badge { + display: inline-block; + width: 12px; + height: 12px; + margin: 0 8px; + border-radius: 6px; + background-color: ${colors.blue600}; + } + .spacer { + display: inline-block; + width: 8px; + } + .message { + display: inline-block; + } + `}</style> + </div> + ) + + return updateAvailable ? ( + <MenuItem + dense + onClick={onClick} + label={updateNotificationLabel} + dataTest="dhis2-ui-headerbar-updatenotification" + /> + ) : null +} + +UpdateNotification.propTypes = { + hideProfileMenu: PropTypes.func.isRequired, +} diff --git a/components/header-bar/src/profile.js b/components/header-bar/src/profile.js index 063d4410eb..46f1d49eff 100755 --- a/components/header-bar/src/profile.js +++ b/components/header-bar/src/profile.js @@ -1,84 +1,87 @@ import { UserAvatar } from '@dhis2-ui/user-avatar' import PropTypes from 'prop-types' -import React from 'react' -import { ProfileMenu } from './profile/profile-menu.js' +import React, { useCallback, useRef, useState } from 'react' +import { DebugInfoModal } from './debug-info/debug-info-modal.js' +import { ProfileMenu } from './profile-menu/index.js' +import { useOnDocClick } from './profile/use-on-doc-click.js' -export default class Profile extends React.Component { - state = { - show: false, - } +const Profile = ({ name, email, avatarId, helpUrl }) => { + const [showProfileMenu, setShowProfileMenu] = useState(false) + const [showDebugInfoModal, setShowDebugInfoModal] = useState(false) + const hideProfileMenu = useCallback( + () => setShowProfileMenu(false), + [setShowProfileMenu] + ) + const toggleProfileMenu = useCallback( + () => setShowProfileMenu((show) => !show), + [setShowProfileMenu] + ) + const containerRef = useRef(null) - componentDidMount() { - document.addEventListener('click', this.onDocClick) - } + useOnDocClick(containerRef, hideProfileMenu) - componentWillUnmount() { - document.removeEventListener('click', this.onDocClick) - } - - onDocClick = (evt) => { - if (this.elContainer && !this.elContainer.contains(evt.target)) { - this.setState({ show: false }) - } - } - - handleToggle = () => this.setState({ show: !this.state.show }) - - render() { - const { name, email, avatarId, helpUrl } = this.props - - return ( - <div - ref={(c) => (this.elContainer = c)} - data-test="headerbar-profile" - className="headerbar-profile" + return ( + <div + ref={containerRef} + data-test="headerbar-profile" + className="headerbar-profile" + > + <button + className="headerbar-profile-btn" + onClick={toggleProfileMenu} > - <button - className="headerbar-profile-btn" - onClick={this.handleToggle} - > - <UserAvatar - avatarId={avatarId} - name={name} - dataTest="headerbar-profile-icon" - medium - /> - </button> + <UserAvatar + avatarId={avatarId} + name={name} + dataTest="headerbar-profile-icon" + medium + /> + </button> - {this.state.show ? ( - <ProfileMenu - avatarId={avatarId} - name={name} - email={email} - helpUrl={helpUrl} - /> - ) : null} + {showProfileMenu && ( + <ProfileMenu + avatarId={avatarId} + name={name} + email={email} + helpUrl={helpUrl} + hideProfileMenu={hideProfileMenu} + showDebugInfoModal={() => { + setShowDebugInfoModal(true) + }} + /> + )} + {showDebugInfoModal && ( + <DebugInfoModal + onClose={() => { + setShowDebugInfoModal(false) + }} + /> + )} - <style jsx>{` - .headerbar-profile { - position: relative; - width: 36px; - height: 36px; - min-width: 36px; - min-height: 36px; - margin: 2px 12px 0 24px; - } + <style jsx>{` + .headerbar-profile { + position: relative; + width: 36px; + height: 36px; + min-width: 36px; + min-height: 36px; + margin: 2px 12px 0 24px; + } - .headerbar-profile-btn { - background: transparent; - padding: 0; - border: 0; - cursor: pointer; - width: 100%; - height: 100%; - } - .headerbar-profile-btn:focus { - outline: 1px dotted white; - } - `}</style> - </div> - ) - } + .headerbar-profile-btn { + background: transparent; + padding: 0; + border: 0; + cursor: pointer; + width: 100%; + height: 100%; + } + .headerbar-profile-btn:focus { + outline: 1px dotted white; + } + `}</style> + </div> + ) } Profile.propTypes = { @@ -87,3 +90,5 @@ Profile.propTypes = { email: PropTypes.string, helpUrl: PropTypes.string, } + +export default Profile diff --git a/components/header-bar/src/profile/use-on-doc-click.js b/components/header-bar/src/profile/use-on-doc-click.js new file mode 100644 index 0000000000..faf9916470 --- /dev/null +++ b/components/header-bar/src/profile/use-on-doc-click.js @@ -0,0 +1,23 @@ +import { useEffect, useMemo } from 'react' + +export const useOnDocClick = (containerRef, hide) => { + const onDocClick = useMemo(() => { + return (evt) => { + if (!containerRef.current) { + return null + } + + if (!containerRef.current.contains(evt.target)) { + hide() + } + } + }, [containerRef, hide]) + + useEffect(() => { + document.addEventListener('click', onDocClick) + + return () => { + document.removeEventListener('click', onDocClick) + } + }, [onDocClick]) +} diff --git a/components/header-bar/src/profile/use-on-doc-click.test.js b/components/header-bar/src/profile/use-on-doc-click.test.js new file mode 100644 index 0000000000..b147c0862a --- /dev/null +++ b/components/header-bar/src/profile/use-on-doc-click.test.js @@ -0,0 +1,40 @@ +import { renderHook } from '@testing-library/react-hooks' +import { useOnDocClick } from './use-on-doc-click.js' + +describe('useOnDocClick', () => { + let eventListenerMap = {} + const hide = jest.fn() + + jest.spyOn(document, 'addEventListener').mockImplementation( + (event, callback) => { + eventListenerMap[event] = callback + } + ) + + beforeEach(() => { + eventListenerMap = {} + }) + + afterEach(() => { + document.addEventListener.mockClear() + hide.mockClear() + }) + + it('should call the hide function when clicking outside', () => { + const el = document.createElement('span') + const containerRef = { current: el } + renderHook(() => useOnDocClick(containerRef, hide)) + + eventListenerMap.click({ target: document.body }) + expect(hide).toHaveBeenCalled() + }) + + it('should not call the hide function when clicking inside', () => { + const el = document.createElement('span') + const containerRef = { current: el } + renderHook(() => useOnDocClick(containerRef, hide)) + + eventListenerMap.click({ target: el }) + expect(hide).not.toHaveBeenCalled() + }) +})