Compare commits

..

1 Commits

Author SHA1 Message Date
David Joy
a5e3997014 fix: updating frontend-base to 3.0.0
Also locking package.json versions and updating associated @edx libraries
2019-09-30 13:14:32 -04:00
79 changed files with 17980 additions and 27902 deletions

33
.env Normal file
View File

@@ -0,0 +1,33 @@
BASE_URL=localhost:1995
SITE_NAME=Open edX
LMS_BASE_URL=http://localhost:18000
CREDENTIALS_BASE_URL=http://localhost:18150
ECOMMERCE_BASE_URL=http://localhost:18130
LOGIN_URL=http://localhost:18000/login
LOGOUT_URL=http://localhost:18000/login
CSRF_TOKEN_API_PATH=/csrf/api/v1/token
REFRESH_ACCESS_TOKEN_ENDPOINT=http://localhost:18000/login_refresh
SEGMENT_KEY=ul
ACCESS_TOKEN_COOKIE_NAME=edx-jwt-cookie-header-payload
USER_INFO_COOKIE_NAME=edx-user-info
CSRF_COOKIE_NAME=csrftoken
LANGUAGE_PREFERENCE_COOKIE_NAME=openedx-language-preference
SITE_NAME=edX
MARKETING_SITE_BASE_URL=http://localhost:18000
ENTERPRISE_MARKETING_URL=http://example.com
ENTERPRISE_MARKETING_UTM_CAMPAIGN=my_campaign
ENTERPRISE_MARKETING_UTM_SOURCE=edX profile
ENTERPRISE_MARKETING_FOOTER_UTM_MEDIUM=Footer
SUPPORT_URL=http://localhost:18000/support
CONTACT_URL=http://localhost:18000/contact
OPEN_SOURCE_URL=http://localhost:18000/openedx
TERMS_OF_SERVICE_URL=http://localhost:18000/terms-of-service
PRIVACY_POLICY_URL=http://localhost:18000/privacy-policy
FACEBOOK_URL=https://www.facebook.com
TWITTER_URL=https://twitter.com
YOU_TUBE_URL=https://www.youtube.com
LINKED_IN_URL=https://www.linkedin.com
REDDIT_URL=https://www.reddit.com
APPLE_APP_STORE_URL=https://www.apple.com/ios/app-store/
GOOGLE_PLAY_URL=https://play.google.com/store
ORDER_HISTORY_URL=localhost:1996/orders

View File

@@ -1,22 +0,0 @@
ACCESS_TOKEN_COOKIE_NAME=edx-jwt-cookie-header-payload
ACCOUNT_PROFILE_URL=http://localhost:1995
ACCOUNT_SETTINGS_URL=http://localhost:1997
BASE_URL=localhost:8080
CREDENTIALS_BASE_URL=http://localhost:18150
CSRF_TOKEN_API_PATH=/csrf/api/v1/token
ECOMMERCE_BASE_URL=http://localhost:18130
LANGUAGE_PREFERENCE_COOKIE_NAME=openedx-language-preference
LMS_BASE_URL=http://localhost:18000
STUDIO_BASE_URL=http://localhost:18010
LOGIN_URL=http://localhost:18000/login
LOGOUT_URL=http://localhost:18000/logout
MARKETING_SITE_BASE_URL=http://localhost:18000
ORDER_HISTORY_URL=localhost:1996/orders
REFRESH_ACCESS_TOKEN_ENDPOINT=http://localhost:18000/login_refresh
SEGMENT_KEY=null
SITE_NAME=Open edX
USER_INFO_COOKIE_NAME=edx-user-info
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico

View File

@@ -1,4 +0,0 @@
// eslint-disable-next-line import/no-extraneous-dependencies
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig('eslint');

19
.eslintrc.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "eslint-config-edx",
"parser": "babel-eslint",
"rules": {
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": [
"webpack.config.js",
"**/*.test.jsx",
"**/*.test.js"
]
}
]
},
"env": {
"jest": true
}
}

View File

@@ -1,19 +0,0 @@
# Run the workflow that adds new tickets that are either:
# - labelled "DEPR"
# - title starts with "[DEPR]"
# - body starts with "Proposal Date" (this is the first template field)
# to the org-wide DEPR project board
name: Add newly created DEPR issues to the DEPR project board
on:
issues:
types: [opened]
jobs:
routeissue:
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
secrets:
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}

View File

@@ -1,20 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "label: " it tries to apply
# the label indicated in rest of comment.
# If the comment starts with "remove label: ", it tries
# to remove the indicated label.
# Note: Labels are allowed to have spaces and this script does
# not parse spaces (as often a space is legitimate), so the command
# "label: really long lots of words label" will apply the
# label "really long lots of words label"
name: Allows for the adding and removing of labels via comment
on:
issue_comment:
types: [created]
jobs:
add_remove_labels:
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master

View File

@@ -1,36 +0,0 @@
name: Default CI
on:
push:
branches:
- master
pull_request:
branches:
- '**'
jobs:
tests:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Nodejs
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VER }}
- name: Install dependencies
run: npm ci
- name: Validate package-lock.json changes
run: make validate-no-uncommitted-package-lock-changes
- name: Lint
run: npm run lint
- name: Test
run: npm run test
- name: Build
run: npm run build
- name: i18n_extract
run: npm run i18n_extract
- name: Coverage
uses: codecov/codecov-action@v3

View File

@@ -1,10 +0,0 @@
# Run commitlint on the commit messages in a pull request.
name: Lint Commit Messages
on:
- pull_request
jobs:
commitlint:
uses: openedx/.github/.github/workflows/commitlint.yml@master

View File

@@ -1,13 +0,0 @@
#check package-lock file version
name: lockfileVersion check
on:
push:
branches:
- master
pull_request:
jobs:
version-check:
uses: openedx/.github/.github/workflows/lockfile-check.yml@master

View File

@@ -1,42 +0,0 @@
name: Release CI
on:
push:
branches:
- master
- alpha
jobs:
release:
name: Release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Nodejs Env
run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VER }}
- name: Install dependencies
run: npm ci
- name: Validate package-lock.json changes
run: make validate-no-uncommitted-package-lock-changes
- name: Lint
run: npm run lint
- name: Test
run: npm run test
- name: i18n_extract
run: npm run i18n_extract
- name: Coverage
uses: codecov/codecov-action@v3
- name: Build
run: npm run build
- name: Release
uses: cycjimmy/semantic-release-action@v3
with:
semantic_version: 16
env:
GITHUB_TOKEN: ${{ secrets.SEMANTIC_RELEASE_GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.SEMANTIC_RELEASE_NPM_TOKEN }}

View File

@@ -1,12 +0,0 @@
# This workflow runs when a comment is made on the ticket
# If the comment starts with "assign me" it assigns the author to the
# ticket (case insensitive)
name: Assign comment author to ticket if they say "assign me"
on:
issue_comment:
types: [created]
jobs:
self_assign_by_comment:
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master

5
.gitignore vendored
View File

@@ -5,8 +5,3 @@ dist
node_modules
temp
src/i18n/transifex_input.json
module.config.js
.idea/
.vscode
src/i18n/messages

1
.nvmrc
View File

@@ -1 +0,0 @@
18

View File

