diff --git a/cypress/fixtures/HeaderBar/applicationTitle.json b/cypress/fixtures/HeaderBar/applicationTitle.json new file mode 100644 index 0000000000..3fe4e0700a --- /dev/null +++ b/cypress/fixtures/HeaderBar/applicationTitle.json @@ -0,0 +1,3 @@ +{ + "applicationTitle": "Foobar" +} diff --git a/cypress/fixtures/HeaderBar/getModules.json b/cypress/fixtures/HeaderBar/getModules.json index 2975f49350..ba16a114f3 100644 --- a/cypress/fixtures/HeaderBar/getModules.json +++ b/cypress/fixtures/HeaderBar/getModules.json @@ -56,14 +56,6 @@ "icon": "../icons/dhis-web-interpretation.png", "description": "" }, - { - "name": "dhis-web-importexport", - "namespace": "/dhis-web-importexport", - "defaultAction": "../dhis-web-importexport/index.action", - "displayName": "Import/Export", - "icon": "../icons/dhis-web-importexport.png", - "description": "" - }, { "name": "WHO Metadata browser", "namespace": "WHO Metadata browser", diff --git a/cypress/fixtures/HeaderBar/getModulesWithSpecialChars.json b/cypress/fixtures/HeaderBar/getModulesWithSpecialChars.json new file mode 100644 index 0000000000..8d7f567ae2 --- /dev/null +++ b/cypress/fixtures/HeaderBar/getModulesWithSpecialChars.json @@ -0,0 +1,229 @@ +{ + "modules": [ + { + "name": "/", + "namespace": "//", + "defaultAction": "..//.action", + "displayName": "A / character", + "icon": "../icons/dhis-web-dashboard.png", + "description": "" + }, + { + "name": "-", + "namespace": "/-", + "defaultAction": "../-.action", + "displayName": "A - character", + "icon": "../icons/dhis-web-dashboard.png", + "description": "" + }, + { + "name": "(", + "namespace": "/(", + "defaultAction": "../(.action", + "displayName": "A ( character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": ")", + "namespace": "/)", + "defaultAction": "../).action", + "displayName": "A ) character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "[", + "namespace": "/[", + "defaultAction": "../[.action", + "displayName": "A [ character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "]", + "namespace": "/]", + "defaultAction": "../].action", + "displayName": "A ] character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "{", + "namespace": "/{", + "defaultAction": "../{.action", + "displayName": "A { character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "}", + "namespace": "/}", + "defaultAction": "../}.action", + "displayName": "A } character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "*", + "namespace": "/*", + "defaultAction": "../*.action", + "displayName": "A * character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "+", + "namespace": "/+", + "defaultAction": "../+.action", + "displayName": "A + character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "?", + "namespace": "/?", + "defaultAction": "../?.action", + "displayName": "A ? character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": ".", + "namespace": "/.", + "defaultAction": "../..action", + "displayName": "A . character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": ",", + "namespace": "/,", + "defaultAction": "../,.action", + "displayName": "A , character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "^", + "namespace": "/^", + "defaultAction": "../^.action", + "displayName": "A ^ character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "$", + "namespace": "/$", + "defaultAction": "../$.action", + "displayName": "A $ character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "|", + "namespace": "/|", + "defaultAction": "../|.action", + "displayName": "A | character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "#", + "namespace": "/#", + "defaultAction": "../#.action", + "displayName": "A # character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "\\s", + "namespace": "/\\s", + "defaultAction": "../\\s.action", + "displayName": "A \\s character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + { + "name": "\\", + "namespace": "/\\", + "defaultAction": "../\\.action", + "displayName": "A \\ character", + "icon": "../icons/dhis-web-data-visualizer.png", + "description": "" + }, + + { + "name": "dhis-web-dashboard", + "namespace": "/dhis-web-dashboard", + "defaultAction": "../dhis-web-dashboard/index.action", + "displayName": "Dashboard", + "icon": "../icons/dhis-web-dashboard.png", + "description": "" + }, + { + "name": "dhis-web-capture", + "namespace": "/dhis-web-capture", + "defaultAction": "../dhis-web-capture/index.action", + "displayName": "Capture", + "icon": "../icons/dhis-web-capture.png", + "description": "" + }, + { + "name": "dhis-web-maintenance", + "namespace": "/dhis-web-maintenance", + "defaultAction": "../dhis-web-maintenance/index.action", + "displayName": "Maintenance", + "icon": "../icons/dhis-web-maintenance.png", + "description": "" + }, + { + "name": "dhis-web-maps", + "namespace": "/dhis-web-maps", + "defaultAction": "../dhis-web-maps/index.action", + "displayName": "Maps", + "icon": "../icons/dhis-web-maps.png", + "description": "" + }, + { + "name": "dhis-web-event-reports", + "namespace": "/dhis-web-event-reports", + "defaultAction": "../dhis-web-event-reports/index.action", + "displayName": "Event Reports", + "icon": "../icons/dhis-web-event-reports.png", + "description": "" + }, + { + "name": "dhis-web-interpretation", + "namespace": "/dhis-web-interpretation", + "defaultAction": "../dhis-web-interpretation/index.action", + "displayName": "Interpretations", + "icon": "../icons/dhis-web-interpretation.png", + "description": "" + }, + { + "name": "dhis-web-import-export", + "namespace": "/dhis-web-import-export", + "defaultAction": "../dhis-web-import-export/index.action", + "displayName": "Import/Export", + "icon": "../icons/dhis-web-importexport.png", + "description": "" + }, + { + "name": "WHO Metadata browser", + "namespace": "WHO Metadata browser", + "defaultAction": "https://debug.dhis2.org/dev/api/apps/WHO-Metadata-browser/index.html", + "displayName": "", + "icon": "https://debug.dhis2.org/dev/api/apps/WHO-Metadata-browser/icons/medicine-48.png", + "description": "" + }, + { + "name": "Dashboard Classic", + "namespace": "Dashboard Classic", + "defaultAction": "https://debug.dhis2.org/dev/api/apps/Dashboard-Classic/index.html", + "displayName": "", + "icon": "https://debug.dhis2.org/dev/api/apps/Dashboard-Classic/icon.png", + "description": "DHIS2 Legacy Dashboard App" + } + ] +} diff --git a/cypress/fixtures/HeaderBar/systemInfo.json b/cypress/fixtures/HeaderBar/systemInfo.json deleted file mode 100644 index 08e51bd481..0000000000 --- a/cypress/fixtures/HeaderBar/systemInfo.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "systemName": "Foobar", - "contextPath": "https://play.dhis2.org/2.32.0" -} diff --git a/cypress/integration/HeaderBar/common/index.js b/cypress/integration/HeaderBar/common/index.js index c23944abe4..071216e2e1 100644 --- a/cypress/integration/HeaderBar/common/index.js +++ b/cypress/integration/HeaderBar/common/index.js @@ -1,50 +1,52 @@ import { Before, Given } from 'cypress-cucumber-preprocessor/steps' +export const baseUrl = 'https://domain.tld/' +export const webCommons = 'https://domain.tld/dhis-web-commons/' + Before(() => { - cy.fixture('HeaderBar/systemInfo').as('systemInfoFixture') + cy.fixture('HeaderBar/applicationTitle').as('applicationTitleFixture') cy.fixture('HeaderBar/me').as('meFixture') cy.fixture('HeaderBar/getModules').as('modulesFixture') cy.fixture('HeaderBar/dashboard').as('dashboardFixture') cy.fixture('HeaderBar/logo_banner').as('logoFixture') -}) - -Given('the HeaderBar loads without an error', () => { cy.server() - cy.get('@systemInfoFixture').then(fx => { + cy.get('@applicationTitleFixture').then(fx => { cy.route({ - url: 'https://domain.tld/api/system/info', + url: `${baseUrl}/api/systemSettings/applicationTitle`, response: fx, - }).as('systemInfo') + }).as('applicationTitle') }) cy.get('@meFixture').then(fx => { cy.route({ - url: 'https://domain.tld/api/me', + url: `${baseUrl}/api/me`, response: fx, - }).as('systemInfo') + }).as('me') }) cy.get('@modulesFixture').then(fx => { cy.route({ - url: 'https://domain.tld/dhis-web-commons/menu/getModules.action', + url: `${baseUrl}/dhis-web-commons/menu/getModules.action`, response: fx, }).as('modules') }) cy.get('@dashboardFixture').then(fx => { cy.route({ - url: 'https://domain.tld/api/me/dashboard', + url: `${baseUrl}/api/me/dashboard`, response: fx, }).as('dashboard') }) cy.get('@logoFixture').then(fx => { cy.route({ - url: 'https://domain.tld/api/staticContent/logo_banner', + url: `${baseUrl}/api/staticContent/logo_banner`, response: fx, }).as('logo_banner') }) +}) +Given('the HeaderBar loads without an error', () => { cy.visitStory('HeaderBarTesting', 'Default') }) diff --git a/cypress/integration/HeaderBar/the_headerbar_contains_a_menu_to_all_apps/the_user_will_be_offered_a_menu_with_5_apps.js b/cypress/integration/HeaderBar/the_headerbar_contains_a_menu_to_all_apps/the_user_will_be_offered_a_menu_with_5_apps.js index 44429141d2..7b1de7a600 100644 --- a/cypress/integration/HeaderBar/the_headerbar_contains_a_menu_to_all_apps/the_user_will_be_offered_a_menu_with_5_apps.js +++ b/cypress/integration/HeaderBar/the_headerbar_contains_a_menu_to_all_apps/the_user_will_be_offered_a_menu_with_5_apps.js @@ -1,13 +1,16 @@ -import '../common/index.js' +import { webCommons } from '../common/index.js' import { When, Then, Given } from 'cypress-cucumber-preprocessor/steps' Given('there are 5 apps available to the user', () => { - cy.fixture('HeaderBar/getModules') - .then(response => ({ - ...response, - modules: response.modules.slice(0, 5), - })) - .as('modulesFixture') + cy.get('@modulesFixture').then(fx => { + cy.route({ + url: `${webCommons}menu/getModules.action`, + response: { + ...fx, + modules: fx.modules.slice(0, 5), + }, + }).as('modules') + }) }) When('the user clicks on the menu icons', () => { diff --git a/cypress/integration/HeaderBar/the_headerbar_should_contain_a_logo_that_links_to_the_homepage/headerbar_contains_logo.js b/cypress/integration/HeaderBar/the_headerbar_should_contain_a_logo_that_links_to_the_homepage/headerbar_contains_logo.js index b9f58cc485..93fb3e68b5 100644 --- a/cypress/integration/HeaderBar/the_headerbar_should_contain_a_logo_that_links_to_the_homepage/headerbar_contains_logo.js +++ b/cypress/integration/HeaderBar/the_headerbar_should_contain_a_logo_that_links_to_the_homepage/headerbar_contains_logo.js @@ -1,14 +1,12 @@ -import '../common/index.js' import { Then } from 'cypress-cucumber-preprocessor/steps' +import { baseUrl } from '../common/index.js' Then('the HeaderBar should display the dhis2 logo', () => { cy.get('[data-test="headerbar-logo"]').should('be.visible') }) Then('the logo should link to the homepage', () => { - cy.getAll('@systemInfoFixture', '[data-test="headerbar-logo"] a').should( - ([{ contextPath }, $a]) => { - expect($a.attr('href')).to.equal(contextPath) - } - ) + cy.get('[data-test="headerbar-logo"] a').should($a => { + expect($a.attr('href')).to.equal(baseUrl) + }) }) diff --git a/cypress/integration/HeaderBar/the_headerbar_should_display_the_title_provided_by_the_backend_and_the_app/the_headerbar_displays_the_custom_title.js b/cypress/integration/HeaderBar/the_headerbar_should_display_the_title_provided_by_the_backend_and_the_app/the_headerbar_displays_the_custom_title.js index 34c3d5c21a..1a26ae55e1 100644 --- a/cypress/integration/HeaderBar/the_headerbar_should_display_the_title_provided_by_the_backend_and_the_app/the_headerbar_displays_the_custom_title.js +++ b/cypress/integration/HeaderBar/the_headerbar_should_display_the_title_provided_by_the_backend_and_the_app/the_headerbar_displays_the_custom_title.js @@ -1,15 +1,15 @@ -import '../common/index' +import { baseUrl } from '../common/index' import { Then, Given } from 'cypress-cucumber-preprocessor/steps' Given( 'the custom title is {string} and the app title is "Example!"', - systemName => { - cy.fixture('HeaderBar/systemInfo') - .then(response => ({ - ...response, - systemName, - })) - .as('systemInfoFixture') + applicationTitle => { + cy.get('@applicationTitleFixture').then(fx => { + cy.route({ + url: `${baseUrl}api/systemSettings/applicationTitle`, + response: { ...fx, applicationTitle }, + }).as('applicationTitle') + }) } ) diff --git a/cypress/integration/HeaderBar/the_search_should_escape_regexp_character.feature b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character.feature new file mode 100644 index 0000000000..29a4621c77 --- /dev/null +++ b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character.feature @@ -0,0 +1,49 @@ +Feature: The search should escape regexp characters + + Scenario Outline: The user searches for an app with a regex character + Given some app names contain a + And the HeaderBar loads without an error + And the search contains a + Then only apps with in their name should be shown + + Examples: + | char | + | / | + | ( | + | ) | + | [ | + | ] | + | { | + | } | + | * | + | + | + | ? | + | . | + | ^ | + | $ | + | \| | + | \\ | + + Scenario Outline: The modules do not contain items with special chars + Given the HeaderBar loads without an error + And the search contains a + And no app name contains a + Then no results should be shown + + Examples: + | char | + | / | + | ( | + | ) | + | [ | + | ] | + | { | + | } | + | * | + | + | + | ? | + | . | + | ^ | + | $ | + | \| | + | \\ | diff --git a/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/common.js b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/common.js new file mode 100644 index 0000000000..896e2b491c --- /dev/null +++ b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/common.js @@ -0,0 +1,6 @@ +import { Given } from 'cypress-cucumber-preprocessor/steps' + +Given(/the search contains a (.*)/, character => { + cy.get('[data-test="headerbar-apps-icon"]').click() + cy.get('#filter').type(character) +}) diff --git a/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/the_modules_do_not_contain_items_with_special_chars.js b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/the_modules_do_not_contain_items_with_special_chars.js new file mode 100644 index 0000000000..6f22df3a1c --- /dev/null +++ b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/the_modules_do_not_contain_items_with_special_chars.js @@ -0,0 +1,20 @@ +import '../common/index' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given(/no app name contains a (.*)/, character => { + cy.wrap(character).then(char => { + cy.get('@modulesFixture').then(fx => { + const modulesWithSpecialChar = fx.modules.filter(module => { + return module.displayName.indexOf(char) !== -1 + }) + + expect(modulesWithSpecialChar).to.have.length(0) + }) + }) +}) + +Then('no results should be shown', () => { + cy.get('[data-test="headerbar-apps-menu-list"] > a > div').should( + 'not.exist' + ) +}) diff --git a/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/the_user_searches_for_an_app_with_a_regex_character.js b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/the_user_searches_for_an_app_with_a_regex_character.js new file mode 100644 index 0000000000..74e18e5665 --- /dev/null +++ b/cypress/integration/HeaderBar/the_search_should_escape_regexp_character/the_user_searches_for_an_app_with_a_regex_character.js @@ -0,0 +1,37 @@ +import '../common/index' +import { Given, Then } from 'cypress-cucumber-preprocessor/steps' + +Given(/some app names contain a (.*)/, character => { + // use fixture with special chars + cy.fixture('HeaderBar/getModulesWithSpecialChars').as('modulesFixture') + + // set fixture as response of the modules action endpoint + cy.get('@modulesFixture').then(fx => { + cy.route({ + url: 'https://domain.tld/dhis-web-commons/menu/getModules.action', + response: fx, + }).as('modules') + }) + + // verify that there's a module with the special char in its name + cy.wrap(character).then(char => { + cy.get('@modulesFixture').then(fx => { + const modulesWithSpecialChar = fx.modules.filter(module => { + return module.displayName.indexOf(char) !== -1 + }) + + expect(modulesWithSpecialChar).to.have.length.of.at.least(1) + }) + }) +}) + +Then(/only apps with (.*) in their name should be shown/, character => { + cy.get('[data-test="headerbar-apps-menu-list"] > a > div').should( + $modules => { + $modules.each((index, module) => { + const displayName = Cypress.$(module).text() + expect(displayName.indexOf(character)).to.not.eql(-1) + }) + } + ) +}) diff --git a/packages/widgets/src/HeaderBar/Apps.js b/packages/widgets/src/HeaderBar/Apps.js index c0bf5d658c..b53baf5063 100755 --- a/packages/widgets/src/HeaderBar/Apps.js +++ b/packages/widgets/src/HeaderBar/Apps.js @@ -1,13 +1,14 @@ +import { Card } from '@dhis2/ui-core' +import { Settings, Apps as AppsIcon } from '@dhis2/ui-icons' +import { colors, theme } from '@dhis2/ui-constants' +import { useConfig } from '@dhis2/app-runtime' import React from 'react' import css from 'styled-jsx/css' - -import propTypes from '@dhis2/prop-types' import i18n from '@dhis2/d2-i18n' +import propTypes from '@dhis2/prop-types' -import { colors, theme } from '@dhis2/ui-constants' -import { Card } from '@dhis2/ui-core' import { InputField } from '../InputField/InputField.js' -import { Settings, Apps as AppsIcon } from '@dhis2/ui-icons' +import { joinPath } from './joinPath.js' const appIcon = css.resolve` svg { @@ -28,7 +29,17 @@ const settingsIcon = css.resolve` } ` -function Search({ value, onChange, contextPath }) { +/** + * Copied from here: + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#Escaping + */ +function escapeRegExpCharacters(text) { + return text.replace(/[/.*+?^${}()|[\]\\]/g, '\\$&') +} + +function Search({ value, onChange }) { + const { baseUrl } = useConfig() + return (
@@ -42,7 +53,7 @@ function Search({ value, onChange, contextPath }) { - + @@ -70,7 +81,6 @@ function Search({ value, onChange, contextPath }) { } Search.propTypes = { - contextPath: propTypes.string.isRequired, value: propTypes.string.isRequired, onChange: propTypes.func.isRequired, } @@ -140,8 +150,13 @@ function List({ apps, filter }) { {apps .filter(({ displayName, name }) => { const appName = displayName || name + const formattedAppName = appName.toLowerCase() + const formattedFilter = escapeRegExpCharacters( + filter + ).toLowerCase() + return filter.length > 0 - ? appName.toLowerCase().match(filter.toLowerCase()) + ? formattedAppName.match(formattedFilter) : true }) .map(({ displayName, name, defaultAction, icon }, idx) => ( @@ -205,17 +220,10 @@ export default class Apps extends React.Component { onChange = ({ value }) => this.setState({ filter: value }) - onIconClick = () => this.setState({ filter: '' }) - AppMenu = apps => (
- + @@ -261,5 +269,4 @@ export default class Apps extends React.Component { Apps.propTypes = { apps: propTypes.array.isRequired, - contextPath: propTypes.string.isRequired, } diff --git a/packages/widgets/src/HeaderBar/HeaderBar.js b/packages/widgets/src/HeaderBar/HeaderBar.js index 085a35a4a1..db0e62e129 100755 --- a/packages/widgets/src/HeaderBar/HeaderBar.js +++ b/packages/widgets/src/HeaderBar/HeaderBar.js @@ -1,8 +1,8 @@ -import React, { useEffect } from 'react' +import React, { useMemo } from 'react' import propTypes from '@dhis2/prop-types' -import { useDataQuery } from '@dhis2/app-runtime' +import { useDataQuery, useConfig } from '@dhis2/app-runtime' import { colors } from '@dhis2/ui-constants' import Apps from './Apps.js' @@ -12,11 +12,11 @@ import { Title } from './Title.js' import { Notifications } from './Notifications.js' import i18n from '@dhis2/d2-i18n' -import '../locales' +import { joinPath } from './joinPath.js' const query = { - systemInfo: { - resource: 'system/info', + title: { + resource: 'systemSettings/applicationTitle', }, user: { resource: 'me', @@ -30,24 +30,26 @@ const query = { } export const HeaderBar = ({ appName, className }) => { + const { baseUrl } = useConfig() const { loading, error, data } = useDataQuery(query) - useEffect(() => { + const apps = useMemo(() => { const getPath = path => path.startsWith('http:') || path.startsWith('https:') ? path - : `${data.systemInfo.contextPath}/api/${path}` + : joinPath(baseUrl, 'api', path) - if (!loading && !error) - data.apps.modules.forEach(app => { - app.icon = getPath(app.icon) - app.defaultAction = getPath(app.defaultAction) - }) + return data?.apps.modules.map(app => ({ + ...app, + icon: getPath(app.icon), + defaultAction: getPath(app.defaultAction), + })) }, [data]) 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 const locale = data.user.settings.keyUiLocale || 'en' + i18n.setDefaultNamespace('default') i18n.changeLanguage(locale) } @@ -55,10 +57,10 @@ export const HeaderBar = ({ appName, className }) => {
{!loading && !error && ( <> - + <div className="right-control-spacer" /> <Notifications @@ -66,16 +68,9 @@ export const HeaderBar = ({ appName, className }) => { data.notifications.unreadInterpretations } messages={data.notifications.unreadMessageConversations} - contextPath={data.systemInfo.contextPath} - /> - <Apps - apps={data.apps.modules} - contextPath={data.systemInfo.contextPath} - /> - <Profile - user={data.user} - contextPath={data.systemInfo.contextPath} /> + <Apps apps={apps} /> + <Profile user={data.user} baseUrl={baseUrl} /> </> )} diff --git a/packages/widgets/src/HeaderBar/HeaderBar.stories.e2e.js b/packages/widgets/src/HeaderBar/HeaderBar.stories.e2e.js index 526b525a7d..2f03f5287c 100644 --- a/packages/widgets/src/HeaderBar/HeaderBar.stories.e2e.js +++ b/packages/widgets/src/HeaderBar/HeaderBar.stories.e2e.js @@ -1,11 +1,16 @@ import React from 'react' import { storiesOf } from '@storybook/react' -import { DataProvider } from '@dhis2/app-runtime' +import { Provider } from '@dhis2/app-runtime' import { HeaderBar } from './HeaderBar.js' storiesOf('HeaderBarTesting', module).add('Default', () => ( - <DataProvider baseUrl="https://domain.tld" apiVersion=""> + <Provider + config={{ + baseUrl: 'https://domain.tld/', + apiVersion: '', + }} + > <HeaderBar appName="Example!" /> - </DataProvider> + </Provider> )) diff --git a/packages/widgets/src/HeaderBar/HeaderBar.stories.js b/packages/widgets/src/HeaderBar/HeaderBar.stories.js index 9a30f9d8ed..fcf5c875e2 100644 --- a/packages/widgets/src/HeaderBar/HeaderBar.stories.js +++ b/packages/widgets/src/HeaderBar/HeaderBar.stories.js @@ -1,14 +1,18 @@ import React from 'react' import { storiesOf } from '@storybook/react' -import { CustomDataProvider } from '@dhis2/app-runtime' +import { CustomDataProvider, Provider } from '@dhis2/app-runtime' //import { Provider } from '@dhis2/app-runtime' import { HeaderBar } from './HeaderBar.js' +const mockConfig = { + baseUrl: 'https://debug.dhis2.org/dev/', + apiVersion: 33, +} + const customData = { - 'system/info': { - systemName: 'Foobar', - contextPath: 'https://play.dhis2.org/2.32.0', + 'systemSettings/applicationTitle': { + applicationTitle: 'Foobar', }, me: { name: 'John Doe', @@ -76,31 +80,31 @@ const customData = { description: '', }, { - name: 'dhis-web-importexport', - namespace: '/dhis-web-importexport', - defaultAction: '../dhis-web-importexport/index.action', + name: 'dhis-web-import-export', + namespace: '/dhis-web-import-export', + defaultAction: '../dhis-web-import-export/index.action', displayName: 'Import/Export', - icon: '../icons/dhis-web-importexport.png', + icon: '../icons/dhis-web-import-export.png', description: '', }, { name: 'WHO Metadata browser', namespace: 'WHO Metadata browser', defaultAction: - 'https://play.dhis2.org/2.32.0/api/apps/WHO-Metadata-browser/index.html', + 'https://debug.dhis2.org/dev/api/apps/WHO-Metadata-browser/index.html', displayName: '', icon: - 'https://play.dhis2.org/2.32.0/api/apps/WHO-Metadata-browser/icons/medicine-48.png', + 'https://debug.dhis2.org/dev/api/apps/WHO-Metadata-browser/icons/medicine-48.png', description: '', }, { name: 'Dashboard Classic', namespace: 'Dashboard Classic', defaultAction: - 'https://play.dhis2.org/2.32.0/api/apps/Dashboard-Classic/index.html', - displayName: '', + 'https://debug.dhis2.org/dev/api/apps/Dashboard-Classic/index.html', + displayName: 'Dashboard Classic', icon: - 'https://play.dhis2.org/2.32.0/api/apps/Dashboard-Classic/icon.png', + 'https://debug.dhis2.org/dev/api/apps/Dashboard-Classic/icon.png', description: 'DHIS2 Legacy Dashboard App', }, ], @@ -111,7 +115,7 @@ const customData = { }, } -const customLogo = { +const customLogoData = { ...customData, 'staticContent/logo_banner': { images: { @@ -120,26 +124,60 @@ const customLogo = { }, } +const customLocaleData = { + ...customData, + 'systemSettings/applicationTitle': { + applicationTitle: 'Le Gros Foobar', + }, + me: { + ...customData.me, + settings: { + keyUiLocale: 'fr', + }, + }, + 'action::menu/getModules': { + modules: customData['action::menu/getModules'].modules.map(mod => ({ + ...mod, + displayName: `Le ${mod.displayName}`, + })), + }, +} + storiesOf('Components/Widgets/HeaderBar', module) .add('Default', () => ( - <CustomDataProvider data={customData}> - <HeaderBar appName="Example!" /> - </CustomDataProvider> + <Provider config={mockConfig}> + <CustomDataProvider data={customData}> + <HeaderBar appName="Example!" /> + </CustomDataProvider> + </Provider> )) .add('Custom Logo (wide dimension)', () => ( - <CustomDataProvider data={customLogo}> - <HeaderBar appName="Example!" /> - </CustomDataProvider> + <Provider config={mockConfig}> + <CustomDataProvider data={customLogoData}> + <HeaderBar appName="Example!" /> + </CustomDataProvider> + </Provider> + )) + .add('Non-english user locale', () => ( + <Provider config={mockConfig}> + <CustomDataProvider data={customLocaleData}> + <HeaderBar appName="Exemple!" /> + </CustomDataProvider> + </Provider> )) .add('Loading...', () => ( - <CustomDataProvider options={{ loadForever: true }}> - <HeaderBar appName="Example!" /> - </CustomDataProvider> + <Provider config={mockConfig}> + <CustomDataProvider options={{ loadForever: true }}> + <HeaderBar appName="Example!" /> + </CustomDataProvider> + </Provider> )) .add('Error!', () => ( - <CustomDataProvider data={{}}> - <HeaderBar appName="Example!" /> - </CustomDataProvider> + <Provider config={mockConfig}> + <CustomDataProvider data={{}}> + <HeaderBar appName="Example!" /> + </CustomDataProvider> + </Provider> )) /* diff --git a/packages/widgets/src/HeaderBar/Logo.js b/packages/widgets/src/HeaderBar/Logo.js index ed0c41f366..b61872b94f 100755 --- a/packages/widgets/src/HeaderBar/Logo.js +++ b/packages/widgets/src/HeaderBar/Logo.js @@ -1,34 +1,34 @@ import React from 'react' -import propTypes from '@dhis2/prop-types' +import { useConfig } from '@dhis2/app-runtime' import { LogoImage } from './LogoImage.js' -export const Logo = ({ baseUrl }) => ( - <div data-test="headerbar-logo"> - <a href={baseUrl}> - <LogoImage /> - </a> +export const Logo = () => { + const { baseUrl } = useConfig() - <style jsx>{` - div { - box-sizing: border-box; - min-width: 49px; - max-height: 48px; - margin: 0 12px 0 0; - border-right: 1px solid rgba(32, 32, 32, 0.15); - } + return ( + <div data-test="headerbar-logo"> + <a href={baseUrl}> + <LogoImage /> + </a> - a, - a:hover, - a:focus, - a:active, - a:visited { - user-select: none; - } - `}</style> - </div> -) + <style jsx>{` + div { + box-sizing: border-box; + min-width: 49px; + max-height: 48px; + margin: 0 12px 0 0; + border-right: 1px solid rgba(32, 32, 32, 0.15); + } -Logo.propTypes = { - baseUrl: propTypes.string.isRequired, + a, + a:hover, + a:focus, + a:active, + a:visited { + user-select: none; + } + `}</style> + </div> + ) } diff --git a/packages/widgets/src/HeaderBar/Notifications.js b/packages/widgets/src/HeaderBar/Notifications.js index dc1b571ea9..1699e583e9 100755 --- a/packages/widgets/src/HeaderBar/Notifications.js +++ b/packages/widgets/src/HeaderBar/Notifications.js @@ -1,37 +1,43 @@ +import { useConfig } from '@dhis2/app-runtime' import React from 'react' import propTypes from '@dhis2/prop-types' import { NotificationIcon } from './NotificationIcon.js' +import { joinPath } from './joinPath.js' -export const Notifications = ({ interpretations, messages, contextPath }) => ( - <div data-test="headerbar-notifications"> - <NotificationIcon - count={interpretations} - href={`${contextPath}/dhis-web-interpretation`} - kind="message" - dataTestId="headerbar-interpretations" - /> +export const Notifications = ({ interpretations, messages }) => { + const { baseUrl } = useConfig() - <NotificationIcon - message="email" - count={messages} - href={`${contextPath}/dhis-web-messaging`} - kind="interpretation" - dataTestId="headerbar-messages" - /> + return ( + <div data-test="headerbar-notifications"> + <NotificationIcon + count={interpretations} + href={joinPath(baseUrl, 'dhis-web-interpretation')} + kind="message" + dataTestId="headerbar-interpretations" + /> + + <NotificationIcon + message="email" + count={messages} + href={joinPath(baseUrl, 'dhis-web-messaging')} + kind="interpretation" + dataTestId="headerbar-messages" + /> + + <style jsx>{` + div { + user-select: none; + display: flex; + flex-direction: row; + align-items: center; + } + `}</style> + </div> + ) +} - <style jsx>{` - div { - user-select: none; - display: flex; - flex-direction: row; - align-items: center; - } - `}</style> - </div> -) Notifications.propTypes = { - contextPath: propTypes.string, interpretations: propTypes.number, messages: propTypes.number, } diff --git a/packages/widgets/src/HeaderBar/Profile.js b/packages/widgets/src/HeaderBar/Profile.js index f484d00939..5c1908c280 100755 --- a/packages/widgets/src/HeaderBar/Profile.js +++ b/packages/widgets/src/HeaderBar/Profile.js @@ -1,17 +1,17 @@ import React from 'react' import propTypes from '@dhis2/prop-types' +import { ImageIcon } from './ImageIcon.js' import { ProfileMenu } from './Profile/ProfileMenu.js' - import { TextIcon } from './TextIcon.js' -import { ImageIcon } from './ImageIcon.js' +import { joinPath } from './joinPath.js' -function avatarPath(avatar, contextPath) { +function avatarPath(avatar, baseUrl) { if (!avatar) { return null } - return `${contextPath}/api/fileResources/${avatar.id}/data` + return joinPath(baseUrl, 'api/fileResources', avatar.id, 'data') } export default class Profile extends React.Component { @@ -35,8 +35,8 @@ export default class Profile extends React.Component { onToggle = () => this.setState({ show: !this.state.show }) - userIcon(me, contextPath) { - const avatar = avatarPath(me.avatar, contextPath) + userIcon(me, baseUrl) { + const avatar = avatarPath(me.avatar, baseUrl) if (avatar) { return ( @@ -58,21 +58,20 @@ export default class Profile extends React.Component { } render() { - const { user, contextPath } = this.props + const { user, baseUrl } = this.props return ( <div ref={c => (this.elContainer = c)} data-test="headerbar-profile" > - {this.userIcon(user, contextPath)} + {this.userIcon(user, baseUrl)} {this.state.show ? ( <ProfileMenu - avatar={avatarPath(user.avatar, contextPath)} + avatar={avatarPath(user.avatar, baseUrl)} name={user.name} email={user.email} - contextPath={contextPath} /> ) : null} @@ -90,6 +89,6 @@ export default class Profile extends React.Component { } Profile.propTypes = { - contextPath: propTypes.string.isRequired, + baseUrl: propTypes.string.isRequired, user: propTypes.object.isRequired, } diff --git a/packages/widgets/src/HeaderBar/Profile/ProfileHeader.js b/packages/widgets/src/HeaderBar/Profile/ProfileHeader.js index 4075ea801a..91fff1366d 100755 --- a/packages/widgets/src/HeaderBar/Profile/ProfileHeader.js +++ b/packages/widgets/src/HeaderBar/Profile/ProfileHeader.js @@ -1,10 +1,11 @@ +import { useConfig } from '@dhis2/app-runtime' import React from 'react' -import propTypes from '@dhis2/prop-types' - import i18n from '@dhis2/d2-i18n' +import propTypes from '@dhis2/prop-types' -import { TextIcon } from '../TextIcon.js' import { ImageIcon } from '../ImageIcon.js' +import { TextIcon } from '../TextIcon.js' +import { joinPath } from '../joinPath.js' const ProfileName = ({ children }) => ( <div data-test="headerbar-profile-username"> @@ -40,36 +41,38 @@ ProfileEmail.propTypes = { children: propTypes.string, } -const ProfileEdit = ({ children, contextPath }) => ( - <a - href={`${contextPath}/dhis-web-user-profile/#/profile`} - data-test="headerbar-profile-edit-profile-link" - > - {children} +const ProfileEdit = ({ children }) => { + const { baseUrl } = useConfig() + + return ( + <a + href={joinPath(baseUrl, 'dhis-web-user-profile/#/profile')} + data-test="headerbar-profile-edit-profile-link" + > + {children} + + <style jsx>{` + a { + color: rgba(0, 0, 0, 0.87); + font-size: 12px; + line-height: 14px; + text-decoration: underline; + cursor: pointer; + } + `}</style> + </a> + ) +} - <style jsx>{` - a { - color: rgba(0, 0, 0, 0.87); - font-size: 12px; - line-height: 14px; - text-decoration: underline; - cursor: pointer; - } - `}</style> - </a> -) ProfileEdit.propTypes = { children: propTypes.string, - contextPath: propTypes.string, } -const ProfileDetails = ({ name, email, contextPath }) => ( +const ProfileDetails = ({ name, email }) => ( <div> <ProfileName>{name}</ProfileName> <ProfileEmail>{email}</ProfileEmail> - <ProfileEdit contextPath={contextPath}> - {i18n.t('Edit profile')} - </ProfileEdit> + <ProfileEdit>{i18n.t('Edit profile')}</ProfileEdit> <style jsx>{` div { @@ -83,17 +86,17 @@ const ProfileDetails = ({ name, email, contextPath }) => ( `}</style> </div> ) + ProfileDetails.propTypes = { - contextPath: propTypes.string, email: propTypes.string, name: propTypes.string, } -export const ProfileHeader = ({ name, email, img, contextPath }) => ( +export const ProfileHeader = ({ name, email, img }) => ( <div> {img ? <ImageIcon src={img} /> : <TextIcon name={name} />} - <ProfileDetails name={name} email={email} contextPath={contextPath} /> + <ProfileDetails name={name} email={email} /> <style jsx>{` div { @@ -107,7 +110,6 @@ export const ProfileHeader = ({ name, email, img, contextPath }) => ( ) ProfileHeader.propTypes = { - contextPath: propTypes.string.isRequired, email: propTypes.string, img: propTypes.string, name: propTypes.string, diff --git a/packages/widgets/src/HeaderBar/Profile/ProfileMenu.js b/packages/widgets/src/HeaderBar/Profile/ProfileMenu.js index b68d8f73cc..eb57104d9d 100755 --- a/packages/widgets/src/HeaderBar/Profile/ProfileMenu.js +++ b/packages/widgets/src/HeaderBar/Profile/ProfileMenu.js @@ -1,17 +1,18 @@ +import { Account } from '@dhis2/ui-icons' +import { Card, Divider, MenuItem } from '@dhis2/ui-core' +import { Exit } from '@dhis2/ui-icons' +import { Help } from '@dhis2/ui-icons' +import { Info } from '@dhis2/ui-icons' +import { Settings } from '@dhis2/ui-icons' +import { colors } from '@dhis2/ui-constants' +import { useConfig } from '@dhis2/app-runtime' import React from 'react' -import propTypes from '@dhis2/prop-types' import css from 'styled-jsx/css' - import i18n from '@dhis2/d2-i18n' -import { Card, Divider, MenuItem } from '@dhis2/ui-core' -import { colors } from '@dhis2/ui-constants' -import { Settings } from '@dhis2/ui-icons' -import { Info } from '@dhis2/ui-icons' -import { Help } from '@dhis2/ui-icons' -import { Exit } from '@dhis2/ui-icons' -import { Account } from '@dhis2/ui-icons' +import propTypes from '@dhis2/prop-types' import { ProfileHeader } from './ProfileHeader.js' +import { joinPath } from '../joinPath.js' const iconStyle = css.resolve` svg { @@ -22,18 +23,18 @@ const iconStyle = css.resolve` } ` -const list = [ +const getMenuList = () => [ { icon: <Settings className={iconStyle.className} />, label: i18n.t('Settings'), value: 'settings', - link: `/dhis-web-user-profile/#/settings`, + link: 'dhis-web-user-profile/#/settings', }, { icon: <Account className={iconStyle.className} />, label: i18n.t('Account'), value: 'account', - link: `/dhis-web-user-profile/#/account`, + link: 'dhis-web-user-profile/#/account', }, { icon: <Help className={iconStyle.className} />, @@ -47,77 +48,73 @@ const list = [ icon: <Info className={iconStyle.className} />, label: i18n.t('About DHIS2'), value: 'about', - link: `/dhis-web-user-profile/#/aboutPage`, + link: 'dhis-web-user-profile/#/aboutPage', }, { icon: <Exit className={iconStyle.className} />, label: i18n.t('Logout'), value: 'logout', - link: `/dhis-web-commons-security/logout.action`, + link: 'dhis-web-commons-security/logout.action', }, ] -const ProfileContents = ({ name, email, avatar, contextPath }) => ( - <Card> - <div> - <ProfileHeader - name={name} - email={email} - img={avatar} - contextPath={contextPath} - /> - <Divider margin="13px 0 7px 0" /> - <ul data-test="headerbar-profile-menu"> - {list.map(({ label, value, icon, link, nobase }) => ( - <MenuItem - href={nobase ? link : `${contextPath}${link}`} - key={`h-mi-${value}`} - label={label} - value={value} - icon={icon} - /> - ))} - </ul> - </div> +const ProfileContents = ({ name, email, avatar }) => { + const { baseUrl } = useConfig() - {iconStyle.styles} - <style jsx>{` - div { - width: 100%; - padding: 0; - } + return ( + <Card> + <div> + <ProfileHeader name={name} email={email} img={avatar} /> + <Divider margin="13px 0 7px 0" /> + <ul data-test="headerbar-profile-menu"> + {getMenuList().map( + ({ label, value, icon, link, nobase }) => ( + <MenuItem + href={nobase ? link : joinPath(baseUrl, link)} + key={`h-mi-${value}`} + label={label} + value={value} + icon={icon} + /> + ) + )} + </ul> + </div> - ul { - padding: 0; - margin: 0; - } + {iconStyle.styles} + <style jsx>{` + div { + width: 100%; + padding: 0; + } + + ul { + padding: 0; + margin: 0; + } + + a, + a:hover, + a:focus, + a:active, + a:visited { + text-decoration: none; + display: block; + } + `}</style> + </Card> + ) +} - a, - a:hover, - a:focus, - a:active, - a:visited { - text-decoration: none; - display: block; - } - `}</style> - </Card> -) ProfileContents.propTypes = { avatar: propTypes.element, - contextPath: propTypes.string, email: propTypes.string, name: propTypes.string, } -export const ProfileMenu = ({ avatar, name, email, contextPath }) => ( +export const ProfileMenu = ({ avatar, name, email }) => ( <div data-test="headerbar-profile-menu"> - <ProfileContents - name={name} - email={email} - avatar={avatar} - contextPath={contextPath} - /> + <ProfileContents name={name} email={email} avatar={avatar} /> <style jsx>{` div { z-index: 10000; @@ -130,9 +127,9 @@ export const ProfileMenu = ({ avatar, name, email, contextPath }) => ( `}</style> </div> ) + ProfileMenu.propTypes = { avatar: propTypes.element, - contextPath: propTypes.string, email: propTypes.string, name: propTypes.string, } diff --git a/packages/widgets/src/HeaderBar/joinPath.js b/packages/widgets/src/HeaderBar/joinPath.js new file mode 100644 index 0000000000..9d3499307c --- /dev/null +++ b/packages/widgets/src/HeaderBar/joinPath.js @@ -0,0 +1,4 @@ +export const joinPath = (...parts) => { + const realParts = parts.filter(part => !!part) + return realParts.map(part => part.replace(/^\/+|\/+$/g, '')).join('/') +}