Compare commits
467 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
354f9fdc38 | ||
|
|
85b07acfb5 | ||
|
|
3e647f7394 | ||
|
|
848b0f37b9 | ||
|
|
818b3800aa | ||
|
|
63e47bc45a | ||
|
|
7ba5371f69 | ||
|
|
9f0c286897 | ||
|
|
4cc5b91d6d | ||
|
|
3d75a72f0c | ||
|
|
0541dc194e | ||
|
|
b92127fd12 | ||
|
|
b2b9f3fa00 | ||
|
|
b9b6282b4b | ||
|
|
8606585978 | ||
|
|
159072779f | ||
|
|
de843d330d | ||
|
|
d554de89ca | ||
|
|
72be96c230 | ||
|
|
564f34a7c6 | ||
|
|
ab15b3d2bf | ||
|
|
59db41c61e | ||
|
|
0415c00353 | ||
|
|
9d01c074e0 | ||
|
|
83c5b0258f | ||
|
|
45246ad5ee | ||
|
|
487b2590bd | ||
|
|
6cab3f3f3e | ||
|
|
e3c8ec027e | ||
|
|
1e899c1c48 | ||
|
|
370b193df3 | ||
|
|
58c34abd66 | ||
|
|
c9942c1552 | ||
|
|
432dbb5e6b | ||
|
|
02748fab13 | ||
|
|
3a5506c646 | ||
|
|
10619ceb5e | ||
|
|
2694492a7c | ||
|
|
be7d0d97e4 | ||
|
|
e6aa4be4f6 | ||
|
|
f2c236c828 | ||
|
|
db912e6dae | ||
|
|
abb08be08e | ||
|
|
6c6ccc7f20 | ||
|
|
2494ad2b57 | ||
|
|
3b2a2bfa95 | ||
|
|
30b91791e3 | ||
|
|
08592aeec7 | ||
|
|
c1d143ace2 | ||
|
|
54a879aec2 | ||
|
|
b5b37f1d64 | ||
|
|
36be99ace0 | ||
|
|
222bc19bd0 | ||
|
|
3d827e64ea | ||
|
|
8a247abd6a | ||
|
|
a6943fbaeb | ||
|
|
19292cd5b6 | ||
|
|
b6374a5c05 | ||
|
|
f6a4036b49 | ||
|
|
12a845ad33 | ||
|
|
a4a7456726 | ||
|
|
6fdf73fed3 | ||
|
|
32b8079744 | ||
|
|
b9f7fe74c8 | ||
|
|
5a259a76df | ||
|
|
d2ad5ee2a4 | ||
|
|
67fefe814b | ||
|
|
9a5b1fa5e7 | ||
|
|
55f21aeeaa | ||
|
|
fc24e61a1c | ||
|
|
bace8286fd | ||
|
|
665653e9a5 | ||
|
|
0cc2282c44 | ||
|
|
455ffd345c | ||
|
|
dde02a0739 | ||
|
|
81cb72f10b | ||
|
|
e285a91408 | ||
|
|
50142adb85 | ||
|
|
7281804fbd | ||
|
|
b446534992 | ||
|
|
3adc305aec | ||
|
|
65446ce9c3 | ||
|
|
f3637b5624 | ||
|
|
3c1d2152aa | ||
|
|
48b22ea41e | ||
|
|
ed76e40862 | ||
|
|
d5b07cc38f | ||
|
|
b9509bb890 | ||
|
|
c87a1049c8 | ||
|
|
e0c22e781d | ||
|
|
f1605d1f27 | ||
|
|
5d7826c26c | ||
|
|
5d075b0cdc | ||
|
|
1a86685f11 | ||
|
|
e31597509c | ||
|
|
c79c137fd6 | ||
|
|
76f735ed39 | ||
|
|
2298791aeb | ||
|
|
9f48ccc66b | ||
|
|
398479d2e7 | ||
|
|
3d6d815373 | ||
|
|
287bf50a46 | ||
|
|
677e872320 | ||
|
|
d8ecfd6fa3 | ||
|
|
9a6074868b | ||
|
|
077ecf38a4 | ||
|
|
7d6aa276ec | ||
|
|
115e69fad3 | ||
|
|
3e04f76c81 | ||
|
|
83eeb88eab | ||
|
|
a05570ed37 | ||
|
|
058a846978 | ||
|
|
04cd95824e | ||
|
|
678b95f60d | ||
|
|
599f513624 | ||
|
|
e8ce471f6f | ||
|
|
4e727748af | ||
|
|
79c5bbff68 | ||
|
|
a95b600a00 | ||
|
|
464e952a1e | ||
|
|
018ca18a4e | ||
|
|
4b89e7561f | ||
|
|
da08e721c2 | ||
|
|
d037e96a0c | ||
|
|
050dd30d8f | ||
|
|
3dd41030d3 | ||
|
|
27228f093d | ||
|
|
83f3241e26 | ||
|
|
e153aefc13 | ||
|
|
936c8714b7 | ||
|
|
6b18f28933 | ||
|
|
0ce451cfd2 | ||
|
|
42e831a693 | ||
|
|
9af7eaf587 | ||
|
|
3fbbde1044 | ||
|
|
c3f0282be4 | ||
|
|
ceb9c13542 | ||
|
|
7229bff3ff | ||
|
|
0695d4f400 | ||
|
|
de8783c708 | ||
|
|
e2540bc3a0 | ||
|
|
ffb5a765e2 | ||
|
|
952e543217 | ||
|
|
0e6a272f2b | ||
|
|
45a1da9f5e | ||
|
|
022515d1d2 | ||
|
|
2d737aae7f | ||
|
|
4c4db14eac | ||
|
|
911cea6a0e | ||
|
|
a52ddfd9bd | ||
|
|
8175ba897a | ||
|
|
cfda72b2e2 | ||
|
|
4483a734bc | ||
|
|
db1903cdce | ||
|
|
71851b13a6 | ||
|
|
6efa31092d | ||
|
|
c3541a3d79 | ||
|
|
dad01fcd78 | ||
|
|
30e6eed60d | ||
|
|
de69ed3dd9 | ||
|
|
1d55df323f | ||
|
|
4e718f85de | ||
|
|
a211547a1d | ||
|
|
784e9afccf | ||
|
|
4b23d8c4e4 | ||
|
|
6d02e63d08 | ||
|
|
b1feed2443 | ||
|
|
cabf4e3f27 | ||
|
|
78a40d47c1 | ||
|
|
c7178afe6b | ||
|
|
18a6840037 | ||
|
|
583a487c38 | ||
|
|
3276496523 | ||
|
|
7ab55175b5 | ||
|
|
72e82005c0 | ||
|
|
c4df727178 | ||
|
|
1f6766175d | ||
|
|
2ac8988a9b | ||
|
|
642be093c7 | ||
|
|
86939a2559 | ||
|
|
8ed18f3d69 | ||
|
|
061746da9f | ||
|
|
de77aa5f0c | ||
|
|
7034d10536 | ||
|
|
4ce7311809 | ||
|
|
e76f5b6937 | ||
|
|
f8fc794458 | ||
|
|
a5069edd94 | ||
|
|
2543926c95 | ||
|
|
c5eb43a2a5 | ||
|
|
256fa5c9d8 | ||
|
|
267cce9f89 | ||
|
|
4f59c80a12 | ||
|
|
59afd596ab | ||
|
|
0c83268163 | ||
|
|
c5f5fa9281 | ||
|
|
e247aee372 | ||
|
|
f6ae5a4bdd | ||
|
|
057d16d3c1 | ||
|
|
93bb38d0bd | ||
|
|
01405eaff9 | ||
|
|
59fa6e2a35 | ||
|
|
9fc3a0e835 | ||
|
|
22e157adf6 | ||
|
|
6b81f69eba | ||
|
|
cefa84006c | ||
|
|
a54309dd63 | ||
|
|
aeb0fd2be7 | ||
|
|
26eb2bb4c7 | ||
|
|
8083079954 | ||
|
|
d4fc8489ea | ||
|
|
4020a81bd4 | ||
|
|
acf1adba80 | ||
|
|
a204ff8c03 | ||
|
|
e8ccc4b707 | ||
|
|
4d86780c73 | ||
|
|
fdbb83f51e | ||
|
|
f6e4664d37 | ||
|
|
65177fcdcc | ||
|
|
7601249fb6 | ||
|
|
c8ef3dad93 | ||
|
|
b02fe00c71 | ||
|
|
4404aede33 | ||
|
|
1b0edb10c4 | ||
|
|
546adff45e | ||
|
|
94b14fd618 | ||
|
|
5b8a9a587b | ||
|
|
2650cb59b3 | ||
|
|
bc2b13175a | ||
|
|
85e8094833 | ||
|
|
aff8dda3ee | ||
|
|
51b505552d | ||
|
|
3648f1b6be | ||
|
|
c78b6964b9 | ||
|
|
664d05134b | ||
|
|
b969522cd0 | ||
|
|
0cd8210ea7 | ||
|
|
1c763c2102 | ||
|
|
073003284a | ||
|
|
92fdf85c9a | ||
|
|
5ee8a8c75c | ||
|
|
536d67404f | ||
|
|
9d99bfcec6 | ||
|
|
3180c9d973 | ||
|
|
1645274d9f | ||
|
|
84e43cb038 | ||
|
|
994b21c0c1 | ||
|
|
940b45ba7e | ||
|
|
4efa0a07ae | ||
|
|
2bd6879bda | ||
|
|
b479f0b376 | ||
|
|
dfdcbc0a8d | ||
|
|
c3b02a2946 | ||
|
|
f6c1a8bcc1 | ||
|
|
6c02962e0d | ||
|
|
acaf98f0b1 | ||
|
|
90351083aa | ||
|
|
6f75684ad9 | ||
|
|
a54f099d68 | ||
|
|
02d081dd26 | ||
|
|
468acc80f0 | ||
|
|
90fdd13fbc | ||
|
|
faf1b8522a | ||
|
|
e8a28b09bc | ||
|
|
c611df3f69 | ||
|
|
ab371f1c3a | ||
|
|
fb2002a004 | ||
|
|
f955ec4434 | ||
|
|
d529e00d7b | ||
|
|
e0cbbf7da1 | ||
|
|
ba209fd050 | ||
|
|
ae7004e95d | ||
|
|
de9eb63b07 | ||
|
|
bf64a829cc | ||
|
|
1e1b06dfa5 | ||
|
|
469a93bd9c | ||
|
|
0645761f05 | ||
|
|
20e9881546 | ||
|
|
42b347058f | ||
|
|
b097535580 | ||
|
|
d06929ca2b | ||
|
|
0a9316c407 | ||
|
|
d7704799fe | ||
|
|
cc26f750e8 | ||
|
|
8ac22515a6 | ||
|
|
b4a8d0afdc | ||
|
|
c131842139 | ||
|
|
acf5398b31 | ||
|
|
871e35d53a | ||
|
|
c8fb2ded56 | ||
|
|
01ffe0b945 | ||
|
|
092c61dada | ||
|
|
cc43299932 | ||
|
|
9086b96313 | ||
|
|
86b9d4a061 | ||
|
|
36fd9cb89a | ||
|
|
5aec913cd6 | ||
|
|
5da9cc3ce4 | ||
|
|
97e8c69345 | ||
|
|
a410ec8ed6 | ||
|
|
7e197e2315 | ||
|
|
6373bc726f | ||
|
|
1ccf3214f0 | ||
|
|
137701d10c | ||
|
|
37d39dba95 | ||
|
|
5d297a0d00 | ||
|
|
1aa6e80274 | ||
|
|
15778a5e4a | ||
|
|
48c41a7757 | ||
|
|
28ee7518f4 | ||
|
|
c35df7ef60 | ||
|
|
a8d5a0b62a | ||
|
|
64f55150b6 | ||
|
|
9ac38762eb | ||
|
|
72077302c0 | ||
|
|
f1a54ededf | ||
|
|
709844a459 | ||
|
|
032c68edf2 | ||
|
|
be8b6fba01 | ||
|
|
541fe55d6e | ||
|
|
28a62cf263 | ||
|
|
6e7584c89c | ||
|
|
49b03689a0 | ||
|
|
fdb00ce1f5 | ||
|
|
5d1d73a6b6 | ||
|
|
b292ee39ad | ||
|
|
c5f13022de | ||
|
|
47a3962118 | ||
|
|
b55d0d6bea | ||
|
|
8cb19859aa | ||
|
|
bafa6452ca | ||
|
|
3ab658ffa0 | ||
|
|
72b6a2c2a0 | ||
|
|
a479556795 | ||
|
|
8f03fa84ac | ||
|
|
990d82be6a | ||
|
|
b90db977a0 | ||
|
|
093452860e | ||
|
|
8ac9a2dc3c | ||
|
|
440eacbce4 | ||
|
|
725fa982c7 | ||
|
|
fe41fbc0b1 | ||
|
|
0de0a3f07f | ||
|
|
b0009fef42 | ||
|
|
8bfae8bf2f | ||
|
|
abe0bbbf51 | ||
|
|
c6e971ef1d | ||
|
|
2f0a07b7eb | ||
|
|
56593df3bd | ||
|
|
45735231aa | ||
|
|
eb1b914e84 | ||
|
|
2db0fd5c33 | ||
|
|
7e33da45c2 | ||
|
|
632938f354 | ||
|
|
540fe28dc0 | ||
|
|
b5671cc84e | ||
|
|
56963c2eb1 | ||
|
|
1099fcaa74 | ||
|
|
cea6e4814e | ||
|
|
4a6da6bde0 | ||
|
|
3da27f829c | ||
|
|
d45e26d70a | ||
|
|
35731f6cb9 | ||
|
|
4aa29ffafe | ||
|
|
f73df420c8 | ||
|
|
fd13828590 | ||
|
|
0160cbb7e3 | ||
|
|
e82bf4de14 | ||
|
|
4218ea0126 | ||
|
|
72ef660feb | ||
|
|
5a262f5d2f | ||
|
|
ca5a094332 | ||
|
|
8ca2a43835 | ||
|
|
03ce864782 | ||
|
|
25558d995d | ||
|
|
ccdcea7c20 | ||
|
|
f45c238ce2 | ||
|
|
8bec10bbeb | ||
|
|
7cd2e11395 | ||
|
|
bcc0e9afb4 | ||
|
|
5a5ae1eb31 | ||
|
|
334793cb81 | ||
|
|
171d820d60 | ||
|
|
7399c9b6cb | ||
|
|
ff4aaf163b | ||
|
|
72195a63f2 | ||
|
|
7654ad5192 | ||
|
|
c6443d414b | ||
|
|
0057de55bd | ||
|
|
af563a1794 | ||
|
|
a5a66c1f5c | ||
|
|
61ce560c80 | ||
|
|
a9605b576b | ||
|
|
bde691da06 | ||
|
|
c6eb62557e | ||
|
|
20649f06d9 | ||
|
|
f2632fb449 | ||
|
|
d4bf756993 | ||
|
|
076a7c8832 | ||
|
|
ee53d4dfb8 | ||
|
|
42668c6d5a | ||
|
|
5645dd4491 | ||
|
|
2bb3febc39 | ||
|
|
2fb9804b91 | ||
|
|
a09317aaae | ||
|
|
080621bcea | ||
|
|
37e9ef0434 | ||
|
|
df1b6ff941 | ||
|
|
52b8b7ca11 | ||
|
|
9ed0af96ca | ||
|
|
8d0bd0ca05 | ||
|
|
de83d5f20f | ||
|
|
54ff71b0ec | ||
|
|
a8347625ae | ||
|
|
305ae120c6 | ||
|
|
e1468b5396 | ||
|
|
10767bdaac | ||
|
|
484d6af4d9 | ||
|
|
8ba7153806 | ||
|
|
334bdb34f3 | ||
|
|
12b63c5583 | ||
|
|
3726792df2 | ||
|
|
cc252e32e6 | ||
|
|
0c58362b49 | ||
|
|
b00e018105 | ||
|
|
02464f9c09 | ||
|
|
67c1fb2cda | ||
|
|
e84843d83a | ||
|
|
20606c2880 | ||
|
|
3ba52a586e | ||
|
|
9eafcc9ca0 | ||
|
|
61ecf93785 | ||
|
|
917e748fc5 | ||
|
|
0d64b19ac4 | ||
|
|
9415709b81 | ||
|
|
003d8ee1a7 | ||
|
|
18dd01d3d2 | ||
|
|
bdb1e03e4e | ||
|
|
5662e5daa3 | ||
|
|
9306ce0783 | ||
|
|
f58ef0ace6 | ||
|
|
7de6ba4381 | ||
|
|
14fe2d9bc6 | ||
|
|
78573e30f1 | ||
|
|
ae014d2f24 | ||
|
|
726aff9f8d | ||
|
|
bb7c5cb39f | ||
|
|
90df9feb87 | ||
|
|
a86cde04bd | ||
|
|
afc03b2253 | ||
|
|
01cee41441 | ||
|
|
307669cf9f | ||
|
|
265f3be635 | ||
|
|
7742de1609 | ||
|
|
1778b78f0a | ||
|
|
36ced5252f | ||
|
|
00ca9197d5 | ||
|
|
adf6f71c05 | ||
|
|
fee5d11aec | ||
|
|
5772de187f | ||
|
|
4c033a655a | ||
|
|
e75043dd2b | ||
|
|
97a15f5059 | ||
|
|
0b882173eb | ||
|
|
ebaa9ea32e | ||
|
|
07faa1290a | ||
|
|
61b68fdd80 |
@@ -1,15 +1,22 @@
|
||||
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/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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('eslint');
|
||||
module.exports = createConfig('eslint');
|
||||
|
||||
19
.github/workflows/add-depr-ticket-to-depr-board.yml
vendored
Normal file
19
.github/workflows/add-depr-ticket-to-depr-board.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
# 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 }}
|
||||
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
20
.github/workflows/add-remove-label-on-comment.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
# 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
|
||||
|
||||
39
.github/workflows/ci.yml
vendored
Normal file
39
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
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@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
10
.github/workflows/commitlint.yml
vendored
Normal file
10
.github/workflows/commitlint.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# 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
|
||||
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
13
.github/workflows/lockfileversion-check.yml
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
#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
|
||||
45
.github/workflows/release.yml
vendored
Normal file
45
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
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@v4
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
- 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 }}
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
12
.github/workflows/self-assign-issue.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
# 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
5
.gitignore
vendored
@@ -5,3 +5,8 @@ dist
|
||||
node_modules
|
||||
temp
|
||||
src/i18n/transifex_input.json
|
||||
module.config.js
|
||||
.idea/
|
||||
|
||||
.vscode
|
||||
src/i18n/messages
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"branch": "master",
|
||||
"branches": [
|
||||
"master",
|
||||
{name: "alpha", prerelease: true}
|
||||
],
|
||||
"tagFormat": "v${version}",
|
||||
"verifyConditions": [
|
||||
"@semantic-release/npm",
|
||||
|
||||
17
.travis.yml
17
.travis.yml
@@ -1,17 +0,0 @@
|
||||
language: node_js
|
||||
node_js: 12
|
||||
install:
|
||||
- npm install
|
||||
script:
|
||||
- npm run lint
|
||||
- npm run test
|
||||
- npm run build
|
||||
after_success:
|
||||
- npx 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=
|
||||
@@ -1,8 +0,0 @@
|
||||
[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
|
||||
24
Makefile
24
Makefile
@@ -1,14 +1,9 @@
|
||||
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-react-intl
|
||||
transifex_temp = ./temp/babel-plugin-formatjs
|
||||
|
||||
build:
|
||||
rm -rf ./dist
|
||||
@@ -19,7 +14,7 @@ build:
|
||||
@rm -rf dist/__mocks__
|
||||
|
||||
requirements:
|
||||
npm install
|
||||
npm ci
|
||||
|
||||
i18n.extract:
|
||||
# Pulling display strings from .jsx files into .json files...
|
||||
@@ -37,21 +32,6 @@ 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...
|
||||
|
||||
178
README.rst
178
README.rst
@@ -1,11 +1,177 @@
|
||||
#########################
|
||||
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
|
||||
=========================
|
||||
|
||||
|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 } }``
|
||||
Please do not report security issues in public. Please email security@openedx.org.
|
||||
|
||||
.. |Build Status| image:: https://api.travis-ci.com/edx/frontend-component-header.svg?branch=master
|
||||
:target: https://travis-ci.com/edx/frontend-component-header
|
||||
@@ -18,4 +184,4 @@ This is the standard Open edX header for use in React applications. It has two e
|
||||
.. |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
|
||||
@@ -1,3 +1,3 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('babel-preserve-modules');
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-angular'],
|
||||
};
|
||||
BIN
docs/images/desktop_header.png
Normal file
BIN
docs/images/desktop_header.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
BIN
docs/images/mobile_main_menu.png
Normal file
BIN
docs/images/mobile_main_menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/images/mobile_user_menu.png
Normal file
BIN
docs/images/mobile_user_menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
111
docs/using_custom_header.rst
Normal file
111
docs/using_custom_header.rst
Normal file
@@ -0,0 +1,111 @@
|
||||
.. 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.
|
||||
@@ -4,8 +4,10 @@ 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 './index.scss';
|
||||
import Header from '../src/';
|
||||
import StudioHeader from '../src/studio-header/StudioHeader';
|
||||
|
||||
subscribe(APP_READY, () => {
|
||||
ReactDOM.render(
|
||||
@@ -31,7 +33,35 @@ subscribe(APP_READY, () => {
|
||||
}}>
|
||||
<Header />
|
||||
</AppContext.Provider>
|
||||
<h5 className="mt-2">Logged in state</h5>
|
||||
<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>
|
||||
</AppProvider>,
|
||||
document.getElementById('root'),
|
||||
);
|
||||
|
||||
@@ -1,2 +1,6 @@
|
||||
@import "~@edx/paragon/scss/core/core.scss";
|
||||
@import '../src/index.scss';
|
||||
@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";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('jest', {
|
||||
setupFiles: [
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
});
|
||||
|
||||
35816
package-lock.json
generated
35816
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
70
package.json
70
package.json
@@ -8,7 +8,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "make build",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"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",
|
||||
@@ -19,52 +19,58 @@
|
||||
],
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint",
|
||||
"commit-msg": "commitlint -e $GIT_PARAMS"
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/edx/frontend-component-header.git"
|
||||
"url": "git+https://github.com/openedx/frontend-component-header.git"
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"bugs": {
|
||||
"url": "https://github.com/edx/frontend-component-header/issues"
|
||||
"url": "https://github.com/openedx/frontend-component-header/issues"
|
||||
},
|
||||
"homepage": "https://github.com/edx/frontend-component-header#readme",
|
||||
"homepage": "https://github.com/openedx/frontend-component-header#readme",
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "8.2.0",
|
||||
"@commitlint/config-angular": "8.2.0",
|
||||
"@commitlint/prompt": "8.2.0",
|
||||
"@commitlint/prompt-cli": "8.2.0",
|
||||
"@edx/frontend-build": "2.0.3",
|
||||
"@edx/frontend-platform": "1.1.4",
|
||||
"@edx/paragon": "7.1.4",
|
||||
"codecov": "3.6.1",
|
||||
"enzyme": "3.10.0",
|
||||
"enzyme-adapter-react-16": "1.14.0",
|
||||
"husky": "3.0.8",
|
||||
"prop-types": "15.7.2",
|
||||
"react": "16.9.0",
|
||||
"react-dom": "16.9.0",
|
||||
"react-redux": "^7.1.1",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"react-test-renderer": "16.9.0",
|
||||
"reactifex": "1.1.1",
|
||||
"redux": "^4.0.4",
|
||||
"redux-saga": "^1.1.1"
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/browserslist-config": "^1.1.1",
|
||||
"@edx/frontend-platform": "8.1.0",
|
||||
"@edx/reactifex": "^2.1.1",
|
||||
"@openedx/frontend-build": "14.0.10",
|
||||
"@openedx/paragon": "22.7.0",
|
||||
"@testing-library/dom": "10.3.2",
|
||||
"@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.25.1",
|
||||
"react-test-renderer": "17.0.2",
|
||||
"redux": "4.2.1",
|
||||
"redux-saga": "1.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "6.6.0",
|
||||
"@fortawesome/free-brands-svg-icons": "6.6.0",
|
||||
"@fortawesome/free-regular-svg-icons": "6.6.0",
|
||||
"@fortawesome/free-solid-svg-icons": "6.6.0",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"axios-mock-adapter": "1.22.0",
|
||||
"babel-polyfill": "6.26.0",
|
||||
"react-responsive": "8.0.1",
|
||||
"react-transition-group": "4.3.0"
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"react-responsive": "8.2.0",
|
||||
"react-transition-group": "4.4.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edx/frontend-platform": "^1.1.4",
|
||||
"@edx/paragon": "^7.0.0",
|
||||
"@edx/frontend-platform": "^7.0.0 || ^8.0.0",
|
||||
"@openedx/paragon": ">= 21.5.7 < 23.0.0",
|
||||
"prop-types": "^15.5.10",
|
||||
"react": "^16.9.0",
|
||||
"react-dom": "^16.9.0"
|
||||
"react": "^16.9.0 || ^17.0.0",
|
||||
"react-dom": "^16.9.0 || ^17.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base"
|
||||
]
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -3,16 +3,16 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import { AvatarIcon } from './Icons';
|
||||
|
||||
function Avatar({
|
||||
const Avatar = ({
|
||||
size,
|
||||
src,
|
||||
alt,
|
||||
className,
|
||||
}) {
|
||||
}) => {
|
||||
const avatar = src ? (
|
||||
<img className="d-block w-100 h-100" src={src} alt={alt} />
|
||||
) : (
|
||||
<AvatarIcon className="text-muted" style={{ width: size, height: size }} role="img" aria-hidden focusable="false" />
|
||||
<AvatarIcon style={{ width: size, height: size }} role="img" aria-hidden focusable="false" />
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -23,7 +23,7 @@ function Avatar({
|
||||
{avatar}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
Avatar.propTypes = {
|
||||
src: PropTypes.string,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
// Local Components
|
||||
import { Menu, MenuTrigger, MenuContent } from './Menu';
|
||||
@@ -18,29 +19,39 @@ class DesktopHeader extends React.Component {
|
||||
super(props);
|
||||
}
|
||||
|
||||
renderMainMenu() {
|
||||
const { mainMenu } = this.props;
|
||||
|
||||
renderMenu(menu) {
|
||||
// Nodes are accepted as a prop
|
||||
if (!Array.isArray(mainMenu)) return mainMenu;
|
||||
if (!Array.isArray(menu)) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
return mainMenu.map((menuItem) => {
|
||||
return menu.map((menuItem) => {
|
||||
const {
|
||||
type,
|
||||
href,
|
||||
content,
|
||||
submenuContent,
|
||||
disabled,
|
||||
isActive,
|
||||
onClick,
|
||||
} = menuItem;
|
||||
|
||||
if (type === 'item') {
|
||||
return (
|
||||
<a key={`${type}-${content}`} className="nav-link" href={href}>{content}</a>
|
||||
<a
|
||||
key={`${type}-${content}`}
|
||||
className={`nav-link${disabled ? ' disabled' : ''}${isActive ? ' active' : ''}`}
|
||||
href={href}
|
||||
onClick={onClick || null}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu key={`${type}-${content}`} tag="div" className="nav-item" respondToPointerEvents>
|
||||
<MenuTrigger tag="a" className="nav-link d-inline-flex align-items-center" href={href}>
|
||||
<MenuTrigger onClick={onClick || null} tag="a" className="nav-link d-inline-flex align-items-center" href={href}>
|
||||
{content} <CaretIcon role="img" aria-hidden focusable="false" />
|
||||
</MenuTrigger>
|
||||
<MenuContent className="pin-left pin-right shadow py-2">
|
||||
@@ -51,6 +62,16 @@ 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,
|
||||
@@ -64,14 +85,30 @@ class DesktopHeader extends React.Component {
|
||||
<MenuTrigger
|
||||
tag="button"
|
||||
aria-label={intl.formatMessage(messages['header.label.account.menu.for'], { username })}
|
||||
className="btn btn-light d-inline-flex align-items-center pl-2 pr-3"
|
||||
className="btn btn-outline-primary 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(({ type, href, content }) => (
|
||||
<a className={`dropdown-${type}`} key={`${type}-${content}`} href={href}>{content}</a>
|
||||
{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, onClick,
|
||||
}) => (
|
||||
<a
|
||||
className={`dropdown-${type}${isActive ? ' active' : ''}${disabled ? ' disabled' : ''}`}
|
||||
key={`${type}-${content}`}
|
||||
href={href}
|
||||
onClick={onClick || null}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
))}
|
||||
{index < userMenu.length - 1 && <div className="dropdown-divider" role="separator" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
@@ -101,12 +138,14 @@ 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">
|
||||
<div className="container-fluid">
|
||||
<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="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"
|
||||
@@ -117,7 +156,13 @@ 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.renderUserMenu() : this.renderLoggedOutItems()}
|
||||
{loggedIn
|
||||
? (
|
||||
<>
|
||||
{this.renderSecondaryMenu()}
|
||||
{this.renderUserMenu()}
|
||||
</>
|
||||
) : this.renderLoggedOutItems()}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,10 +176,19 @@ DesktopHeader.propTypes = {
|
||||
PropTypes.node,
|
||||
PropTypes.array,
|
||||
]),
|
||||
secondaryMenu: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.array,
|
||||
]),
|
||||
userMenu: PropTypes.arrayOf(PropTypes.shape({
|
||||
type: PropTypes.oneOf(['item', 'menu']),
|
||||
href: PropTypes.string,
|
||||
content: PropTypes.string,
|
||||
heading: PropTypes.string,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
type: PropTypes.oneOf(['item', 'menu']),
|
||||
href: PropTypes.string,
|
||||
content: PropTypes.string,
|
||||
isActive: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
})),
|
||||
})),
|
||||
loggedOutItems: PropTypes.arrayOf(PropTypes.shape({
|
||||
type: PropTypes.oneOf(['item', 'menu']),
|
||||
@@ -154,6 +208,7 @@ DesktopHeader.propTypes = {
|
||||
|
||||
DesktopHeader.defaultProps = {
|
||||
mainMenu: [],
|
||||
secondaryMenu: [],
|
||||
userMenu: [],
|
||||
loggedOutItems: [],
|
||||
logo: null,
|
||||
|
||||
137
src/Header.jsx
137
src/Header.jsx
@@ -2,13 +2,18 @@ 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 { ensureConfig } from '@edx/frontend-platform/config';
|
||||
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 LogoSVG from './logo.svg';
|
||||
|
||||
import messages from './Header.messages';
|
||||
|
||||
ensureConfig([
|
||||
@@ -16,41 +21,78 @@ ensureConfig([
|
||||
'LOGOUT_URL',
|
||||
'LOGIN_URL',
|
||||
'SITE_NAME',
|
||||
'LOGO_URL',
|
||||
'ORDER_HISTORY_URL',
|
||||
], 'Header component');
|
||||
|
||||
function Header({ intl }) {
|
||||
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 mainMenu = [
|
||||
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 userMenu = authenticatedUser === null ? [] : [
|
||||
{
|
||||
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 mainMenu = mainMenuItems || defaultMainMenu;
|
||||
const secondaryMenu = secondaryMenuItems || [];
|
||||
const userMenu = authenticatedUser === null ? [] : userMenuItems || defaultUserMenu;
|
||||
|
||||
const loggedOutItems = [
|
||||
{
|
||||
@@ -66,32 +108,55 @@ function Header({ intl }) {
|
||||
];
|
||||
|
||||
const props = {
|
||||
logo: LogoSVG,
|
||||
logo: config.LOGO_URL,
|
||||
logoAltText: config.SITE_NAME,
|
||||
siteName: 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,
|
||||
userMenu,
|
||||
loggedOutItems,
|
||||
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 (
|
||||
<React.Fragment>
|
||||
<Responsive maxWidth={768}>
|
||||
<>
|
||||
<Responsive maxWidth={769}>
|
||||
<MobileHeader {...props} />
|
||||
</Responsive>
|
||||
<Responsive minWidth={769}>
|
||||
<DesktopHeader {...props} />
|
||||
</Responsive>
|
||||
</React.Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
@@ -56,6 +56,16 @@ 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',
|
||||
@@ -67,7 +77,7 @@ const messages = defineMessages({
|
||||
description: 'The aria label for the account menu trigger',
|
||||
},
|
||||
'header.label.account.menu.for': {
|
||||
id: 'header.label.account.menu',
|
||||
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',
|
||||
},
|
||||
@@ -91,6 +101,16 @@ 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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react/prop-types */
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import TestRenderer from 'react-test-renderer';
|
||||
@@ -6,26 +7,31 @@ 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 component = (
|
||||
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<AppContext.Provider value={{
|
||||
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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Header />
|
||||
</AppContext.Provider>
|
||||
</IntlProvider>
|
||||
</ResponsiveContext.Provider>
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -33,29 +39,22 @@ describe('<Header />', () => {
|
||||
});
|
||||
|
||||
it('renders correctly for authenticated desktop', () => {
|
||||
const component = (
|
||||
<ResponsiveContext.Provider value={{ width: 1280 }}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<AppContext.Provider value={{
|
||||
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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Header />
|
||||
</AppContext.Provider>
|
||||
</IntlProvider>
|
||||
</ResponsiveContext.Provider>
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -63,24 +62,17 @@ describe('<Header />', () => {
|
||||
});
|
||||
|
||||
it('renders correctly for anonymous mobile', () => {
|
||||
const component = (
|
||||
<ResponsiveContext.Provider value={{ width: 500 }}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<AppContext.Provider value={{
|
||||
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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Header />
|
||||
</AppContext.Provider>
|
||||
</IntlProvider>
|
||||
</ResponsiveContext.Provider>
|
||||
);
|
||||
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);
|
||||
|
||||
@@ -88,29 +80,22 @@ describe('<Header />', () => {
|
||||
});
|
||||
|
||||
it('renders correctly for authenticated mobile', () => {
|
||||
const component = (
|
||||
<ResponsiveContext.Provider value={{ width: 500 }}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<AppContext.Provider value={{
|
||||
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,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Header />
|
||||
</AppContext.Provider>
|
||||
</IntlProvider>
|
||||
</ResponsiveContext.Provider>
|
||||
);
|
||||
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);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
23
src/Logo.jsx
23
src/Logo.jsx
@@ -1,30 +1,25 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
|
||||
function Logo({ src, alt, ...attributes }) {
|
||||
return (
|
||||
<img src={src} alt={alt} {...attributes} />
|
||||
);
|
||||
}
|
||||
const Logo = ({ src, alt, ...attributes }) => (
|
||||
<img src={src} alt={alt} {...attributes} />
|
||||
);
|
||||
|
||||
Logo.propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
function LinkedLogo({
|
||||
const LinkedLogo = ({
|
||||
href,
|
||||
src,
|
||||
alt,
|
||||
...attributes
|
||||
}) {
|
||||
return (
|
||||
<a href={href} {...attributes}>
|
||||
<img className="d-block" src={src} alt={alt} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
}) => (
|
||||
<a href={href} {...attributes}>
|
||||
<img className="d-block" src={src} alt={alt} />
|
||||
</a>
|
||||
);
|
||||
|
||||
LinkedLogo.propTypes = {
|
||||
href: PropTypes.string.isRequired,
|
||||
|
||||
@@ -2,13 +2,10 @@ import React from 'react';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
|
||||
function MenuTrigger({ tag, className, ...attributes }) {
|
||||
return React.createElement(tag, {
|
||||
className: `menu-trigger ${className}`,
|
||||
...attributes,
|
||||
});
|
||||
}
|
||||
const MenuTrigger = ({ tag, className, ...attributes }) => React.createElement(tag, {
|
||||
className: `menu-trigger ${className}`,
|
||||
...attributes,
|
||||
});
|
||||
MenuTrigger.propTypes = {
|
||||
tag: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
@@ -17,15 +14,13 @@ MenuTrigger.defaultProps = {
|
||||
tag: 'div',
|
||||
className: null,
|
||||
};
|
||||
const MenuTriggerType = <MenuTrigger />.type;
|
||||
const MenuTriggerComp = <MenuTrigger />;
|
||||
const MenuTriggerType = MenuTriggerComp.type;
|
||||
|
||||
|
||||
function MenuContent({ tag, className, ...attributes }) {
|
||||
return React.createElement(tag, {
|
||||
className: ['menu-content', className].join(' '),
|
||||
...attributes,
|
||||
});
|
||||
}
|
||||
const MenuContent = ({ tag, className, ...attributes }) => React.createElement(tag, {
|
||||
className: ['menu-content', className].join(' '),
|
||||
...attributes,
|
||||
});
|
||||
MenuContent.propTypes = {
|
||||
tag: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
@@ -35,6 +30,17 @@ 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) {
|
||||
@@ -66,10 +72,14 @@ 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();
|
||||
}
|
||||
@@ -77,7 +87,9 @@ 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();
|
||||
@@ -89,7 +101,9 @@ class Menu extends React.Component {
|
||||
}
|
||||
|
||||
onKeyDown(e) {
|
||||
if (!this.state.expanded) return;
|
||||
if (!this.state.expanded) {
|
||||
return;
|
||||
}
|
||||
switch (e.key) {
|
||||
case 'Escape': {
|
||||
e.preventDefault();
|
||||
@@ -131,16 +145,19 @@ 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() {
|
||||
@@ -151,7 +168,7 @@ class Menu extends React.Component {
|
||||
// Any extra props are attributes for the menu
|
||||
const attributes = {};
|
||||
Object.keys(this.props)
|
||||
.filter(property => Menu.propTypes[property] === undefined)
|
||||
.filter(property => menuPropTypes[property] === undefined)
|
||||
.forEach((property) => {
|
||||
attributes[property] = this.props[property];
|
||||
});
|
||||
@@ -173,7 +190,9 @@ 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
|
||||
@@ -182,7 +201,9 @@ 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);
|
||||
@@ -240,18 +261,7 @@ class Menu extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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.propTypes = menuPropTypes;
|
||||
Menu.defaultProps = {
|
||||
tag: 'div',
|
||||
className: null,
|
||||
@@ -263,5 +273,4 @@ Menu.defaultProps = {
|
||||
transitionClassName: 'menu-content',
|
||||
};
|
||||
|
||||
|
||||
export { Menu, MenuTrigger, MenuContent };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
// Local Components
|
||||
import { Menu, MenuTrigger, MenuContent } from './Menu';
|
||||
@@ -18,23 +19,31 @@ class MobileHeader extends React.Component {
|
||||
super(props);
|
||||
}
|
||||
|
||||
renderMainMenu() {
|
||||
const { mainMenu } = this.props;
|
||||
|
||||
renderMenu(menu) {
|
||||
// Nodes are accepted as a prop
|
||||
if (!Array.isArray(mainMenu)) return mainMenu;
|
||||
if (!Array.isArray(menu)) {
|
||||
return menu;
|
||||
}
|
||||
|
||||
return mainMenu.map((menuItem) => {
|
||||
return menu.map((menuItem) => {
|
||||
const {
|
||||
type,
|
||||
href,
|
||||
content,
|
||||
submenuContent,
|
||||
disabled,
|
||||
isActive,
|
||||
onClick,
|
||||
} = menuItem;
|
||||
|
||||
if (type === 'item') {
|
||||
return (
|
||||
<a key={`${type}-${content}`} className="nav-link" href={href}>
|
||||
<a
|
||||
key={`${type}-${content}`}
|
||||
className={`nav-link${disabled ? ' disabled' : ''}${isActive ? ' active' : ''}`}
|
||||
href={href}
|
||||
onClick={onClick || null}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
);
|
||||
@@ -42,7 +51,7 @@ class MobileHeader extends React.Component {
|
||||
|
||||
return (
|
||||
<Menu key={`${type}-${content}`} tag="div" className="nav-item">
|
||||
<MenuTrigger tag="a" role="button" tabIndex="0" className="nav-link">
|
||||
<MenuTrigger onClick={onClick || null} tag="a" role="button" tabIndex="0" className="nav-link">
|
||||
{content}
|
||||
</MenuTrigger>
|
||||
<MenuContent className="position-static pin-left pin-right py-2">
|
||||
@@ -53,13 +62,33 @@ 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(({ type, href, content }) => (
|
||||
<li className="nav-item" key={`${type}-${content}`}>
|
||||
<a className="nav-link" href={href}>{content}</a>
|
||||
</li>
|
||||
return userMenu.map((group) => (
|
||||
group.items.map(({
|
||||
type, content, href, disabled, isActive, onClick,
|
||||
}) => (
|
||||
<li className="nav-item" key={`${type}-${content}`}>
|
||||
<a
|
||||
className={`nav-link${isActive ? ' active' : ''}${disabled ? ' disabled' : ''}`}
|
||||
href={href}
|
||||
onClick={onClick || null}
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
));
|
||||
}
|
||||
|
||||
@@ -89,17 +118,22 @@ 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}`}
|
||||
>
|
||||
<div className="w-100 d-flex justify-content-start">
|
||||
{mainMenu.length > 0 ?
|
||||
<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">
|
||||
|
||||
<Menu className="position-static">
|
||||
<MenuTrigger
|
||||
tag="button"
|
||||
@@ -115,27 +149,31 @@ class MobileHeader extends React.Component {
|
||||
className="nav flex-column pin-left pin-right border-top shadow py-2"
|
||||
>
|
||||
{this.renderMainMenu()}
|
||||
{this.renderSecondaryMenu()}
|
||||
</MenuContent>
|
||||
</Menu> : null }
|
||||
</div>
|
||||
<div className="w-100 d-flex justify-content-center">
|
||||
</Menu>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`w-100 d-flex ${logoClasses}`}>
|
||||
{ logoDestination === null ? <Logo className="logo" src={logo} alt={logoAltText} /> : <LinkedLogo className="logo" {...logoProps} itemType="http://schema.org/Organization" />}
|
||||
</div>
|
||||
<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>
|
||||
{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}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -146,11 +184,19 @@ MobileHeader.propTypes = {
|
||||
PropTypes.node,
|
||||
PropTypes.array,
|
||||
]),
|
||||
|
||||
secondaryMenu: PropTypes.oneOfType([
|
||||
PropTypes.node,
|
||||
PropTypes.array,
|
||||
]),
|
||||
userMenu: PropTypes.arrayOf(PropTypes.shape({
|
||||
type: PropTypes.oneOf(['item', 'menu']),
|
||||
href: PropTypes.string,
|
||||
content: PropTypes.string,
|
||||
heading: PropTypes.string,
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
type: PropTypes.oneOf(['item', 'menu']),
|
||||
href: PropTypes.string,
|
||||
content: PropTypes.string,
|
||||
isActive: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
})),
|
||||
})),
|
||||
loggedOutItems: PropTypes.arrayOf(PropTypes.shape({
|
||||
type: PropTypes.oneOf(['item', 'menu']),
|
||||
@@ -171,6 +217,7 @@ MobileHeader.propTypes = {
|
||||
|
||||
MobileHeader.defaultProps = {
|
||||
mainMenu: [],
|
||||
secondaryMenu: [],
|
||||
userMenu: [],
|
||||
loggedOutItems: [],
|
||||
logo: null,
|
||||
|
||||
@@ -4,8 +4,14 @@ 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"
|
||||
className="container-fluid null"
|
||||
>
|
||||
<div
|
||||
className="nav-container position-relative d-flex align-items-center"
|
||||
@@ -17,7 +23,7 @@ exports[`<Header /> renders correctly for anonymous desktop 1`] = `
|
||||
<img
|
||||
alt="edX"
|
||||
className="d-block"
|
||||
src="icon/mock/path"
|
||||
src="https://edx-cdn.org/v3/default/logo.svg"
|
||||
/>
|
||||
</a>
|
||||
<nav
|
||||
@@ -27,6 +33,7 @@ exports[`<Header /> renders correctly for anonymous desktop 1`] = `
|
||||
<a
|
||||
className="nav-link"
|
||||
href="http://localhost:18000/dashboard"
|
||||
onClick={null}
|
||||
>
|
||||
Courses
|
||||
</a>
|
||||
@@ -58,6 +65,12 @@ exports[`<Header /> renders correctly for anonymous mobile 1`] = `
|
||||
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"
|
||||
>
|
||||
@@ -81,7 +94,7 @@ exports[`<Header /> renders correctly for anonymous mobile 1`] = `
|
||||
height="24px"
|
||||
role="img"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5rem",
|
||||
"width": "1.5rem",
|
||||
}
|
||||
@@ -126,7 +139,7 @@ exports[`<Header /> renders correctly for anonymous mobile 1`] = `
|
||||
<img
|
||||
alt="edX"
|
||||
className="d-block"
|
||||
src="icon/mock/path"
|
||||
src="https://edx-cdn.org/v3/default/logo.svg"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
@@ -151,7 +164,7 @@ exports[`<Header /> renders correctly for anonymous mobile 1`] = `
|
||||
<span
|
||||
className="avatar overflow-hidden d-inline-flex rounded-circle null"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5rem",
|
||||
"width": "1.5rem",
|
||||
}
|
||||
@@ -159,12 +172,11 @@ exports[`<Header /> renders correctly for anonymous mobile 1`] = `
|
||||
>
|
||||
<svg
|
||||
aria-hidden={true}
|
||||
className="text-muted"
|
||||
focusable="false"
|
||||
height="24px"
|
||||
role="img"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5rem",
|
||||
"width": "1.5rem",
|
||||
}
|
||||
@@ -189,8 +201,14 @@ 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"
|
||||
className="container-fluid null"
|
||||
>
|
||||
<div
|
||||
className="nav-container position-relative d-flex align-items-center"
|
||||
@@ -202,7 +220,7 @@ exports[`<Header /> renders correctly for authenticated desktop 1`] = `
|
||||
<img
|
||||
alt="edX"
|
||||
className="d-block"
|
||||
src="icon/mock/path"
|
||||
src="https://edx-cdn.org/v3/default/logo.svg"
|
||||
/>
|
||||
</a>
|
||||
<nav
|
||||
@@ -212,6 +230,7 @@ exports[`<Header /> renders correctly for authenticated desktop 1`] = `
|
||||
<a
|
||||
className="nav-link"
|
||||
href="http://localhost:18000/dashboard"
|
||||
onClick={null}
|
||||
>
|
||||
Courses
|
||||
</a>
|
||||
@@ -230,13 +249,13 @@ exports[`<Header /> renders correctly for authenticated desktop 1`] = `
|
||||
aria-expanded={false}
|
||||
aria-haspopup="menu"
|
||||
aria-label="Account menu for edX"
|
||||
className="menu-trigger btn btn-light d-inline-flex align-items-center pl-2 pr-3"
|
||||
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",
|
||||
}
|
||||
@@ -244,12 +263,11 @@ exports[`<Header /> renders correctly for authenticated desktop 1`] = `
|
||||
>
|
||||
<svg
|
||||
aria-hidden={true}
|
||||
className="text-muted"
|
||||
focusable="false"
|
||||
height="24px"
|
||||
role="img"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5em",
|
||||
"width": "1.5em",
|
||||
}
|
||||
@@ -294,6 +312,12 @@ exports[`<Header /> renders correctly for authenticated mobile 1`] = `
|
||||
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"
|
||||
>
|
||||
@@ -317,7 +341,7 @@ exports[`<Header /> renders correctly for authenticated mobile 1`] = `
|
||||
height="24px"
|
||||
role="img"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5rem",
|
||||
"width": "1.5rem",
|
||||
}
|
||||
@@ -362,7 +386,7 @@ exports[`<Header /> renders correctly for authenticated mobile 1`] = `
|
||||
<img
|
||||
alt="edX"
|
||||
className="d-block"
|
||||
src="icon/mock/path"
|
||||
src="https://edx-cdn.org/v3/default/logo.svg"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
@@ -387,7 +411,7 @@ exports[`<Header /> renders correctly for authenticated mobile 1`] = `
|
||||
<span
|
||||
className="avatar overflow-hidden d-inline-flex rounded-circle null"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5rem",
|
||||
"width": "1.5rem",
|
||||
}
|
||||
@@ -395,12 +419,11 @@ exports[`<Header /> renders correctly for authenticated mobile 1`] = `
|
||||
>
|
||||
<svg
|
||||
aria-hidden={true}
|
||||
className="text-muted"
|
||||
focusable="false"
|
||||
height="24px"
|
||||
role="img"
|
||||
style={
|
||||
Object {
|
||||
{
|
||||
"height": "1.5rem",
|
||||
"width": "1.5rem",
|
||||
}
|
||||
|
||||
16
src/generic/messages.js
Normal file
16
src/generic/messages.js
Normal file
@@ -0,0 +1,16 @@
|
||||
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;
|
||||
@@ -1,18 +1 @@
|
||||
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;
|
||||
export default {};
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
{
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
{
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
{
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
{
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
{
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
{
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import Header from './Header';
|
||||
import LearningHeader from './learning-header/LearningHeader';
|
||||
import messages from './i18n/index';
|
||||
import StudioHeader from './studio-header';
|
||||
|
||||
export { messages };
|
||||
export { LearningHeader, messages, StudioHeader };
|
||||
|
||||
export default Header;
|
||||
|
||||
@@ -3,6 +3,7 @@ $blue: #007db8;
|
||||
$white: #fff;
|
||||
|
||||
@import './Menu/menu.scss';
|
||||
@import './studio-header/StudioHeader.scss';
|
||||
|
||||
.dropdown-item a {
|
||||
text-decoration: none;
|
||||
@@ -25,6 +26,30 @@ $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;
|
||||
@@ -32,6 +57,8 @@ $white: #fff;
|
||||
}
|
||||
|
||||
.site-header-mobile {
|
||||
height: 3rem;
|
||||
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
@@ -43,7 +70,6 @@ $white: #fff;
|
||||
|
||||
|
||||
.site-header-desktop {
|
||||
height: 3.75rem;
|
||||
box-shadow: 0 1px 0 0 rgba(0,0,0,.1);
|
||||
background: $white;
|
||||
.nav-link {
|
||||
@@ -62,6 +88,15 @@ $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;
|
||||
|
||||
32
src/learning-header/AnonymousUserMenu.jsx
Normal file
32
src/learning-header/AnonymousUserMenu.jsx
Normal file
@@ -0,0 +1,32 @@
|
||||
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);
|
||||
56
src/learning-header/AuthenticatedUserDropdown.jsx
Normal file
56
src/learning-header/AuthenticatedUserDropdown.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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);
|
||||
79
src/learning-header/LearningHeader.jsx
Normal file
79
src/learning-header/LearningHeader.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
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);
|
||||
29
src/learning-header/LearningHeader.test.jsx
Normal file
29
src/learning-header/LearningHeader.test.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
41
src/learning-header/messages.js
Normal file
41
src/learning-header/messages.js
Normal file
@@ -0,0 +1,41 @@
|
||||
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;
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 17 KiB |
110
src/setupTest.js
110
src/setupTest.js
@@ -1,14 +1,25 @@
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
|
||||
import Enzyme from 'enzyme';
|
||||
import Adapter from 'enzyme-adapter-react-16';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import '@testing-library/jest-dom';
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
import 'babel-polyfill';
|
||||
|
||||
Enzyme.configure({ adapter: new Adapter() });
|
||||
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';
|
||||
|
||||
// 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_TOKEN_API_PATH = '/csrf/api/v1/token';
|
||||
@@ -16,10 +27,99 @@ 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/login';
|
||||
process.env.LOGOUT_URL = 'http://localhost:18000/logout';
|
||||
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,
|
||||
};
|
||||
|
||||
24
src/studio-header/BrandNav.jsx
Normal file
24
src/studio-header/BrandNav.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
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;
|
||||
54
src/studio-header/CourseLockUp.jsx
Normal file
54
src/studio-header/CourseLockUp.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
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);
|
||||
179
src/studio-header/HeaderBody.jsx
Normal file
179
src/studio-header/HeaderBody.jsx
Normal file
@@ -0,0 +1,179 @@
|
||||
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;
|
||||
73
src/studio-header/MobileHeader.jsx
Normal file
73
src/studio-header/MobileHeader.jsx
Normal file
@@ -0,0 +1,73 @@
|
||||
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;
|
||||
51
src/studio-header/MobileMenu.jsx
Normal file
51
src/studio-header/MobileMenu.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
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;
|
||||
40
src/studio-header/NavDropdownMenu.jsx
Normal file
40
src/studio-header/NavDropdownMenu.jsx
Normal file
@@ -0,0 +1,40 @@
|
||||
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;
|
||||
78
src/studio-header/StudioHeader.jsx
Normal file
78
src/studio-header/StudioHeader.jsx
Normal file
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
49
src/studio-header/StudioHeader.scss
Normal file
49
src/studio-header/StudioHeader.scss
Normal file
@@ -0,0 +1,49 @@
|
||||
$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;
|
||||
}
|
||||
}
|
||||
}
|
||||
212
src/studio-header/StudioHeader.test.jsx
Normal file
212
src/studio-header/StudioHeader.test.jsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/* 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
69
src/studio-header/UserMenu.jsx
Normal file
69
src/studio-header/UserMenu.jsx
Normal file
@@ -0,0 +1,69 @@
|
||||
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);
|
||||
3
src/studio-header/index.js
Normal file
3
src/studio-header/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import StudioHeader from './StudioHeader';
|
||||
|
||||
export default StudioHeader;
|
||||
61
src/studio-header/messages.js
Normal file
61
src/studio-header/messages.js
Normal file
@@ -0,0 +1,61 @@
|
||||
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;
|
||||
36
src/studio-header/utils.js
Normal file
36
src/studio-header/utils.js
Normal file
@@ -0,0 +1,36 @@
|
||||
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;
|
||||
6
src/test-utils.js
Normal file
6
src/test-utils.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const executeThunk = async (thunk, dispatch, getState) => {
|
||||
await thunk(dispatch, getState);
|
||||
await new Promise(setImmediate);
|
||||
};
|
||||
|
||||
export default executeThunk;
|
||||
@@ -1,5 +1,5 @@
|
||||
const path = require('path');
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
|
||||
module.exports = createConfig('webpack-dev', {
|
||||
entry: path.resolve(__dirname, 'example'),
|
||||
@@ -7,4 +7,9 @@ module.exports = createConfig('webpack-dev', {
|
||||
path: path.resolve(__dirname, 'example/dist'),
|
||||
publicPath: '/',
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@edx/frontend-component-header': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user