@@ -1,8 +1,5 @@
{
"branches": [
"master",
{name: "alpha", prerelease: true}
],
"branch": "master",
"tagFormat": "v${version}",
"verifyConditions": [
"@semantic-release/npm",

17
.travis.yml Normal file
View File

@@ -0,0 +1,17 @@
language: node_js
node_js: 12
install:
- npm install
script:
- npm run lint
- npm run test
- npm run build
after_success:
- npm run semantic-release
- codecov
env:
global:
# GH_TOKEN
- secure: CFN/uOByWC+7S+AAXECLQ0mNgoiyCDl1ZB5AT5+/qP+xEVs0ysFKDWrD9W8KeQqHqCMPUKNt23nrgxwvFCkSx+n1MAnxZmOdVJWbzGbUB9TsqrwVUALkqof1MrRB8UFVEzIRaO60iRN/L3zVXML+4GsycYX6rHyptbAypxplpljDyKrY8tc/mM7AGZ9eVFGSq+7CXXmdvkhP9kLkH1tIYvR7wjTKvZHbHf6YRjIVCiyzxM4S/E9l8JRnbERp02XosRD62PUJXXk6EJVn6Qoub6CaPnpew5crW0iRF1UJs54U29zWd/S+LuW66WkLfJu7rDq6AFJNMtMNusJxwVkOv4X+p0oJDWZEhojW+/Wm10UAu8/g5oAqeePZEGiSbT3Hp1VqOc4FY/kmOLiM+L6oq/AA2XX6iiE8lA64IH5R8ApQamF4GTUYTvKHeLPgXnGJH7A95Xy9/+jmX9I9wJREMrHrkyPjoX/NTRdG+RrebB1+An9Bt9vAbG862gbOZfgTcuWHDOlG5gcA964Fr7RqR5a62yr45Gw+Q0lTrLj5mGAjjSpMRIAQzi9e7oXmoMZnvenu/WJAe5M+u+/gv82HeXcMwLvNNGSvz+0i1xNUOoX1zHG046oGKiX0Zu+l0JfNwihJTO7vJlaITmjhfOyufwpk74xEyrhf8nLF9e5Frec=
# NPM_TOKEN
- secure: fuV04Ctf0mgbw6nTJhsTzGZ6dyafZtGVj30ZkvSWsB9hUU8KDtl7wWVW9EayCQsSyyFgPY9RVav5olgC/zljAjDGg0nfF7n8uKIABA0TXdP683WZd06bVOmDXfL26B3yM83aW2xgHZN6VCCvCE8bLP1V6eV8nsr38gDgxVbHa0YasDMmvtYrog+IjxwjcJx7fD0RbYyi7iJC++pdw9kcFqOad28Us7L/jCn+rC3CmUT4kOwPjP5g1v5sB2FA7ouN5s1hUUTKuttV32VJgRP7wbZzoHeHX5BRGSqijdXNSaK5UwzqRnM1sGZkuNDZhJbSB3q90SQrPRgV+fRizwN8zs8Htb+Kk8+wGY6zNhmi9C+lUIv7UpDYbstMWYIf39+P/24Oj+vJBjMY30M9NWB8gt1OQ0dJUoK53v1+BMVmDB0doL6I53xwzUjQetvqOF0Wm3E3OrqJP00OJdzIcAeh2DzcIRW1SrBhI4HAsl7QJZNpRw11QzJ3K2iQSiWNd8qIuX+XJjzQdn1v09gCstvbP33Vn8tP1x0XTSi8wIhTDqE0bII/Sc80Jh76nu0ItQ8pmX4lGsER4C0N3Dp7Zz51yW7E70AWWLsUrMNdF0oHoP437ZRGPhYs5OI6x5AM2jlU8fJ5aUroEsYFCwkH0OO37THohpAQpApe9zL10miTEbw=

8
.tx/config Normal file
View File

@@ -0,0 +1,8 @@
[main]
host = https://www.transifex.com
[edx-platform.frontend-component-header]
file_filter = src/i18n/messages/<lang>.json
source_file = src/i18n/transifex_input.json
source_lang = en
type = KEYVALUEJSON

View File

@@ -1,20 +1,25 @@
transifex_resource = frontend-component-header
transifex_langs = "ar,fr,es_419,zh_CN"
transifex_utils = ./node_modules/.bin/transifex-utils.js
i18n = ./src/i18n
transifex_input = $(i18n)/transifex_input.json
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-formatjs
transifex_temp = ./temp/babel-plugin-react-intl
build:
rm -rf ./dist
./node_modules/.bin/fedx-scripts babel src --out-dir dist --source-maps --ignore **/*.test.jsx,**/__mocks__,**/__snapshots__,**/setupTest.js --copy-files
./node_modules/.bin/babel src --out-dir dist --source-maps --ignore **/*.test.jsx,**/__mocks__,**/__snapshots__,**/setupTest.js --copy-files
@# --copy-files will bring in everything else that wasn't processed by babel. Remove what we don't want.
@rm -rf dist/**/*.test.jsx
@rm -rf dist/**/__snapshots__
@rm -rf dist/__mocks__
requirements:
npm ci
npm install
i18n.extract:
# Pulling display strings from .jsx files into .json files...
@@ -32,6 +37,21 @@ detect_changed_source_translations:
# Checking for changed translations...
git diff --exit-code $(i18n)
# Pushes translations to Transifex. You must run make extract_translations first.
push_translations:
# Pushing strings to Transifex...
tx push -s
# Fetching hashes from Transifex...
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
# Writing out comments to file...
$(transifex_utils) $(transifex_temp) --comments
# Pushing comments to Transifex...
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --language=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:
# Checking for package-lock.json changes...

View File

@@ -1,177 +1,11 @@
#########################
frontend-component-header
#########################
|license| |Build Status| |Codecov| |npm_version| |npm_downloads| |semantic-release|
********
Purpose
********
A generic header for Open edX micro-frontend applications.
************
Getting Started
************
Prerequisites
=============
The `devstack`_ is currently recommended as a development environment for your
new MFE. If you start it with ``make dev.up.lms`` that should give you
everything you need as a companion to this frontend.
Note that it is also possible to use `Tutor`_ to develop an MFE. You can refer
to the `relevant tutor-mfe documentation`_ to get started using it.
.. _Devstack: https://github.com/openedx/devstack
.. _Tutor: https://github.com/overhangio/tutor
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
Requirements
============
This component uses ``@edx/frontend-platform`` services such as i18n, analytics, configuration, and the ``AppContext`` React component, and expects that it has been loaded into a micro-frontend that has been properly initialized via ``@edx/frontend-platform``'s ``initialize`` function. `Please visit the frontend template application to see an example. <https://github.com/openedx/frontend-template-application/blob/master/src/index.jsx>`_
Environment Variables
====================
* ``LMS_BASE_URL`` - The URL of the LMS of your Open edX instance.
* ``LOGOUT_URL`` - The URL of the API endpoint which performs a user logout.
* ``LOGIN_URL`` - The URL of the login page where a user can sign into their account.
* ``SITE_NAME`` - The user-facing name of the site, used as `alt` text on the logo in the header.
Defaults to "localhost" in development.
* ``LOGO_URL`` - The URL of the site's logo. This logo is displayed in the header.
* ``ORDER_HISTORY_URL`` - The URL of the order history page.
* ``ACCOUNT_PROFILE_URL`` - The URL of the account profile page.
* ``ACCOUNT_SETTINGS_URL`` - The URL of the account settings page.
* ``AUTHN_MINIMAL_HEADER`` - A boolean flag which hides the main menu, user menu, and logged-out
menu items when truthy. This is intended to be used in micro-frontends like
frontend-app-authentication in which these menus are considered distractions from the user's task.
Installation
============
To install this header into your Open edX micro-frontend, run the following command in your MFE:
``npm i --save @edx/frontend-component-header``
This will make the component available to be imported into your application.
Cloning and Startup
===================
.. code-block::
1. Clone your new repo:
``git clone https://github.com/openedx/frontend-component-header.git``
2. Use node v18.x.
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Install npm dependencies:
``cd frontend-component-header && npm ci``
4. Start the dev server:
``npm start``
Usage
=====
This library has the following exports:
* ``(default)``: The header as a React component.
* ``messages``: Internationalization messages suitable for use with `@edx/frontend-platform/i18n <https://edx.github.io/frontend-platform/module-Internationalization.html>`_
* ``dist/index.scss``: A SASS file which contains style information for the component. It should be imported into the micro-frontend's own SCSS file.
Examples
========
* `An example of component and messages usage. <https://github.com/openedx/frontend-template-application/blob/3355bb3a96232390e9056f35b06ffa8f105ed7ca/src/index.jsx#L21>`_
* `An example of SCSS file usage. <https://github.com/openedx/frontend-template-application/blob/3cd5485bf387b8c479baf6b02bf59e3061dc3465/src/index.scss#L8>`_
Development
===========
Install dependencies::
npm ci
Start the development server::
npm start
Build a production distribution::
npm run build
License
=======
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
Contributing
============
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
Getting Help
===========
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
Our real-time conversations are on Slack. You can request a `Slack
invitation`_, then join our `community Slack workspace`_. Because this is a
frontend repository, the best place to discuss it would be in the `#wg-frontend
channel`_.
For anything non-trivial, the best path is to open an issue in this repository
with as many details about the issue you are facing as you can provide.
https://github.com/openedx/frontend-component-header/issues
For more information about these options, see the `Getting Help`_ page.
.. _Slack invitation: https://openedx.org/slack
.. _community Slack workspace: https://openedx.slack.com/
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
.. _Getting Help: https://openedx.org/community/connect
The Open edX Code of Conduct
============================
All community members are expected to follow the `Open edX Code of Conduct`_.
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
Reporting Security Issues
=========================
Please do not report security issues in public. Please email security@openedx.org.
|Build Status| |Codecov| |npm_version| |npm_downloads| |license| |semantic-release|
This is the standard Open edX header for use in React applications. It has two exports:
- **default**: The Header Component
- **messages**: for i18n in the form of ``{ locale: { key: translatedString } }``
.. |Build Status| image:: https://api.travis-ci.com/edx/frontend-component-header.svg?branch=master
:target: https://travis-ci.com/edx/frontend-component-header
@@ -184,4 +18,4 @@ Please do not report security issues in public. Please email security@openedx.or
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-component-header.svg
:target: @edx/frontend-component-header
.. |semantic-release| image:: https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg
:target: https://github.com/semantic-release/semantic-release
:target: https://github.com/semantic-release/semantic-release

View File

@@ -1,3 +1,33 @@
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig('babel-preserve-modules');
module.exports = {
presets: [
[
'@babel/preset-env',
{
modules: false,
},
],
'@babel/preset-react',
],
plugins: [
'@babel/plugin-proposal-object-rest-spread',
'@babel/plugin-proposal-class-properties',
],
env: {
i18n: {
plugins: [
[
'react-intl',
{
messagesDir: './temp/babel-plugin-react-intl',
moduleSourceName: '@edx/frontend-i18n',
},
],
],
},
test: {
presets: [
'@babel/preset-env',
],
},
},
};

3
commitlint.config.js Normal file
View File

@@ -0,0 +1,3 @@
module.exports = {
extends: ['@commitlint/config-angular'],
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

View File

@@ -1,111 +0,0 @@
.. title:: Custom Header Component Documentation
Custom Header Component
=======================
Overview
--------
The ``Header`` component is used to display a header with a provided ``mainMenuItems``,
``secondaryMenuItems``, and ``userMenuItems`` props. If props are provided, the component will use them; otherwise,
if any of the props ``(mainMenuItems, secondaryMenuItems, userMenuItems)`` are not provided, default
items will be displayed. This component provides flexibility in customization, making it suitable for a wide
range of applications.
Props Details
-------------
The `Header` component accepts the following **optional** props for customization:
``mainMenuItems``
*****************
The main menu items is a list of menu items objects. On desktop screens, these items are displayed on the left side next to the logo icon.
On mobile screens, the main menu is displayed as a dropdown menu triggered by a hamburger icon. The main menu dropdown appears below the logo when opened.
Example:
::
[
{ type: 'item', href: '/courses', content: 'Courses', isActive: true },
{ type: 'item', href: '/programs', content: 'Programs' },
{ type: 'item', href: '/discover', content: 'Discover New', disabled, true },
{
type: 'submenu',
content: 'Sub Menu Item',
submenuContent: (
<>
<div className="mb-1"><a rel="noopener" href="#">Submenu item 1</a></div>
<div className="mb-1"><a rel="noopener" href="#">Submenu item 2</a></div>
</>
),
},
]
**Submenu Implementation**
To implement a submenu, set the type to ``submenu`` and provide a ``submenuContent`` property.
The submenuContent should be a React component (as shown in above example) that can be rendered.
**Note:**
- The ``type`` should be ``item`` or ``submenu``. If type is ``submenu``, it should contain ``submenuContent`` instead of ``href``.
- If any item is to be disabled, we can pass optional ``disabled: true`` in that item object and
- If any item is to be active, we can pass optional ``isActive: true`` in that item object
secondaryMenuItems
******************
The secondary menu items has same structure as ``mainMenuItems``. On desktop screen, these items are displayed on the right of header just before the userMenu avatar and on mobile screen,
these items are displayed below the mainMenu items in dropdown.
Example:
::
[
{ type: 'item', href: '/help', content: 'Help' },
]
userMenuItems
*************
The user menu items is list of objects. On desktop screens, these items are displayed as a dropdown menu on the most right side of the header. The dropdown is opened by clicking on the avatar icon, which is typically located at the far right of the header.
On mobile screens, the user menu is also displayed as a dropdown menu, appearing under the avatar icon.
Each object represents a group in the user menu. Each object contains the ``heading`` and
list of menu items to be displayed in that group. Heading is optional and will be displayed only if passed. There can
be multiple groups. For a normal user menu, a single group can be passed with empty heading.
Example:
::
[
{
heading: '',
items: [
{ type: 'item', href: '/profile', content: 'Profile' },
{ type: 'item', href: '/logout', content: 'Logout' }
]
},
]
Screenshots
***********
Desktop:
.. image:: ./images/desktop_header.png
Mobile:
.. image:: ./images/mobile_main_menu.png
.. image:: ./images/mobile_user_menu.png
Some Important Notes
--------------------
- Intl formatted strings should be passed in content attribute.
- Only menu items in the main menu can be disabled.
- Menu items in the main menu and user menu can have ``isActive`` prop.

10
example/.sassrc.js Normal file
View File

@@ -0,0 +1,10 @@
const path = require('path');
// Resolve ~tilda paths used in paragon to
// node_modules. This is used to reference
// bootstrap specifically.
module.exports = {
includePaths: [
path.resolve(__dirname, '../node_modules'),
],
};

12
example/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en-us">
<head>
<title>Example</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="root"></div>
<script src="./index.js"></script>
</body>
</html>

View File

@@ -2,71 +2,44 @@ import 'babel-polyfill';
import React from 'react';
import ReactDOM from 'react-dom';
import { initialize, getConfig, subscribe, APP_READY } from '@edx/frontend-platform';
import { AppContext, AppProvider } from '@edx/frontend-platform/react';
import Header from '@edx/frontend-component-header';
import { App, AppContext, APP_READY, AppProvider } from '@edx/frontend-base';
import { NewRelicLoggingService } from '@edx/frontend-logging';
import './index.scss';
import StudioHeader from '../src/studio-header/StudioHeader';
import SiteHeader from '../src/';
subscribe(APP_READY, () => {
App.subscribe(APP_READY, () => {
ReactDOM.render(
<AppProvider>
{/* We can fake out authentication by including another provider here with the data we want */}
<AppContext.Provider value={{
authenticatedUser: null,
config: getConfig(),
authenticatedUser: {
userId: null,
username: null,
roles: [],
administrator: false,
},
config: App.config
}}>
<Header />
<SiteHeader />
</AppContext.Provider>
<h5 className="mt-2 mb-5">Logged out state</h5>
{/* We can fake out authentication by including another provider here with the data we want */}
<AppContext.Provider value={{
authenticatedUser: {
userId: '123abc',
userId: null,
username: 'testuser',
roles: [],
administrator: false,
},
config: getConfig(),
config: App.config
}}>
<Header />
<SiteHeader />
</AppContext.Provider>
<h5 className="mt-2 mb-5">Logged in state</h5>
<AppContext.Provider value={{
authenticatedUser: {
userId: '123abc',
username: 'testuser',
roles: [],
administrator: false,
},
config: getConfig(),
}}>
<StudioHeader
number="run123"
org="testX"
title="Course Name"
isHiddenMainMenu={false}
mainMenuDropdowns={[
{
id: 'content-dropdown',
buttonTitle: 'Content',
items: [{
href: '#',
title: 'Outline',
}],
},
]}
outlineLink="#"
/>
</AppContext.Provider>
<h5 className="mt-2">Logged in state for Studio header</h5>
<h5 className="mt-2">Logged in state</h5>
</AppProvider>,
document.getElementById('root'),
);
});
initialize({
messages: []
});
App.initialize({ messages: [], loggingService: NewRelicLoggingService });

View File

@@ -1,6 +1,2 @@
@import "@edx/brand/paragon/fonts";
@import "@edx/brand/paragon/variables";
@import "@openedx/paragon/scss/core/core";
@import "@edx/brand/paragon/overrides";
@import "@edx/frontend-component-header/index";
@import "~@edx/paragon/scss/core/core.scss";
@import '../src/index.scss';

View File

@@ -1,7 +0,0 @@
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig('jest', {
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.js',
],
});

42198
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,74 +2,96 @@
"name": "@edx/frontend-component-header",
"version": "1.0.0-semantically-released",
"description": "The standard header for Open edX",
"main": "dist/index.js",
"publishConfig": {
"access": "public"
},
"main": "dist/index.js",
"scripts": {
"build": "make build",
"i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"test": "fedx-scripts jest --coverage"
"i18n_extract": "BABEL_ENV=i18n babel src --quiet > /dev/null",
"lint": "eslint --ext .js --ext .jsx .",
"semantic-release": "semantic-release",
"start": "parcel ./example/index.html --no-source-maps --out-dir example/dist",
"test": "jest --coverage --passWithNoTests"
},
"files": [
"/dist"
"/dist",
"/src"
],
"husky": {
"hooks": {
"pre-commit": "npm run lint"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/openedx/frontend-component-header.git"
"url": "git+https://github.com/edx/frontend-component-header.git"
},
"author": "edX",
"license": "AGPL-3.0",
"bugs": {
"url": "https://github.com/openedx/frontend-component-header/issues"
"url": "https://github.com/edx/frontend-component-header/issues"
},
"homepage": "https://github.com/openedx/frontend-component-header#readme",
"homepage": "https://github.com/edx/frontend-component-header#readme",
"devDependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/browserslist-config": "^1.1.1",
"@edx/frontend-platform": "7.1.3",
"@edx/reactifex": "^2.1.1",
"@openedx/frontend-build": "13.1.4",
"@openedx/paragon": "22.2.1",
"@testing-library/dom": "10.0.0",
"@testing-library/jest-dom": "5.17.0",
"@testing-library/react": "10.4.9",
"husky": "8.0.3",
"jest": "29.7.0",
"jest-chain": "1.1.6",
"prop-types": "15.8.1",
"react": "17.0.2",
"react-dom": "17.0.2",
"react-redux": "7.2.9",
"react-router-dom": "6.22.3",
"react-test-renderer": "17.0.2",
"redux": "4.2.1",
"redux-saga": "1.3.0"
"@babel/cli": "7.6.0",
"@babel/core": "7.6.0",
"@babel/plugin-proposal-class-properties": "7.5.5",
"@babel/plugin-proposal-object-rest-spread": "7.5.5",
"@babel/preset-env": "7.6.0",
"@babel/preset-react": "7.0.0",
"@edx/frontend-analytics": "3.0.0",
"@edx/frontend-auth": "7.0.1",
"@edx/frontend-base": "3.0.0",
"@edx/frontend-i18n": "3.0.3",
"@edx/frontend-logging": "3.0.1",
"@edx/paragon": "7.1.4",
"babel-eslint": "10.0.3",
"babel-plugin-react-intl": "4.1.18",
"dotenv": "8.1.0",
"enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.14.0",
"eslint": "6.3.0",
"eslint-config-edx": "4.0.4",
"jest": "24.9.0",
"parcel-bundler": "1.12.3",
"prop-types": "15.7.2",
"react": "16.9.0",
"react-dom": "16.9.0",
"react-redux": "7.1.1",
"react-router-dom": "5.0.1",
"react-test-renderer": "16.9.0",
"reactifex": "1.1.1",
"redux": "4.0.4",
"redux-saga": "1.0.5",
"sass": "1.22.12",
"semantic-release": "15.13.24"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "6.5.2",
"@fortawesome/free-brands-svg-icons": "6.5.2",
"@fortawesome/free-regular-svg-icons": "6.5.2",
"@fortawesome/free-solid-svg-icons": "6.5.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"axios-mock-adapter": "1.22.0",
"babel-polyfill": "6.26.0",
"react-responsive": "8.2.0",
"react-transition-group": "4.4.5"
"react-responsive": "8.0.1",
"react-transition-group": "4.3.0"
},
"peerDependencies": {
"@edx/frontend-platform": "^7.0.0",
"prop-types": "^15.5.10",
"react": "^16.9.0 || ^17.0.0",
"react-dom": "^16.9.0 || ^17.0.0",
"@openedx/paragon": ">= 21.5.7 < 23.0.0"
"@edx/frontend-analytics": "^3.0.0",
"@edx/frontend-base": "^3.0.0",
"@edx/frontend-i18n": "^3.0.3",
"prop-types": "^15.7.2",
"react": "^16.9.0"
},
"jest": {
"transform": {
"^.+\\.jsx?$": "babel-jest"
},
"collectCoverageFrom": [
"src/**/*.{js,jsx}",
"!**/node_modules/**",
"!**/dist/**"
],
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/src/__mocks__/fileMock.js"
},
"transformIgnorePatterns": [
"/node_modules/(?!@edx)"
],
"setupFiles": [
"./src/setupTest.js",
"dotenv/config"
]
}
}

View File

@@ -1,11 +0,0 @@
<!doctype html>
<html lang="en-us" dir="ltr">
<head>
<title>Header</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,33 +1,5 @@
{
"extends": [
"config:base",
"schedule:weekly",
":automergeLinters",
":automergeMinor",
":automergeTesters",
":enableVulnerabilityAlerts",
":rebaseStalePrs",
":semanticCommits",
":updateNotScheduled"
],
"packageRules": [
{
"matchDepTypes": [
"devDependencies"
],
"matchUpdateTypes": [
"lockFileMaintenance",
"minor",
"patch",
"pin"
],
"automerge": true
},
{
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
],
"timezone": "America/New_York"
"config:base"
]
}

View File

@@ -3,16 +3,16 @@ import PropTypes from 'prop-types';
import { AvatarIcon } from './Icons';
const Avatar = ({
function Avatar({
size,
src,
alt,
className,
}) => {
}) {
const avatar = src ? (
<img className="d-block w-100 h-100" src={src} alt={alt} />
) : (
<AvatarIcon style={{ width: size, height: size }} role="img" aria-hidden focusable="false" />
<AvatarIcon className="text-muted" style={{ width: size, height: size }} role="img" aria-hidden focusable="false" />
);
return (
@@ -23,7 +23,7 @@ const Avatar = ({
{avatar}
</span>
);
};
}
Avatar.propTypes = {
src: PropTypes.string,

View File

@@ -1,7 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-i18n';
// Local Components
import { Menu, MenuTrigger, MenuContent } from './Menu';
@@ -9,7 +8,7 @@ import Avatar from './Avatar';
import { LinkedLogo, Logo } from './Logo';
// i18n
import messages from './Header.messages';
import messages from './SiteHeader.messages';
// Assets
import { CaretIcon } from './Icons';
@@ -19,31 +18,23 @@ class DesktopHeader extends React.Component {
super(props);
}
renderMenu(menu) {
// Nodes are accepted as a prop
if (!Array.isArray(menu)) {
return menu;
}
renderMainMenu() {
const { mainMenu } = this.props;
return menu.map((menuItem) => {
// Nodes are accepted as a prop
if (!Array.isArray(mainMenu)) return mainMenu;
return mainMenu.map((menuItem) => {
const {
type,
href,
content,
submenuContent,
disabled,
isActive,
} = menuItem;
if (type === 'item') {
return (
<a
key={`${type}-${content}`}
className={`nav-link${disabled ? ' disabled' : ''}${isActive ? ' active' : ''}`}
href={href}
>
{content}
</a>
<a key={`${type}-${content}`} className="nav-link" href={href}>{content}</a>
);
}
@@ -60,16 +51,6 @@ class DesktopHeader extends React.Component {
});
}
renderMainMenu() {
const { mainMenu } = this.props;
return this.renderMenu(mainMenu);
}
renderSecondaryMenu() {
const { secondaryMenu } = this.props;
return this.renderMenu(secondaryMenu);
}
renderUserMenu() {
const {
userMenu,
@@ -83,29 +64,14 @@ class DesktopHeader extends React.Component {
<MenuTrigger
tag="button"
aria-label={intl.formatMessage(messages['header.label.account.menu.for'], { username })}
className="btn btn-outline-primary d-inline-flex align-items-center pl-2 pr-3"
className="btn btn-light d-inline-flex align-items-center pl-2 pr-3"
>
<Avatar size="1.5em" src={avatar} alt="" className="mr-2" />
{username} <CaretIcon role="img" aria-hidden focusable="false" />
</MenuTrigger>
<MenuContent className="mb-0 dropdown-menu show dropdown-menu-right pin-right shadow py-2">
{userMenu.map((group, index) => (
// eslint-disable-next-line react/jsx-no-comment-textnodes,react/no-array-index-key
<React.Fragment key={index}>
{group.heading && <div className="dropdown-header" role="heading" aria-level="1">{group.heading}</div>}
{group.items.map(({
type, content, href, disabled, isActive,
}) => (
<a
className={`dropdown-${type}${isActive ? ' active' : ''}${disabled ? ' disabled' : ''}`}
key={`${type}-${content}`}
href={href}
>
{content}
</a>
))}
{index < userMenu.length - 1 && <div className="dropdown-divider" role="separator" />}
</React.Fragment>
{userMenu.map(({ type, href, content }) => (
<a className={`dropdown-${type}`} key={`${type}-${content}`} href={href}>{content}</a>
))}
</MenuContent>
</Menu>
@@ -135,14 +101,12 @@ class DesktopHeader extends React.Component {
intl,
} = this.props;
const logoProps = { src: logo, alt: logoAltText, href: logoDestination };
const logoClasses = getConfig().AUTHN_MINIMAL_HEADER ? 'mw-100' : null;
return (
<header className="site-header-desktop">
<a className="nav-skip sr-only sr-only-focusable" href="#main">{intl.formatMessage(messages['header.label.skip.nav'])}</a>
<div className={`container-fluid ${logoClasses}`}>
<div className="container-fluid">
<div className="nav-container position-relative d-flex align-items-center">
{logoDestination === null ? <Logo className="logo" src={logo} alt={logoAltText} /> : <LinkedLogo className="logo" {...logoProps} />}
{ logoDestination === null ? <Logo className="logo" src={logo} alt={logoAltText} /> : <LinkedLogo className="logo" {...logoProps} />}
<nav
aria-label={intl.formatMessage(messages['header.label.main.nav'])}
className="nav main-nav"
@@ -153,13 +117,7 @@ class DesktopHeader extends React.Component {
aria-label={intl.formatMessage(messages['header.label.secondary.nav'])}
className="nav secondary-menu-container align-items-center ml-auto"
>
{loggedIn
? (
<>
{this.renderSecondaryMenu()}
{this.renderUserMenu()}
</>
) : this.renderLoggedOutItems()}
{loggedIn ? this.renderUserMenu() : this.renderLoggedOutItems()}
</nav>
</div>
</div>
@@ -173,18 +131,10 @@ DesktopHeader.propTypes = {
PropTypes.node,
PropTypes.array,
]),
secondaryMenu: PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
]),
userMenu: PropTypes.arrayOf(PropTypes.shape({
heading: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
isActive: PropTypes.bool,
})),
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
})),
loggedOutItems: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
@@ -204,7 +154,6 @@ DesktopHeader.propTypes = {
DesktopHeader.defaultProps = {
mainMenu: [],
secondaryMenu: [],
userMenu: [],
loggedOutItems: [],
logo: null,

View File

@@ -1,162 +0,0 @@
import React, { useContext } from 'react';
import Responsive from 'react-responsive';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import {
APP_CONFIG_INITIALIZED,
ensureConfig,
mergeConfig,
getConfig,
subscribe,
} from '@edx/frontend-platform';
import PropTypes from 'prop-types';
import DesktopHeader from './DesktopHeader';
import MobileHeader from './MobileHeader';
import messages from './Header.messages';
ensureConfig([
'LMS_BASE_URL',
'LOGOUT_URL',
'LOGIN_URL',
'SITE_NAME',
'LOGO_URL',
'ORDER_HISTORY_URL',
], 'Header component');
subscribe(APP_CONFIG_INITIALIZED, () => {
mergeConfig({
AUTHN_MINIMAL_HEADER: !!process.env.AUTHN_MINIMAL_HEADER,
}, 'Header additional config');
});
/**
* Header component for the application.
* Displays a header with the provided main menu, secondary menu, and user menu when the user is authenticated.
* If any of the props (mainMenuItems, secondaryMenuItems, userMenuItems) are not provided, default
* items are displayed.
* For more details on how to use this component, please refer to this document:
* https://github.com/openedx/frontend-component-header/blob/master/docs/using_custom_header.rst
*
* @param {list} mainMenuItems - The list of main menu items to display.
* See the documentation for the structure of main menu item.
* @param {list} secondaryMenuItems - The list of secondary menu items to display.
* See the documentation for the structure of secondary menu item.
* @param {list} userMenuItems - The list of user menu items to display.
* See the documentation for the structure of user menu item.
*/
const Header = ({
intl, mainMenuItems, secondaryMenuItems, userMenuItems,
}) => {
const { authenticatedUser, config } = useContext(AppContext);
const defaultMainMenu = [
{
type: 'item',
href: `${config.LMS_BASE_URL}/dashboard`,
content: intl.formatMessage(messages['header.links.courses']),
},
];
const defaultUserMenu = authenticatedUser === null ? [] : [{
heading: '',
items: [
{
type: 'item',
href: `${config.LMS_BASE_URL}/dashboard`,
content: intl.formatMessage(messages['header.user.menu.dashboard']),
},
{
type: 'item',
href: `${config.ACCOUNT_PROFILE_URL}/u/${authenticatedUser.username}`,
content: intl.formatMessage(messages['header.user.menu.profile']),
},
{
type: 'item',
href: config.ACCOUNT_SETTINGS_URL,
content: intl.formatMessage(messages['header.user.menu.account.settings']),
},
// Users should only see Order History if have a ORDER_HISTORY_URL define in the environment.
...(config.ORDER_HISTORY_URL ? [{
type: 'item',
href: config.ORDER_HISTORY_URL,
content: intl.formatMessage(messages['header.user.menu.order.history']),
}] : []),
{
type: 'item',
href: config.LOGOUT_URL,
content: intl.formatMessage(messages['header.user.menu.logout']),
},
],
}];
const mainMenu = mainMenuItems || defaultMainMenu;
const secondaryMenu = secondaryMenuItems || [];
const userMenu = authenticatedUser === null ? [] : userMenuItems || defaultUserMenu;
const loggedOutItems = [
{
type: 'item',
href: config.LOGIN_URL,
content: intl.formatMessage(messages['header.user.menu.login']),
},
{
type: 'item',
href: `${config.LMS_BASE_URL}/register`,
content: intl.formatMessage(messages['header.user.menu.register']),
},
];
const props = {
logo: config.LOGO_URL,
logoAltText: config.SITE_NAME,
logoDestination: `${config.LMS_BASE_URL}/dashboard`,
loggedIn: authenticatedUser !== null,
username: authenticatedUser !== null ? authenticatedUser.username : null,
avatar: authenticatedUser !== null ? authenticatedUser.avatar : null,
mainMenu: getConfig().AUTHN_MINIMAL_HEADER ? [] : mainMenu,
secondaryMenu: getConfig().AUTHN_MINIMAL_HEADER ? [] : secondaryMenu,
userMenu: getConfig().AUTHN_MINIMAL_HEADER ? [] : userMenu,
loggedOutItems: getConfig().AUTHN_MINIMAL_HEADER ? [] : loggedOutItems,
};
return (
<>
<Responsive maxWidth={769}>
<MobileHeader {...props} />
</Responsive>
<Responsive minWidth={769}>
<DesktopHeader {...props} />
</Responsive>
</>
);
};
Header.defaultProps = {
mainMenuItems: null,
secondaryMenuItems: null,
userMenuItems: null,
};
Header.propTypes = {
intl: intlShape.isRequired,
mainMenuItems: PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
]),
secondaryMenuItems: PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
]),
userMenuItems: PropTypes.arrayOf(PropTypes.shape({
heading: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
isActive: PropTypes.bool,
})),
})),
};
export default injectIntl(Header);

View File

@@ -1,104 +0,0 @@
/* eslint-disable react/prop-types */
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import TestRenderer from 'react-test-renderer';
import { AppContext } from '@edx/frontend-platform/react';
import { Context as ResponsiveContext } from 'react-responsive';
import Header from './index';
const HeaderComponent = ({ width, contextValue }) => (
<ResponsiveContext.Provider value={width}>
<IntlProvider locale="en" messages={{}}>
<AppContext.Provider
value={contextValue}
>
<Header />
</AppContext.Provider>
</IntlProvider>
</ResponsiveContext.Provider>
);
describe('<Header />', () => {
it('renders correctly for anonymous desktop', () => {
const contextValue = {
authenticatedUser: null,
config: {
LMS_BASE_URL: process.env.LMS_BASE_URL,
SITE_NAME: process.env.SITE_NAME,
LOGIN_URL: process.env.LOGIN_URL,
LOGOUT_URL: process.env.LOGOUT_URL,
LOGO_URL: process.env.LOGO_URL,
},
};
const component = <HeaderComponent width={{ width: 1280 }} contextValue={contextValue} />;
const wrapper = TestRenderer.create(component);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('renders correctly for authenticated desktop', () => {
const contextValue = {
authenticatedUser: {
userId: 'abc123',
username: 'edX',
roles: [],
administrator: false,
},
config: {
LMS_BASE_URL: process.env.LMS_BASE_URL,
SITE_NAME: process.env.SITE_NAME,
LOGIN_URL: process.env.LOGIN_URL,
LOGOUT_URL: process.env.LOGOUT_URL,
LOGO_URL: process.env.LOGO_URL,
},
};
const component = <HeaderComponent width={{ width: 1280 }} contextValue={contextValue} />;
const wrapper = TestRenderer.create(component);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('renders correctly for anonymous mobile', () => {
const contextValue = {
authenticatedUser: null,
config: {
LMS_BASE_URL: process.env.LMS_BASE_URL,
SITE_NAME: process.env.SITE_NAME,
LOGIN_URL: process.env.LOGIN_URL,
LOGOUT_URL: process.env.LOGOUT_URL,
LOGO_URL: process.env.LOGO_URL,
},
};
const component = <HeaderComponent width={{ width: 500 }} contextValue={contextValue} />;
const wrapper = TestRenderer.create(component);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('renders correctly for authenticated mobile', () => {
const contextValue = {
authenticatedUser: {
userId: 'abc123',
username: 'edX',
roles: [],
administrator: false,
},
config: {
LMS_BASE_URL: process.env.LMS_BASE_URL,
SITE_NAME: process.env.SITE_NAME,
LOGIN_URL: process.env.LOGIN_URL,
LOGOUT_URL: process.env.LOGOUT_URL,
LOGO_URL: process.env.LOGO_URL,
},
};
const component = <HeaderComponent width={{ width: 500 }} contextValue={contextValue} />;
const wrapper = TestRenderer.create(component);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View File

@@ -1,6 +1,6 @@
import React from 'react';
export const MenuIcon = (props) => (
export const MenuIcon = props => (
<svg
width="24px"
height="24px"
@@ -14,7 +14,7 @@ export const MenuIcon = (props) => (
</svg>
);
export const AvatarIcon = (props) => (
export const AvatarIcon = props => (
<svg
width="24px"
height="24px"
@@ -29,7 +29,7 @@ export const AvatarIcon = (props) => (
</svg>
);
export const CaretIcon = (props) => (
export const CaretIcon = props => (
<svg
width="16px"
height="16px"

View File

@@ -1,25 +1,30 @@
import React from 'react';
import PropTypes from 'prop-types';
const Logo = ({ src, alt, ...attributes }) => (
<img src={src} alt={alt} {...attributes} />
);
function Logo({ src, alt, ...attributes }) {
return (
<img src={src} alt={alt} {...attributes} />
);
}
Logo.propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired,
};
const LinkedLogo = ({
function LinkedLogo({
href,
src,
alt,
...attributes
}) => (
<a href={href} {...attributes}>
<img className="d-block" src={src} alt={alt} />
</a>
);
}) {
return (
<a href={href} {...attributes}>
<img className="d-block" src={src} alt={alt} />
</a>
);
}
LinkedLogo.propTypes = {
href: PropTypes.string.isRequired,

View File

@@ -2,10 +2,13 @@ import React from 'react';
import { CSSTransition } from 'react-transition-group';
import PropTypes from 'prop-types';
const MenuTrigger = ({ tag, className, ...attributes }) => React.createElement(tag, {
className: `menu-trigger ${className}`,
...attributes,
});
function MenuTrigger({ tag, className, ...attributes }) {
return React.createElement(tag, {
className: `menu-trigger ${className}`,
...attributes,
});
}
MenuTrigger.propTypes = {
tag: PropTypes.string,
className: PropTypes.string,
@@ -14,13 +17,15 @@ MenuTrigger.defaultProps = {
tag: 'div',
className: null,
};
const MenuTriggerComp = <MenuTrigger />;
const MenuTriggerType = MenuTriggerComp.type;
const MenuTriggerType = <MenuTrigger />.type;
const MenuContent = ({ tag, className, ...attributes }) => React.createElement(tag, {
className: ['menu-content', className].join(' '),
...attributes,
});
function MenuContent({ tag, className, ...attributes }) {
return React.createElement(tag, {
className: ['menu-content', className].join(' '),
...attributes,
});
}
MenuContent.propTypes = {
tag: PropTypes.string,
className: PropTypes.string,
@@ -30,17 +35,6 @@ MenuContent.defaultProps = {
className: null,
};
const menuPropTypes = {
tag: PropTypes.string,
onClose: PropTypes.func,
onOpen: PropTypes.func,
closeOnDocumentClick: PropTypes.bool,
respondToPointerEvents: PropTypes.bool,
className: PropTypes.string,
transitionTimeout: PropTypes.number,
transitionClassName: PropTypes.string,
children: PropTypes.arrayOf(PropTypes.node).isRequired,
};
class Menu extends React.Component {
constructor(props) {
@@ -72,14 +66,10 @@ class Menu extends React.Component {
// Event handlers
onDocumentClick(e) {
if (!this.props.closeOnDocumentClick) {
return;
}
if (!this.props.closeOnDocumentClick) return;
const clickIsInMenu = this.menu.current === e.target || this.menu.current.contains(e.target);
if (clickIsInMenu) {
return;
}
if (clickIsInMenu) return;
this.close();
}
@@ -87,9 +77,7 @@ class Menu extends React.Component {
onTriggerClick(e) {
// Let the browser follow the link of the trigger if the menu
// is already expanded and the trigger has an href attribute
if (this.state.expanded && e.target.getAttribute('href')) {
return;
}
if (this.state.expanded && e.target.getAttribute('href')) return;
e.preventDefault();
this.toggle();
@@ -101,9 +89,7 @@ class Menu extends React.Component {
}
onKeyDown(e) {
if (!this.state.expanded) {
return;
}
if (!this.state.expanded) return;
switch (e.key) {
case 'Escape': {
e.preventDefault();
@@ -145,19 +131,16 @@ class Menu extends React.Component {
}
onMouseEnter() {
if (!this.props.respondToPointerEvents) {
return;
}
if (!this.props.respondToPointerEvents) return;
this.open();
}
onMouseLeave() {
if (!this.props.respondToPointerEvents) {
return;
}
if (!this.props.respondToPointerEvents) return;
this.close();
}
// Internal functions
getFocusableElements() {
@@ -168,7 +151,7 @@ class Menu extends React.Component {
// Any extra props are attributes for the menu
const attributes = {};
Object.keys(this.props)
.filter(property => menuPropTypes[property] === undefined)
.filter(property => Menu.propTypes[property] === undefined)
.forEach((property) => {
attributes[property] = this.props[property];
});
@@ -190,9 +173,7 @@ class Menu extends React.Component {
}
open() {
if (this.props.onOpen) {
this.props.onOpen();
}
if (this.props.onOpen) this.props.onOpen();
this.setState({ expanded: true });
// Listen to touchend and click events to ensure the menu
// can be closed on mobile, pointer, and mixed input devices
@@ -201,9 +182,7 @@ class Menu extends React.Component {
}
close() {
if (this.props.onClose) {
this.props.onClose();
}
if (this.props.onClose) this.props.onClose();
this.setState({ expanded: false });
document.removeEventListener('touchend', this.onDocumentClick, true);
document.removeEventListener('click', this.onDocumentClick, true);
@@ -261,7 +240,18 @@ class Menu extends React.Component {
}
}
Menu.propTypes = menuPropTypes;
Menu.propTypes = {
tag: PropTypes.string,
onClose: PropTypes.func,
onOpen: PropTypes.func,
closeOnDocumentClick: PropTypes.bool,
respondToPointerEvents: PropTypes.bool,
className: PropTypes.string,
transitionTimeout: PropTypes.number,
transitionClassName: PropTypes.string,
children: PropTypes.arrayOf(PropTypes.node).isRequired,
};
Menu.defaultProps = {
tag: 'div',
className: null,
@@ -273,4 +263,5 @@ Menu.defaultProps = {
transitionClassName: 'menu-content',
};
export { Menu, MenuTrigger, MenuContent };

View File

@@ -1,7 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-i18n';
// Local Components
import { Menu, MenuTrigger, MenuContent } from './Menu';
@@ -9,7 +8,7 @@ import Avatar from './Avatar';
import { LinkedLogo, Logo } from './Logo';
// i18n
import messages from './Header.messages';
import messages from './SiteHeader.messages';
// Assets
import { MenuIcon } from './Icons';
@@ -19,29 +18,23 @@ class MobileHeader extends React.Component {
super(props);
}
renderMenu(menu) {
// Nodes are accepted as a prop
if (!Array.isArray(menu)) {
return menu;
}
renderMainMenu() {
const { mainMenu } = this.props;
return menu.map((menuItem) => {
// Nodes are accepted as a prop
if (!Array.isArray(mainMenu)) return mainMenu;
return mainMenu.map((menuItem) => {
const {
type,
href,
content,
submenuContent,
disabled,
isActive,
} = menuItem;
if (type === 'item') {
return (
<a
key={`${type}-${content}`}
className={`nav-link${disabled ? ' disabled' : ''}${isActive ? ' active' : ''}`}
href={href}
>
<a key={`${type}-${content}`} className="nav-link" href={href}>
{content}
</a>
);
@@ -60,32 +53,13 @@ class MobileHeader extends React.Component {
});
}
renderMainMenu() {
const { mainMenu } = this.props;
return this.renderMenu(mainMenu);
}
renderSecondaryMenu() {
const { secondaryMenu } = this.props;
return this.renderMenu(secondaryMenu);
}
renderUserMenuItems() {
const { userMenu } = this.props;
return userMenu.map((group) => (
group.items.map(({
type, content, href, disabled, isActive,
}) => (
<li className="nav-item" key={`${type}-${content}`}>
<a
className={`nav-link${isActive ? ' active' : ''}${disabled ? ' disabled' : ''}`}
href={href}
>
{content}
</a>
</li>
))
return userMenu.map(({ type, href, content }) => (
<li className="nav-item" key={`${type}-${content}`}>
<a className="nav-link" href={href}>{content}</a>
</li>
));
}
@@ -115,22 +89,17 @@ class MobileHeader extends React.Component {
stickyOnMobile,
intl,
mainMenu,
userMenu,
loggedOutItems,
} = this.props;
const logoProps = { src: logo, alt: logoAltText, href: logoDestination };
const stickyClassName = stickyOnMobile ? 'sticky-top' : '';
const logoClasses = getConfig().AUTHN_MINIMAL_HEADER ? 'justify-content-left pl-3' : 'justify-content-center';
return (
<header
aria-label={intl.formatMessage(messages['header.label.main.header'])}
className={`site-header-mobile d-flex justify-content-between align-items-center shadow ${stickyClassName}`}
>
<a className="nav-skip sr-only sr-only-focusable" href="#main">{intl.formatMessage(messages['header.label.skip.nav'])}</a>
{mainMenu.length > 0 ? (
<div className="w-100 d-flex justify-content-start">
<div className="w-100 d-flex justify-content-start">
{mainMenu.length > 0 ?
<Menu className="position-static">
<MenuTrigger
tag="button"
@@ -146,31 +115,27 @@ class MobileHeader extends React.Component {
className="nav flex-column pin-left pin-right border-top shadow py-2"
>
{this.renderMainMenu()}
{this.renderSecondaryMenu()}
</MenuContent>
</Menu>
</div>
) : null}
<div className={`w-100 d-flex ${logoClasses}`}>
</Menu> : null }
</div>
<div className="w-100 d-flex justify-content-center">
{ logoDestination === null ? <Logo className="logo" src={logo} alt={logoAltText} /> : <LinkedLogo className="logo" {...logoProps} itemType="http://schema.org/Organization" />}
</div>
{userMenu.length > 0 || loggedOutItems.length > 0 ? (
<div className="w-100 d-flex justify-content-end align-items-center">
<Menu tag="nav" aria-label={intl.formatMessage(messages['header.label.secondary.nav'])} className="position-static">
<MenuTrigger
tag="button"
className="icon-button"
aria-label={intl.formatMessage(messages['header.label.account.menu'])}
title={intl.formatMessage(messages['header.label.account.menu'])}
>
<Avatar size="1.5rem" src={avatar} alt={username} />
</MenuTrigger>
<MenuContent tag="ul" className="nav flex-column pin-left pin-right border-top shadow py-2">
{loggedIn ? this.renderUserMenuItems() : this.renderLoggedOutItems()}
</MenuContent>
</Menu>
</div>
) : null}
<div className="w-100 d-flex justify-content-end align-items-center">
<Menu tag="nav" aria-label={intl.formatMessage(messages['header.label.secondary.nav'])} className="position-static">
<MenuTrigger
tag="button"
className="icon-button"
aria-label={intl.formatMessage(messages['header.label.account.menu'])}
title={intl.formatMessage(messages['header.label.account.menu'])}
>
<Avatar size="1.5rem" src={avatar} alt={username} />
</MenuTrigger>
<MenuContent tag="ul" className="nav flex-column pin-left pin-right border-top shadow py-2">
{loggedIn ? this.renderUserMenuItems() : this.renderLoggedOutItems()}
</MenuContent>
</Menu>
</div>
</header>
);
}
@@ -181,18 +146,11 @@ MobileHeader.propTypes = {
PropTypes.node,
PropTypes.array,
]),
secondaryMenu: PropTypes.oneOfType([
PropTypes.node,
PropTypes.array,
]),
userMenu: PropTypes.arrayOf(PropTypes.shape({
heading: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
isActive: PropTypes.bool,
})),
type: PropTypes.oneOf(['item', 'menu']),
href: PropTypes.string,
content: PropTypes.string,
})),
loggedOutItems: PropTypes.arrayOf(PropTypes.shape({
type: PropTypes.oneOf(['item', 'menu']),
@@ -213,7 +171,6 @@ MobileHeader.propTypes = {
MobileHeader.defaultProps = {
mainMenu: [],
secondaryMenu: [],
userMenu: [],
loggedOutItems: [],
logo: null,

96
src/SiteHeader.jsx Normal file
View File

@@ -0,0 +1,96 @@
import React, { useContext } from 'react';
import Responsive from 'react-responsive';
import { injectIntl, intlShape } from '@edx/frontend-i18n';
import { App, AppContext } from '@edx/frontend-base';
import DesktopHeader from './DesktopHeader';
import MobileHeader from './MobileHeader';
import LogoSVG from './logo.svg';
import messages from './SiteHeader.messages';
App.requireConfig([
'LMS_BASE_URL',
'LOGOUT_URL',
'LOGIN_URL',
'SITE_NAME',
], 'Header component');
function SiteHeader({ intl }) {
const { authenticatedUser, config } = useContext(AppContext);
const mainMenu = [
{
type: 'item',
href: `${config.LMS_BASE_URL}/dashboard`,
content: intl.formatMessage(messages['header.links.courses']),
},
];
const userMenu = [
{
type: 'item',
href: `${config.LMS_BASE_URL}/dashboard`,
content: intl.formatMessage(messages['header.user.menu.dashboard']),
},
{
type: 'item',
href: `${config.LMS_BASE_URL}/u/${authenticatedUser.username}`,
content: intl.formatMessage(messages['header.user.menu.profile']),
},
{
type: 'item',
href: `${config.LMS_BASE_URL}/account/settings`,
content: intl.formatMessage(messages['header.user.menu.account.settings']),
},
{
type: 'item',
href: config.LOGOUT_URL,
content: intl.formatMessage(messages['header.user.menu.logout']),
},
];
const loggedOutItems = [
{
type: 'item',
href: config.LOGIN_URL,
content: intl.formatMessage(messages['header.user.menu.login']),
},
{
type: 'item',
href: `${config.LMS_BASE_URL}/register`,
content: intl.formatMessage(messages['header.user.menu.register']),
},
];
const props = {
logo: LogoSVG,
logoAltText: config.SITE_NAME,
siteName: config.SITE_NAME,
logoDestination: `${config.LMS_BASE_URL}/dashboard`,
loggedIn: !!authenticatedUser.username,
username: authenticatedUser.username,
avatar: authenticatedUser.avatar,
mainMenu,
userMenu,
loggedOutItems,
};
return (
<React.Fragment>
<Responsive maxWidth={768}>
<MobileHeader {...props} />
</Responsive>
<Responsive minWidth={769}>
<DesktopHeader {...props} />
</Responsive>
</React.Fragment>
);
}
SiteHeader.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(SiteHeader);

View File

@@ -1,4 +1,4 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
import { defineMessages } from '@edx/frontend-i18n';
const messages = defineMessages({
'header.links.courses': {
@@ -56,16 +56,6 @@ const messages = defineMessages({
defaultMessage: 'Sign Up',
description: 'Link to registration',
},
'header.user.menu.studio.home': {
id: 'header.user.menu.studio.home',
defaultMessage: 'Studio Home',
description: 'Link to the Studio Home',
},
'header.user.menu.studio.maintenance': {
id: 'header.user.menu.studio.maintenance',
defaultMessage: 'Maintenance',
description: 'Link to the Studio Maintenance',
},
'header.label.account.nav': {
id: 'header.label.account.nav',
defaultMessage: 'Account',
@@ -77,7 +67,7 @@ const messages = defineMessages({
description: 'The aria label for the account menu trigger',
},
'header.label.account.menu.for': {
id: 'header.label.account.menu.for',
id: 'header.label.account.menu',
defaultMessage: 'Account menu for {username}',
description: 'The aria label for the account menu trigger when the username is displayed in it',
},
@@ -101,16 +91,6 @@ const messages = defineMessages({
defaultMessage: 'Secondary',
description: 'The aria label for the seconary nav',
},
'header.label.skip.nav': {
id: 'header.label.skip.nav',
defaultMessage: 'Skip to main content',
description: 'A link used by screen readers to allow users to skip to the main content of the page.',
},
'header.label.app.nav': {
id: 'header.label.app.nav',
defaultMessage: 'App',
description: 'The aria label for the app Nav',
},
});
export default messages;

70
src/SiteHeader.test.jsx Normal file
View File

@@ -0,0 +1,70 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-i18n';
import TestRenderer from 'react-test-renderer';
import { AppContext } from '@edx/frontend-base';
import { Context as ResponsiveContext } from 'react-responsive';
import SiteHeader from './index';
describe('<SiteHeader />', () => {
it('renders correctly for desktop', () => {
const component = (
<ResponsiveContext.Provider value={{ width: 1280 }}>
<IntlProvider locale="en" messages={{}}>
<AppContext.Provider value={{
authenticatedUser: {
userId: null,
username: null,
roles: [],
administrator: false,
},
config: {
LMS_BASE_URL: process.env.LMS_BASE_URL,
SITE_NAME: process.env.SITE_NAME,
LOGIN_URL: process.env.LOGIN_URL,
LOGOUT_URL: process.env.LOGOUT_URL,
},
}}
>
<SiteHeader />
</AppContext.Provider>
</IntlProvider>
</ResponsiveContext.Provider>
);
const wrapper = TestRenderer.create(component);
expect(wrapper.toJSON()).toMatchSnapshot();
});
it('renders correctly for mobile', () => {
const component = (
<ResponsiveContext.Provider value={{ width: 500 }}>
<IntlProvider locale="en" messages={{}}>
<AppContext.Provider value={{
authenticatedUser: {
userId: null,
username: null,
roles: [],
administrator: false,
},
config: {
LMS_BASE_URL: process.env.LMS_BASE_URL,
SITE_NAME: process.env.SITE_NAME,
LOGIN_URL: process.env.LOGIN_URL,
LOGOUT_URL: process.env.LOGOUT_URL,
},
}}
>
<SiteHeader />
</AppContext.Provider>
</IntlProvider>
</ResponsiveContext.Provider>
);
const wrapper = TestRenderer.create(component);
expect(wrapper.toJSON()).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,3 @@
// __mocks__/fileMock.js
module.exports = 'test-file-stub';

View File

@@ -1,443 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<Header /> renders correctly for anonymous desktop 1`] = `
<header
className="site-header-desktop"
>
<a
className="nav-skip sr-only sr-only-focusable"
href="#main"
>
Skip to main content
</a>
<div
className="container-fluid null"
>
<div
className="nav-container position-relative d-flex align-items-center"
>
<a
className="logo"
href="http://localhost:18000/dashboard"
>
<img
alt="edX"
className="d-block"
src="https://edx-cdn.org/v3/default/logo.svg"
/>
</a>
<nav
aria-label="Main"
className="nav main-nav"
>
<a
className="nav-link"
href="http://localhost:18000/dashboard"
>
Courses
</a>
</nav>
<nav
aria-label="Secondary"
className="nav secondary-menu-container align-items-center ml-auto"
>
<a
className="btn mr-2 btn-link"
href="http://localhost:18000/login"
>
Login
</a>
<a
className="btn mr-2 btn-outline-primary"
href="http://localhost:18000/register"
>
Sign Up
</a>
</nav>
</div>
</div>
</header>
`;
exports[`<Header /> renders correctly for anonymous mobile 1`] = `
<header
aria-label="Main"
className="site-header-mobile d-flex justify-content-between align-items-center shadow sticky-top"
>
<a
className="nav-skip sr-only sr-only-focusable"
href="#main"
>
Skip to main content
</a>
<div
className="w-100 d-flex justify-content-start"
>
<div
className="menu position-static"
onKeyDown={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<button
aria-expanded={false}
aria-haspopup="menu"
aria-label="Main Menu"
className="menu-trigger icon-button"
onClick={[Function]}
title="Main Menu"
>
<svg
aria-hidden={true}
focusable="false"
height="24px"
role="img"
style={
Object {
"height": "1.5rem",
"width": "1.5rem",
}
}
version="1.1"
viewBox="0 0 24 24"
width="24px"
>
<rect
fill="currentColor"
height="2"
width="20"
x="2"
y="5"
/>
<rect
fill="currentColor"
height="2"
width="20"
x="2"
y="11"
/>
<rect
fill="currentColor"
height="2"
width="20"
x="2"
y="17"
/>
</svg>
</button>
</div>
</div>
<div
className="w-100 d-flex justify-content-center"
>
<a
className="logo"
href="http://localhost:18000/dashboard"
itemType="http://schema.org/Organization"
>
<img
alt="edX"
className="d-block"
src="https://edx-cdn.org/v3/default/logo.svg"
/>
</a>
</div>
<div
className="w-100 d-flex justify-content-end align-items-center"
>
<nav
aria-label="Secondary"
className="menu position-static"
onKeyDown={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<button
aria-expanded={false}
aria-haspopup="menu"
aria-label="Account Menu"
className="menu-trigger icon-button"
onClick={[Function]}
title="Account Menu"
>
<span
className="avatar overflow-hidden d-inline-flex rounded-circle null"
style={
Object {
"height": "1.5rem",
"width": "1.5rem",
}
}
>
<svg
aria-hidden={true}
focusable="false"
height="24px"
role="img"
style={
Object {
"height": "1.5rem",
"width": "1.5rem",
}
}
version="1.1"
viewBox="0 0 24 24"
width="24px"
>
<path
d="M4.10255106,18.1351061 C4.7170266,16.0581859 8.01891846,14.4720277 12,14.4720277 C15.9810815,14.4720277 19.2829734,16.0581859 19.8974489,18.1351061 C21.215206,16.4412566 22,14.3122775 22,12 C22,6.4771525 17.5228475,2 12,2 C6.4771525,2 2,6.4771525 2,12 C2,14.3122775 2.78479405,16.4412566 4.10255106,18.1351061 Z M12,24 C5.372583,24 0,18.627417 0,12 C0,5.372583 5.372583,0 12,0 C18.627417,0 24,5.372583 24,12 C24,18.627417 18.627417,24 12,24 Z M12,13 C9.790861,13 8,11.209139 8,9 C8,6.790861 9.790861,5 12,5 C14.209139,5 16,6.790861 16,9 C16,11.209139 14.209139,13 12,13 Z"
fill="currentColor"
/>
</svg>
</span>
</button>
</nav>
</div>
</header>
`;
exports[`<Header /> renders correctly for authenticated desktop 1`] = `
<header
className="site-header-desktop"
>
<a
className="nav-skip sr-only sr-only-focusable"
href="#main"
>
Skip to main content
</a>
<div
className="container-fluid null"
>
<div
className="nav-container position-relative d-flex align-items-center"
>
<a
className="logo"
href="http://localhost:18000/dashboard"
>
<img
alt="edX"
className="d-block"
src="https://edx-cdn.org/v3/default/logo.svg"
/>
</a>
<nav
aria-label="Main"
className="nav main-nav"
>
<a
className="nav-link"
href="http://localhost:18000/dashboard"
>
Courses
</a>
</nav>
<nav
aria-label="Secondary"
className="nav secondary-menu-container align-items-center ml-auto"
>
<div
className="menu null"
onKeyDown={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<button
aria-expanded={false}
aria-haspopup="menu"
aria-label="Account menu for edX"
className="menu-trigger btn btn-outline-primary d-inline-flex align-items-center pl-2 pr-3"
onClick={[Function]}
>
<span
className="avatar overflow-hidden d-inline-flex rounded-circle mr-2"
style={
Object {
"height": "1.5em",
"width": "1.5em",
}
}
>
<svg
aria-hidden={true}
focusable="false"
height="24px"
role="img"
style={
Object {
"height": "1.5em",
"width": "1.5em",
}
}
version="1.1"
viewBox="0 0 24 24"
width="24px"
>
<path
d="M4.10255106,18.1351061 C4.7170266,16.0581859 8.01891846,14.4720277 12,14.4720277 C15.9810815,14.4720277 19.2829734,16.0581859 19.8974489,18.1351061 C21.215206,16.4412566 22,14.3122775 22,12 C22,6.4771525 17.5228475,2 12,2 C6.4771525,2 2,6.4771525 2,12 C2,14.3122775 2.78479405,16.4412566 4.10255106,18.1351061 Z M12,24 C5.372583,24 0,18.627417 0,12 C0,5.372583 5.372583,0 12,0 C18.627417,0 24,5.372583 24,12 C24,18.627417 18.627417,24 12,24 Z M12,13 C9.790861,13 8,11.209139 8,9 C8,6.790861 9.790861,5 12,5 C14.209139,5 16,6.790861 16,9 C16,11.209139 14.209139,13 12,13 Z"
fill="currentColor"
/>
</svg>
</span>
edX
<svg
aria-hidden={true}
focusable="false"
height="16px"
role="img"
version="1.1"
viewBox="0 0 16 16"
width="16px"
>
<path
d="M7,4 L7,8 L11,8 L11,10 L5,10 L5,4 L7,4 Z"
fill="currentColor"
transform="translate(8.000000, 7.000000) rotate(-45.000000) translate(-8.000000, -7.000000) "
/>
</svg>
</button>
</div>
</nav>
</div>
</div>
</header>
`;
exports[`<Header /> renders correctly for authenticated mobile 1`] = `
<header
aria-label="Main"
className="site-header-mobile d-flex justify-content-between align-items-center shadow sticky-top"
>
<a
className="nav-skip sr-only sr-only-focusable"
href="#main"
>
Skip to main content
</a>
<div
className="w-100 d-flex justify-content-start"
>
<div
className="menu position-static"
onKeyDown={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<button
aria-expanded={false}
aria-haspopup="menu"
aria-label="Main Menu"
className="menu-trigger icon-button"
onClick={[Function]}
title="Main Menu"
>
<svg
aria-hidden={true}
focusable="false"
height="24px"
role="img"
style={
Object {
"height": "1.5rem",
"width": "1.5rem",
}
}
version="1.1"
viewBox="0 0 24 24"
width="24px"
>
<rect
fill="currentColor"
height="2"
width="20"
x="2"
y="5"
/>
<rect
fill="currentColor"
height="2"
width="20"
x="2"
y="11"
/>
<rect
fill="currentColor"
height="2"
width="20"
x="2"
y="17"
/>
</svg>
</button>
</div>
</div>
<div
className="w-100 d-flex justify-content-center"
>
<a
className="logo"
href="http://localhost:18000/dashboard"
itemType="http://schema.org/Organization"
>
<img
alt="edX"
className="d-block"
src="https://edx-cdn.org/v3/default/logo.svg"
/>
</a>
</div>
<div
className="w-100 d-flex justify-content-end align-items-center"
>
<nav
aria-label="Secondary"
className="menu position-static"
onKeyDown={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<button
aria-expanded={false}
aria-haspopup="menu"
aria-label="Account Menu"
className="menu-trigger icon-button"
onClick={[Function]}
title="Account Menu"
>
<span
className="avatar overflow-hidden d-inline-flex rounded-circle null"
style={
Object {
"height": "1.5rem",
"width": "1.5rem",
}
}
>
<svg
aria-hidden={true}
focusable="false"
height="24px"
role="img"
style={
Object {
"height": "1.5rem",
"width": "1.5rem",
}
}
version="1.1"
viewBox="0 0 24 24"
width="24px"
>
<path
d="M4.10255106,18.1351061 C4.7170266,16.0581859 8.01891846,14.4720277 12,14.4720277 C15.9810815,14.4720277 19.2829734,16.0581859 19.8974489,18.1351061 C21.215206,16.4412566 22,14.3122775 22,12 C22,6.4771525 17.5228475,2 12,2 C6.4771525,2 2,6.4771525 2,12 C2,14.3122775 2.78479405,16.4412566 4.10255106,18.1351061 Z M12,24 C5.372583,24 0,18.627417 0,12 C0,5.372583 5.372583,0 12,0 C18.627417,0 24,5.372583 24,12 C24,18.627417 18.627417,24 12,24 Z M12,13 C9.790861,13 8,11.209139 8,9 C8,6.790861 9.790861,5 12,5 C14.209139,5 16,6.790861 16,9 C16,11.209139 14.209139,13 12,13 Z"
fill="currentColor"
/>
</svg>
</span>
</button>
</nav>
</div>
</header>
`;

View File

@@ -0,0 +1,186 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SiteHeader /> renders correctly for desktop 1`] = `
<header
className="site-header-desktop"
>
<div
className="container-fluid"
>
<div
className="nav-container position-relative d-flex align-items-center"
>
<a
className="logo"
href="http://localhost:18000/dashboard"
>
<img
alt="edX"
className="d-block"
src="test-file-stub"
/>
</a>
<nav
aria-label="Main"
className="nav main-nav"
>
<a
className="nav-link"
href="http://localhost:18000/dashboard"
>
Courses
</a>
</nav>
<nav
aria-label="Secondary"
className="nav secondary-menu-container align-items-center ml-auto"
>
<a
className="btn mr-2 btn-link"
href="http://localhost:18000/login"
>
Login
</a>
<a
className="btn mr-2 btn-outline-primary"
href="http://localhost:18000/register"
>
Sign Up
</a>
</nav>
</div>
</div>
</header>
`;
exports[`<SiteHeader /> renders correctly for mobile 1`] = `
<header
aria-label="Main"
className="site-header-mobile d-flex justify-content-between align-items-center shadow sticky-top"
>
<div
className="w-100 d-flex justify-content-start"
>
<div
className="menu position-static"
onKeyDown={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<button
aria-expanded={false}
aria-haspopup="menu"
aria-label="Main Menu"
className="menu-trigger icon-button"
onClick={[Function]}
title="Main Menu"
>
<svg
aria-hidden={true}
focusable="false"
height="24px"
role="img"
style={
Object {
"height": "1.5rem",
"width": "1.5rem",
}
}
version="1.1"
viewBox="0 0 24 24"
width="24px"
>
<rect
fill="currentColor"
height="2"
width="20"
x="2"
y="5"
/>
<rect
fill="currentColor"
height="2"
width="20"
x="2"
y="11"
/>
<rect
fill="currentColor"
height="2"
width="20"
x="2"
y="17"
/>
</svg>
</button>
</div>
</div>
<div
className="w-100 d-flex justify-content-center"
>
<a
className="logo"
href="http://localhost:18000/dashboard"
itemType="http://schema.org/Organization"
>
<img
alt="edX"
className="d-block"
src="test-file-stub"
/>
</a>
</div>
<div
className="w-100 d-flex justify-content-end align-items-center"
>
<nav
aria-label="Secondary"
className="menu position-static"
onKeyDown={[Function]}
onMouseEnter={[Function]}
onMouseLeave={[Function]}
>
<button
aria-expanded={false}
aria-haspopup="menu"
aria-label="Account Menu"
className="menu-trigger icon-button"
onClick={[Function]}
title="Account Menu"
>
<span
className="avatar overflow-hidden d-inline-flex rounded-circle null"
style={
Object {
"height": "1.5rem",
"width": "1.5rem",
}
}
>
<svg
aria-hidden={true}
className="text-muted"
focusable="false"
height="24px"
role="img"
style={
Object {
"height": "1.5rem",
"width": "1.5rem",
}
}
version="1.1"
viewBox="0 0 24 24"
width="24px"
>
<path
d="M4.10255106,18.1351061 C4.7170266,16.0581859 8.01891846,14.4720277 12,14.4720277 C15.9810815,14.4720277 19.2829734,16.0581859 19.8974489,18.1351061 C21.215206,16.4412566 22,14.3122775 22,12 C22,6.4771525 17.5228475,2 12,2 C6.4771525,2 2,6.4771525 2,12 C2,14.3122775 2.78479405,16.4412566 4.10255106,18.1351061 Z M12,24 C5.372583,24 0,18.627417 0,12 C0,5.372583 5.372583,0 12,0 C18.627417,0 24,5.372583 24,12 C24,18.627417 18.627417,24 12,24 Z M12,13 C9.790861,13 8,11.209139 8,9 C8,6.790861 9.790861,5 12,5 C14.209139,5 16,6.790861 16,9 C16,11.209139 14.209139,13 12,13 Z"
fill="currentColor"
/>
</svg>
</span>
</button>
</nav>
</div>
</header>
`;

View File

@@ -1,16 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
registerSentenceCase: {
id: 'general.register.sentenceCase',
defaultMessage: 'Register',
description: 'Text in a button, prompting the user to register.',
},
signInSentenceCase: {
id: 'general.signIn.sentenceCase',
defaultMessage: 'Sign in',
description: 'Text in a button, prompting the user to log in.',
},
});
export default messages;

View File

@@ -1 +1,18 @@
export default {};
import arMessages from './messages/ar.json';
// no need to import en messages-- they are in the defaultMessage field
import es419Messages from './messages/es_419.json';
import frMessages from './messages/fr.json';
import kokrMessages from './messages/ko_KR.json';
import ptbrMessages from './messages/pt_BR.json';
import zhcnMessages from './messages/zh_CN.json';
const messages = {
ar: arMessages,
'es-419': es419Messages,
fr: frMessages,
'zh-cn': zhcnMessages,
'ko-kr': kokrMessages,
'pt-br': ptbrMessages,
};
export default messages;

View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -0,0 +1,2 @@
{
}

View File

@@ -1,8 +1,6 @@
import Header from './Header';
import LearningHeader from './learning-header/LearningHeader';
import SiteHeader from './SiteHeader';
import messages from './i18n/index';
import StudioHeader from './studio-header';
export { LearningHeader, messages, StudioHeader };
export { messages };
export default Header;
export default SiteHeader;

View File

@@ -3,7 +3,6 @@ $blue: #007db8;
$white: #fff;
@import './Menu/menu.scss';
@import './studio-header/StudioHeader.scss';
.dropdown-item a {
text-decoration: none;
@@ -26,30 +25,6 @@ $white: #fff;
}
}
.learning-header {
min-width: 0;
.course-title-lockup {
min-width: 0;
span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-bottom: 0.1rem;
}
}
.user-dropdown {
.btn {
height: 3rem;
// @media (max-width: -1 + map-get($grid-breakpoints, "sm")) {
// padding: 0 0.5rem;
// }
}
}
}
.site-header-mobile,
.site-header-desktop {
position: relative;
@@ -57,8 +32,6 @@ $white: #fff;
}
.site-header-mobile {
height: 3rem;
.nav-link {
text-decoration: none;
cursor: pointer;
@@ -70,6 +43,7 @@ $white: #fff;
.site-header-desktop {
height: 3.75rem;
box-shadow: 0 1px 0 0 rgba(0,0,0,.1);
background: $white;
.nav-link {
@@ -88,15 +62,6 @@ $white: #fff;
height: 100%;
}
}
.secondary-menu-container {
.nav-link:hover,
.nav-link:focus,
.nav-link.active,
.expanded .nav-link {
background: $component-active-bg;
color: $component-active-color;
}
}
.main-nav {
.nav-link {
padding: 1.125rem 1rem;

View File

@@ -1,32 +0,0 @@
import React from 'react';
import { getConfig } from '@edx/frontend-platform';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import genericMessages from '../generic/messages';
const AnonymousUserMenu = ({ intl }) => (
<div>
<Button
className="mr-3"
variant="outline-primary"
href={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
>
{intl.formatMessage(genericMessages.registerSentenceCase)}
</Button>
<Button
variant="primary"
href={`${getLoginRedirectUrl(global.location.href)}`}
>
{intl.formatMessage(genericMessages.signInSentenceCase)}
</Button>
</div>
);
AnonymousUserMenu.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(AnonymousUserMenu);

View File

@@ -1,56 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Dropdown } from '@openedx/paragon';
import messages from './messages';
const AuthenticatedUserDropdown = ({ intl, username }) => {
const dashboardMenuItem = (
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>
{intl.formatMessage(messages.dashboard)}
</Dropdown.Item>
);
return (
<>
<a className="text-gray-700" href={`${getConfig().SUPPORT_URL}`}>{intl.formatMessage(messages.help)}</a>
<Dropdown className="user-dropdown ml-3">
<Dropdown.Toggle variant="outline-primary">
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
<span data-hj-suppress className="d-none d-md-inline">
{username}
</span>
</Dropdown.Toggle>
<Dropdown.Menu className="dropdown-menu-right">
{dashboardMenuItem}
<Dropdown.Item href={`${getConfig().ACCOUNT_PROFILE_URL}/u/${username}`}>
{intl.formatMessage(messages.profile)}
</Dropdown.Item>
<Dropdown.Item href={getConfig().ACCOUNT_SETTINGS_URL}>
{intl.formatMessage(messages.account)}
</Dropdown.Item>
{ getConfig().ORDER_HISTORY_URL && (
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>
{intl.formatMessage(messages.orderHistory)}
</Dropdown.Item>
)}
<Dropdown.Item href={getConfig().LOGOUT_URL}>
{intl.formatMessage(messages.signOut)}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</>
);
};
AuthenticatedUserDropdown.propTypes = {
intl: intlShape.isRequired,
username: PropTypes.string.isRequired,
};
export default injectIntl(AuthenticatedUserDropdown);

View File

@@ -1,79 +0,0 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { getConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import AnonymousUserMenu from './AnonymousUserMenu';
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
import messages from './messages';
const LinkedLogo = ({
href,
src,
alt,
...attributes
}) => (
<a href={href} {...attributes}>
<img className="d-block" src={src} alt={alt} />
</a>
);
LinkedLogo.propTypes = {
href: PropTypes.string.isRequired,
src: PropTypes.string.isRequired,
alt: PropTypes.string.isRequired,
};
const LearningHeader = ({
courseOrg, courseNumber, courseTitle, intl, showUserDropdown,
}) => {
const { authenticatedUser } = useContext(AppContext);
const headerLogo = (
<LinkedLogo
className="logo"
href={`${getConfig().LMS_BASE_URL}/dashboard`}
src={getConfig().LOGO_URL}
alt={getConfig().SITE_NAME}
/>
);
return (
<header className="learning-header">
<a className="sr-only sr-only-focusable" href="#main-content">{intl.formatMessage(messages.skipNavLink)}</a>
<div className="container-xl py-2 d-flex align-items-center">
{headerLogo}
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
</div>
{showUserDropdown && authenticatedUser && (
<AuthenticatedUserDropdown
username={authenticatedUser.username}
/>
)}
{showUserDropdown && !authenticatedUser && (
<AnonymousUserMenu />
)}
</div>
</header>
);
};
LearningHeader.propTypes = {
courseOrg: PropTypes.string,
courseNumber: PropTypes.string,
courseTitle: PropTypes.string,
intl: intlShape.isRequired,
showUserDropdown: PropTypes.bool,
};
LearningHeader.defaultProps = {
courseOrg: null,
courseNumber: null,
courseTitle: null,
showUserDropdown: true,
};
export default injectIntl(LearningHeader);

View File

@@ -1,29 +0,0 @@
import React from 'react';
import {
authenticatedUser, initializeMockApp, render, screen,
} from '../setupTest';
import { LearningHeader as Header } from '../index';
describe('Header', () => {
beforeAll(async () => {
// We need to mock AuthService to implicitly use `getAuthenticatedUser` within `AppContext.Provider`.
await initializeMockApp();
});
it('displays user button', () => {
render(<Header />);
expect(screen.getByText(authenticatedUser.username)).toBeInTheDocument();
});
it('displays course data', () => {
const courseData = {
courseOrg: 'course-org',
courseNumber: 'course-number',
courseTitle: 'course-title',
};
render(<Header {...courseData} />);
expect(screen.getByText(`${courseData.courseOrg} ${courseData.courseNumber}`)).toBeInTheDocument();
expect(screen.getByText(courseData.courseTitle)).toBeInTheDocument();
});
});

View File

@@ -1,41 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
dashboard: {
id: 'header.menu.dashboard.label',
defaultMessage: 'Dashboard',
description: 'The text for the user menu Dashboard navigation link.',
},
help: {
id: 'header.help.label',
defaultMessage: 'Help',
description: 'The text for the link to the Help Center',
},
profile: {
id: 'header.menu.profile.label',
defaultMessage: 'Profile',
description: 'The text for the user menu Profile navigation link.',
},
account: {
id: 'header.menu.account.label',
defaultMessage: 'Account',
description: 'The text for the user menu Account navigation link.',
},
orderHistory: {
id: 'header.menu.orderHistory.label',
defaultMessage: 'Order History',
description: 'The text for the user menu Order History navigation link.',
},
skipNavLink: {
id: 'header.navigation.skipNavLink',
defaultMessage: 'Skip to main content.',
description: 'A link used by screen readers to allow users to skip to the main content of the page.',
},
signOut: {
id: 'header.menu.signOut.label',
defaultMessage: 'Sign Out',
description: 'The label for the user menu Sign Out action.',
},
});
export default messages;

1
src/logo.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -1,125 +1,26 @@
/* eslint-disable import/no-extraneous-dependencies */
import React from 'react';
import PropTypes from 'prop-types';
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
import 'babel-polyfill';
import 'jest-chain';
import { getConfig, mergeConfig } from '@edx/frontend-platform';
import { configure as configureLogging } from '@edx/frontend-platform/logging';
import { configure as configureI18n } from '@edx/frontend-platform/i18n';
import { configure as configureAuth, MockAuthService } from '@edx/frontend-platform/auth';
import { render as rtlRender } from '@testing-library/react';
import { IntlProvider } from 'react-intl';
import AppProvider from '@edx/frontend-platform/react/AppProvider';
import appMessages from './i18n';
Enzyme.configure({ adapter: new Adapter() });
// These configuration values are usually set in webpack's EnvironmentPlugin however
// Jest does not use webpack so we need to set these so for testing
process.env.ACCESS_TOKEN_COOKIE_NAME = 'edx-jwt-cookie-header-payload';
process.env.ACCOUNT_PROFILE_URL = 'http://localhost:1995';
process.env.ACCOUNT_SETTINGS_URL = 'http://localhost:1997';
process.env.BASE_URL = 'localhost:1995';
process.env.CREDENTIALS_BASE_URL = 'http://localhost:18150';
process.env.CSRF_COOKIE_NAME = 'csrftoken';
process.env.CSRF_TOKEN_API_PATH = '/csrf/api/v1/token';
process.env.ECOMMERCE_BASE_URL = 'http://localhost:18130';
process.env.LANGUAGE_PREFERENCE_COOKIE_NAME = 'openedx-language-preference';
process.env.LMS_BASE_URL = 'http://localhost:18000';
process.env.LOGIN_URL = 'http://localhost:18000/login';
process.env.LOGOUT_URL = 'http://localhost:18000/logout';
process.env.LOGOUT_URL = 'http://localhost:18000/login';
process.env.MARKETING_SITE_BASE_URL = 'http://localhost:18000';
process.env.ORDER_HISTORY_URL = 'localhost:1996/orders';
process.env.REFRESH_ACCESS_TOKEN_ENDPOINT = 'http://localhost:18000/login_refresh';
process.env.SEGMENT_KEY = 'segment_whoa';
process.env.SITE_NAME = 'edX';
process.env.USER_INFO_COOKIE_NAME = 'edx-user-info';
process.env.LOGO_URL = 'https://edx-cdn.org/v3/default/logo.svg';
process.env.LOGO_TRADEMARK_URL = 'https://edx-cdn.org/v3/default/logo-trademark.svg';
process.env.LOGO_WHITE_URL = 'https://edx-cdn.org/v3/default/logo-white.svg';
process.env.FAVICON_URL = 'https://edx-cdn.org/v3/default/favicon.ico';
class MockLoggingService {
logInfo = jest.fn();
logError = jest.fn();
}
export const authenticatedUser = {
userId: 'abc123',
username: 'Mock User',
roles: [],
administrator: false,
};
export function initializeMockApp() {
mergeConfig({
INSIGHTS_BASE_URL: process.env.INSIGHTS_BASE_URL || null,
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL || null,
TWITTER_URL: process.env.TWITTER_URL || null,
BASE_URL: process.env.BASE_URL || null,
LMS_BASE_URL: process.env.LMS_BASE_URL || null,
LOGIN_URL: process.env.LOGIN_URL || null,
LOGOUT_URL: process.env.LOGOUT_URL || null,
REFRESH_ACCESS_TOKEN_ENDPOINT: process.env.REFRESH_ACCESS_TOKEN_ENDPOINT || null,
ACCESS_TOKEN_COOKIE_NAME: process.env.ACCESS_TOKEN_COOKIE_NAME || null,
CSRF_TOKEN_API_PATH: process.env.CSRF_TOKEN_API_PATH || null,
LOGO_URL: process.env.LOGO_URL || null,
SITE_NAME: process.env.SITE_NAME || null,
authenticatedUser: {
userId: 'abc123',
username: 'Mock User',
roles: [],
administrator: false,
},
});
const loggingService = configureLogging(MockLoggingService, {
config: getConfig(),
});
const authService = configureAuth(MockAuthService, {
config: getConfig(),
loggingService,
});
// i18n doesn't have a service class to return.
configureI18n({
config: getConfig(),
loggingService,
messages: [appMessages],
});
return { loggingService, authService };
}
function render(
ui,
{
store = null,
...renderOptions
} = {},
) {
const Wrapper = ({ children }) => (
// eslint-disable-next-line react/jsx-filename-extension
<IntlProvider locale="en">
<AppProvider store={store}>
{children}
</AppProvider>
</IntlProvider>
);
Wrapper.propTypes = {
children: PropTypes.node.isRequired,
};
return rtlRender(ui, { wrapper: Wrapper, ...renderOptions });
}
// Re-export everything.
export * from '@testing-library/react';
// Override `render` method.
export {
render,
};

View File

@@ -1,24 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
const BrandNav = ({
studioBaseUrl,
logo,
logoAltText,
}) => (
<a href={studioBaseUrl}>
<img
src={logo}
alt={logoAltText}
className="d-block logo"
/>
</a>
);
BrandNav.propTypes = {
studioBaseUrl: PropTypes.string.isRequired,
logo: PropTypes.string.isRequired,
logoAltText: PropTypes.string.isRequired,
};
export default BrandNav;

View File

@@ -1,54 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
OverlayTrigger,
Tooltip,
} from '@openedx/paragon';
import messages from './messages';
const CourseLockUp = ({
outlineLink,
org,
number,
title,
// injected
intl,
}) => (
<OverlayTrigger
placement="bottom"
overlay={(
<Tooltip id="course-lock-up">
{title}
</Tooltip>
)}
>
<a
className="course-title-lockup mr-2"
href={outlineLink}
aria-label={intl.formatMessage(messages['header.label.courseOutline'])}
data-testid="course-lock-up-block"
>
<span className="d-block small m-0 text-gray-800" data-testid="course-org-number">{org} {number}</span>
<span className="d-block m-0 font-weight-bold text-gray-800" data-testid="course-title">{title}</span>
</a>
</OverlayTrigger>
);
CourseLockUp.propTypes = {
number: PropTypes.string,
org: PropTypes.string,
title: PropTypes.string,
outlineLink: PropTypes.string,
// injected
intl: intlShape.isRequired,
};
CourseLockUp.defaultProps = {
number: null,
org: null,
title: null,
outlineLink: null,
};
export default injectIntl(CourseLockUp);

View File

@@ -1,179 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Button,
Container,
Icon,
IconButton,
Nav,
Row,
} from '@openedx/paragon';
import { Close, MenuIcon, Search } from '@openedx/paragon/icons';
import CourseLockUp from './CourseLockUp';
import UserMenu from './UserMenu';
import BrandNav from './BrandNav';
import NavDropdownMenu from './NavDropdownMenu';
import messages from './messages';
const HeaderBody = ({
logo,
logoAltText,
number,
org,
title,
username,
isAdmin,
studioBaseUrl,
logoutUrl,
authenticatedUserAvatar,
isMobile,
setModalPopupTarget,
toggleModalPopup,
isModalPopupOpen,
isHiddenMainMenu,
mainMenuDropdowns,
outlineLink,
searchButtonAction,
}) => {
const intl = useIntl();
const renderBrandNav = (
<BrandNav
{...{
studioBaseUrl,
logo,
logoAltText,
}}
/>
);
return (
<Container size="xl" className="px-2.5">
<ActionRow as="header">
{isHiddenMainMenu ? (
<Row className="flex-nowrap ml-4">
{renderBrandNav}
</Row>
) : (
<>
{isMobile ? (
<Button
ref={setModalPopupTarget}
className="d-inline-flex align-items-center"
variant="tertiary"
onClick={toggleModalPopup}
iconBefore={isModalPopupOpen ? Close : MenuIcon}
data-testid="mobile-menu-button"
>
Menu
</Button>
) : (
<div className="w-25">
<Row className="m-0 flex-nowrap">
{renderBrandNav}
<CourseLockUp
{...{
outlineLink,
number,
org,
title,
}}
/>
</Row>
</div>
)}
{isMobile ? (
<>
<ActionRow.Spacer />
{renderBrandNav}
</>
) : (
<Nav data-testid="desktop-menu" className="ml-2">
{mainMenuDropdowns.map(dropdown => {
const { id, buttonTitle, items } = dropdown;
return (
<NavDropdownMenu key={id} {...{ id, buttonTitle, items }} />
);
})}
</Nav>
)}
</>
)}
<ActionRow.Spacer />
{searchButtonAction && (
<Nav>
<IconButton
src={Search}
iconAs={Icon}
onClick={searchButtonAction}
aria-label={intl.formatMessage(messages['header.label.search.nav'])}
/>
</Nav>
)}
<Nav>
<UserMenu
{...{
username,
studioBaseUrl,
logoutUrl,
authenticatedUserAvatar,
isAdmin,
}}
/>
</Nav>
</ActionRow>
</Container>
);
};
HeaderBody.propTypes = {
studioBaseUrl: PropTypes.string.isRequired,
logoutUrl: PropTypes.string.isRequired,
setModalPopupTarget: PropTypes.func,
toggleModalPopup: PropTypes.func,
isModalPopupOpen: PropTypes.bool,
number: PropTypes.string,
org: PropTypes.string,
title: PropTypes.string,
logo: PropTypes.string,
logoAltText: PropTypes.string,
authenticatedUserAvatar: PropTypes.string,
username: PropTypes.string,
isAdmin: PropTypes.bool,
isMobile: PropTypes.bool,
isHiddenMainMenu: PropTypes.bool,
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
buttonTitle: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.string,
})),
})),
outlineLink: PropTypes.string,
searchButtonAction: PropTypes.func,
};
HeaderBody.defaultProps = {
setModalPopupTarget: null,
toggleModalPopup: null,
isModalPopupOpen: false,
logo: null,
logoAltText: null,
number: '',
org: '',
title: '',
authenticatedUserAvatar: null,
username: null,
isAdmin: false,
isMobile: false,
isHiddenMainMenu: false,
mainMenuDropdowns: [],
outlineLink: null,
searchButtonAction: null,
};
export default HeaderBody;

View File

@@ -1,73 +0,0 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useToggle, ModalPopup } from '@openedx/paragon';
import HeaderBody from './HeaderBody';
import MobileMenu from './MobileMenu';
const MobileHeader = ({
mainMenuDropdowns,
...props
}) => {
const [isOpen, , close, toggle] = useToggle(false);
const [target, setTarget] = useState(null);
return (
<>
<HeaderBody
{...props}
isMobile
setModalPopupTarget={setTarget}
toggleModalPopup={toggle}
isModalPopupOpen={isOpen}
/>
<ModalPopup
hasArrow
placement="bottom"
positionRef={target}
isOpen={isOpen}
onClose={close}
onEscapeKey={close}
className="mobile-menu-container"
>
<MobileMenu {...{ mainMenuDropdowns }} />
</ModalPopup>
</>
);
};
MobileHeader.propTypes = {
studioBaseUrl: PropTypes.string.isRequired,
logoutUrl: PropTypes.string.isRequired,
number: PropTypes.string,
org: PropTypes.string,
title: PropTypes.string,
logo: PropTypes.string,
logoAltText: PropTypes.string,
authenticatedUserAvatar: PropTypes.string,
username: PropTypes.string,
isAdmin: PropTypes.bool,
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
buttonTitle: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.string,
})),
})),
outlineLink: PropTypes.string,
};
MobileHeader.defaultProps = {
logo: null,
logoAltText: null,
number: null,
org: null,
title: null,
authenticatedUserAvatar: null,
username: null,
isAdmin: false,
mainMenuDropdowns: [],
outlineLink: null,
};
export default MobileHeader;

View File

@@ -1,51 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Collapsible } from '@openedx/paragon';
const MobileMenu = ({
mainMenuDropdowns,
}) => (
<div
className="ml-4 p-2 bg-light-100 border border-gray-200 small rounded"
data-testid="mobile-menu"
>
<div>
{mainMenuDropdowns.map(dropdown => {
const { id, buttonTitle, items } = dropdown;
return (
<Collapsible
className="border-light-100"
title={buttonTitle}
key={id}
>
<ul className="p-0" style={{ listStyleType: 'none' }}>
{items.map(item => (
<li className="mobile-menu-item">
<a href={item.href}>
{item.title}
</a>
</li>
))}
</ul>
</Collapsible>
);
})}
</div>
</div>
);
MobileMenu.propTypes = {
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
buttonTitle: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.string,
})),
})),
};
MobileMenu.defaultProps = {
mainMenuDropdowns: [],
};
export default MobileMenu;

View File

@@ -1,40 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import {
Dropdown,
DropdownButton,
} from '@openedx/paragon';
const NavDropdownMenu = ({
id,
buttonTitle,
items,
}) => (
<DropdownButton
id={id}
title={buttonTitle}
variant="outline-primary"
className="mr-2"
>
{items.map(item => (
<Dropdown.Item
key={`${item.title}-dropdown-item`}
href={item.href}
className="small"
>
{item.title}
</Dropdown.Item>
))}
</DropdownButton>
);
NavDropdownMenu.propTypes = {
id: PropTypes.string.isRequired,
buttonTitle: PropTypes.string.isRequired,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.string,
})).isRequired,
};
export default NavDropdownMenu;

View File

@@ -1,78 +0,0 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import Responsive from 'react-responsive';
import { AppContext } from '@edx/frontend-platform/react';
import { ensureConfig } from '@edx/frontend-platform';
import MobileHeader from './MobileHeader';
import HeaderBody from './HeaderBody';
ensureConfig([
'STUDIO_BASE_URL',
'SITE_NAME',
'LOGOUT_URL',
'LOGIN_URL',
'LOGO_URL',
], 'Studio Header component');
const StudioHeader = ({
number, org, title, isHiddenMainMenu, mainMenuDropdowns, outlineLink, searchButtonAction,
}) => {
const { authenticatedUser, config } = useContext(AppContext);
const props = {
logo: config.LOGO_URL,
logoAltText: `Studio ${config.SITE_NAME}`,
number,
org,
title,
username: authenticatedUser?.username,
isAdmin: authenticatedUser?.administrator,
authenticatedUserAvatar: authenticatedUser?.avatar,
studioBaseUrl: config.STUDIO_BASE_URL,
logoutUrl: config.LOGOUT_URL,
isHiddenMainMenu,
mainMenuDropdowns,
outlineLink,
searchButtonAction,
};
return (
<div className="studio-header">
<a className="nav-skip sr-only sr-only-focusable" href="#main">Skip to content</a>
<Responsive maxWidth={841}>
<MobileHeader {...props} />
</Responsive>
<Responsive minWidth={842}>
<HeaderBody {...props} />
</Responsive>
</div>
);
};
StudioHeader.propTypes = {
number: PropTypes.string,
org: PropTypes.string,
title: PropTypes.string.isRequired,
isHiddenMainMenu: PropTypes.bool,
mainMenuDropdowns: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
buttonTitle: PropTypes.string,
items: PropTypes.arrayOf(PropTypes.shape({
href: PropTypes.string,
title: PropTypes.string,
})),
})),
outlineLink: PropTypes.string,
searchButtonAction: PropTypes.func,
};
StudioHeader.defaultProps = {
number: '',
org: '',
isHiddenMainMenu: false,
mainMenuDropdowns: [],
outlineLink: null,
searchButtonAction: null,
};
export default StudioHeader;

View File

@@ -1,49 +0,0 @@
$spacer: 1rem;
$white: #FFFFFF;
.studio-header {
position: relative;
z-index: 1000;
height: 3.75rem;
box-shadow: 0 1px 0 0 rgb(0 0 0 / .1);
background: $white;
.btn-outline-primary {
border-color: $white;
}
.logo {
display: block;
box-sizing: content-box;
position: relative;
top: -.05em;
height: 1.75rem;
padding: $spacer 0;
margin-right: $spacer;
img {
display: block;
height: 100%;
}
}
.course-title-lockup {
@media only screen and (min-width: 769px) {
padding: .5rem;
padding-right: $spacer;
border-right: 1px solid #E5E5E5;
width: 70%;
}
overflow: hidden;
span {
color: #333333;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.375rem;
}
}
}

View File

@@ -1,212 +0,0 @@
/* eslint-disable react/prop-types */
import React, { useMemo } from 'react';
import {
render,
fireEvent,
waitFor,
} from '@testing-library/react';
import { AppContext } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { Context as ResponsiveContext } from 'react-responsive';
import StudioHeader from './StudioHeader';
import messages from './messages';
const authenticatedUser = {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
avatar: '/imges/test.png',
};
let currentUser;
let screenWidth = 1280;
const RootWrapper = ({
...props
}) => {
const appContextValue = useMemo(() => ({
authenticatedUser: currentUser,
config: {
LOGOUT_URL: process.env.LOGOUT_URL,
LOGO_URL: process.env.LOGO_URL,
SITE_NAME: process.env.SITE_NAME,
STUDIO_BASE_URL: process.env.STUDIO_BASE_URL,
LOGIN_URL: process.env.LOGIN_URL,
},
}), []);
const responsiveContextValue = useMemo(() => ({ width: screenWidth }), []);
return (
// eslint-disable-next-line react/jsx-no-constructed-context-values, react/prop-types
<IntlProvider locale="en">
<AppContext.Provider value={appContextValue}>
<ResponsiveContext.Provider value={responsiveContextValue}>
<StudioHeader
{...props}
/>
</ResponsiveContext.Provider>
</AppContext.Provider>
</IntlProvider>
);
};
const props = {
number: '123',
org: 'Ed',
title: 'test',
mainMenuDropdowns: [
{
id: 'testId',
buttonTitle: 'test',
items: [
{
title: 'link',
href: '#',
},
],
},
],
outlineLink: 'tEsTLInK',
searchButtonAction: null,
};
describe('Header', () => {
beforeEach(() => {
jest.clearAllMocks();
currentUser = authenticatedUser;
});
describe('desktop', () => {
it('course lock up should be visible', () => {
const { getByTestId } = render(<RootWrapper {...props} />);
const courseLockUpBlock = getByTestId('course-lock-up-block');
expect(courseLockUpBlock).toBeVisible();
});
it('mobile menu should not be visible', () => {
const { queryByTestId } = render(<RootWrapper {...props} />);
const mobileMenuButton = queryByTestId('mobile-menu-button');
expect(mobileMenuButton).toBeNull();
});
it('desktop menu should be visible', () => {
const { getByTestId } = render(<RootWrapper {...props} />);
const desktopMenu = getByTestId('desktop-menu');
expect(desktopMenu).toBeVisible();
});
it('should render one dropdown', async () => {
const { getAllByRole, getByText } = render(<RootWrapper {...props} />);
const dropdownMenu = getAllByRole('button')[0];
expect(dropdownMenu).toBeVisible();
await waitFor(() => fireEvent.click(dropdownMenu));
const dropdownOption = getByText('link');
expect(dropdownOption).toBeVisible();
});
it('maintenance should not be in user menu', async () => {
currentUser = { ...authenticatedUser, administrator: false };
const { getAllByRole, queryByText } = render(<RootWrapper {...props} />);
const userMenu = getAllByRole('button')[1];
await waitFor(() => fireEvent.click(userMenu));
const maintenanceButton = queryByText(messages['header.user.menu.maintenance'].defaultMessage);
expect(maintenanceButton).toBeNull();
});
it('user menu should use avatar icon', async () => {
currentUser = { ...authenticatedUser, avatar: null };
const { getByTestId } = render(<RootWrapper {...props} />);
const avatarIcon = getByTestId('avatar-icon');
expect(avatarIcon).toBeVisible();
});
it('should hide nav items if prop isHiddenMainMenu true', async () => {
const initialProps = { ...props, isHiddenMainMenu: true };
const { queryByTestId } = render(<RootWrapper {...initialProps} />);
const desktopMenu = queryByTestId('desktop-menu');
const mobileMenuButton = queryByTestId('mobile-menu-button');
expect(mobileMenuButton).toBeNull();
expect(desktopMenu).toBeNull();
});
it('should show search button', async () => {
const testProps = { ...props, searchButtonAction: () => undefined };
const { getByRole } = render(<RootWrapper {...testProps} />);
const searchButton = getByRole('button', { name: 'Search content' });
expect(searchButton).toBeVisible();
});
it('should not show search button', async () => {
const testProps = { ...props, searchButtonAction: null };
const { queryByRole } = render(<RootWrapper {...testProps} />);
expect(queryByRole('button', { name: 'Search content' })).not.toBeInTheDocument();
});
});
describe('mobile', () => {
beforeEach(() => { screenWidth = 500; });
it('course lock up should not be visible', async () => {
const { queryByTestId } = render(<RootWrapper {...props} />);
const courseLockUpBlock = queryByTestId('course-lock-up-block');
expect(courseLockUpBlock).toBeNull();
});
it('mobile menu should be visible', async () => {
const { getByTestId } = render(<RootWrapper {...props} />);
const mobileMenuButton = getByTestId('mobile-menu-button');
expect(mobileMenuButton).toBeVisible();
await waitFor(() => fireEvent.click(mobileMenuButton));
const mobileMenu = getByTestId('mobile-menu');
expect(mobileMenu).toBeVisible();
});
it('desktop menu should not be visible', () => {
const { queryByTestId } = render(<RootWrapper {...props} />);
const desktopMenu = queryByTestId('desktop-menu');
expect(desktopMenu).toBeNull();
});
it('maintenance should be in user menu', async () => {
const { getAllByRole, getByText } = render(<RootWrapper {...props} />);
const userMenu = getAllByRole('button')[1];
await waitFor(() => fireEvent.click(userMenu));
const maintenanceButton = getByText(messages['header.user.menu.maintenance'].defaultMessage);
expect(maintenanceButton).toBeVisible();
});
it('user menu should use avatar image', async () => {
const { getByTestId } = render(<RootWrapper {...props} />);
const avatarImage = getByTestId('avatar-image');
expect(avatarImage).toBeVisible();
});
it('should hide nav items if prop isHiddenMainMenu true', async () => {
const initialProps = { ...props, isHiddenMainMenu: true };
const { queryByTestId } = render(<RootWrapper {...initialProps} />);
const desktopMenu = queryByTestId('desktop-menu');
const mobileMenuButton = queryByTestId('mobile-menu-button');
expect(mobileMenuButton).toBeNull();
expect(desktopMenu).toBeNull();
});
});
});

View File

@@ -1,69 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import {
Avatar,
} from '@openedx/paragon';
import NavDropdownMenu from './NavDropdownMenu';
import getUserMenuItems from './utils';
const UserMenu = ({
username,
studioBaseUrl,
logoutUrl,
authenticatedUserAvatar,
isMobile,
isAdmin,
// injected
intl,
}) => {
const avatar = authenticatedUserAvatar ? (
<img
className="d-block w-100 h-100"
src={authenticatedUserAvatar}
alt={username}
data-testid="avatar-image"
/>
) : (
<Avatar
size="sm"
className="mr-2"
alt={username}
data-testid="avatar-icon"
/>
);
const title = isMobile ? avatar : <>{avatar}{username}</>;
return (
<NavDropdownMenu
buttonTitle={title}
id="user-dropdown-menu"
items={getUserMenuItems({
studioBaseUrl,
logoutUrl,
intl,
isAdmin,
})}
/>
);
};
UserMenu.propTypes = {
username: PropTypes.string,
studioBaseUrl: PropTypes.string.isRequired,
logoutUrl: PropTypes.string.isRequired,
authenticatedUserAvatar: PropTypes.string,
isMobile: PropTypes.bool,
isAdmin: PropTypes.bool,
// injected
intl: intlShape.isRequired,
};
UserMenu.defaultProps = {
isMobile: false,
isAdmin: false,
authenticatedUserAvatar: null,
username: null,
};
export default injectIntl(UserMenu);

View File

@@ -1,3 +0,0 @@
import StudioHeader from './StudioHeader';
export default StudioHeader;

View File

@@ -1,61 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'header.user.menu.studio': {
id: 'header.user.menu.studio',
defaultMessage: 'Studio Home',
description: 'Link to Studio Home',
},
'header.user.menu.maintenance': {
id: 'header.user.menu.maintenance',
defaultMessage: 'Maintenance',
description: 'Link to the Studio maintenance page',
},
'header.user.menu.logout': {
id: 'header.user.menu.logout',
defaultMessage: 'Logout',
description: 'Logout link',
},
'header.label.account.menu': {
id: 'header.label.account.menu',
defaultMessage: 'Account Menu',
description: 'The aria label for the account menu trigger',
},
'header.label.account.menu.for': {
id: 'header.label.account.menu.for',
defaultMessage: 'Account menu for {username}',
description: 'The aria label for the account menu trigger when the username is displayed in it',
},
'header.label.main.nav': {
id: 'header.label.main.nav',
defaultMessage: 'Main',
description: 'The aria label for the main menu nav',
},
'header.label.main.menu': {
id: 'header.label.main.menu',
defaultMessage: 'Main Menu',
description: 'The aria label for the main menu trigger',
},
'header.label.main.header': {
id: 'header.label.main.header',
defaultMessage: 'Main',
description: 'The aria label for the main header',
},
'header.label.secondary.nav': {
id: 'header.label.secondary.nav',
defaultMessage: 'Secondary',
description: 'The aria label for the seconary nav',
},
'header.label.courseOutline': {
id: 'header.label.courseOutline',
defaultMessage: 'Back to course outline in Studio',
description: 'The aria label for the link back to the Studio Course Outline',
},
'header.label.search.nav': {
id: 'header.label.search.nav',
defaultMessage: 'Search content',
description: 'The aria label for the search content button nav',
},
});
export default messages;

View File

@@ -1,36 +0,0 @@
import messages from './messages';
const getUserMenuItems = ({
studioBaseUrl,
logoutUrl,
intl,
isAdmin,
}) => {
let items = [
{
href: `${studioBaseUrl}`,
title: intl.formatMessage(messages['header.user.menu.studio']),
}, {
href: `${logoutUrl}`,
title: intl.formatMessage(messages['header.user.menu.logout']),
},
];
if (isAdmin) {
items = [
{
href: `${studioBaseUrl}`,
title: intl.formatMessage(messages['header.user.menu.studio']),
}, {
href: `${studioBaseUrl}/maintenance`,
title: intl.formatMessage(messages['header.user.menu.maintenance']),
}, {
href: `${logoutUrl}`,
title: intl.formatMessage(messages['header.user.menu.logout']),
},
];
}
return items;
};
export default getUserMenuItems;

View File

@@ -1,6 +0,0 @@
const executeThunk = async (thunk, dispatch, getState) => {
await thunk(dispatch, getState);
await new Promise(setImmediate);
};
export default executeThunk;

View File

@@ -1,15 +0,0 @@
const path = require('path');
const { createConfig } = require('@openedx/frontend-build');
module.exports = createConfig('webpack-dev', {
entry: path.resolve(__dirname, 'example'),
output: {
path: path.resolve(__dirname, 'example/dist'),
publicPath: '/',
},
resolve: {
alias: {
'@edx/frontend-component-header': path.resolve(__dirname, 'src'),
},
},
});