├── .env.development ├── .env.test ├── .eslintignore ├── .eslintrc.js ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── add-depr-ticket-to-depr-board.yml │ ├── add-remove-label-on-comment.yml │ ├── ci.yml │ ├── commitlint.yml │ ├── lockfileversion-check.yml │ ├── manual-publish.yml │ ├── npm-deprecate.yml │ ├── release.yml │ ├── self-assign-issue.yml │ └── update-browserslist-db.yml ├── .gitignore ├── .npmignore ├── .nvmrc ├── .releaserc ├── LICENSE ├── Makefile ├── README.md ├── __mocks__ └── universal-cookie.js ├── babel.config.js ├── catalog-info.yaml ├── codecov.yml ├── docs ├── addTagsPlugin.js ├── auth-API.md ├── decisions │ ├── 0001-record-architecture-decisions.rst │ ├── 0002-frontend-base-design-goals.rst │ ├── 0003-consolidation-into-frontend-platform.rst │ ├── 0004-axios-caching-implementation.rst │ ├── 0005-token-null-after-successful-refresh.rst │ ├── 0006-middleware-support-for-http-clients.rst │ └── 0007-javascript-file-configuration.rst ├── how_tos │ ├── assets │ │ └── paragon-theme-loader.png │ ├── automatic-case-conversion.rst │ ├── caching.rst │ ├── i18n.rst │ └── theming.md ├── removeExport.js └── template │ └── edx │ ├── README.md │ ├── publish.js │ ├── static │ ├── fonts │ │ ├── OpenSans-Bold-webfont.eot │ │ ├── OpenSans-Bold-webfont.svg │ │ ├── OpenSans-Bold-webfont.woff │ │ ├── OpenSans-BoldItalic-webfont.eot │ │ ├── OpenSans-BoldItalic-webfont.svg │ │ ├── OpenSans-BoldItalic-webfont.woff │ │ ├── OpenSans-Italic-webfont.eot │ │ ├── OpenSans-Italic-webfont.svg │ │ ├── OpenSans-Italic-webfont.woff │ │ ├── OpenSans-Light-webfont.eot │ │ ├── OpenSans-Light-webfont.svg │ │ ├── OpenSans-Light-webfont.woff │ │ ├── OpenSans-LightItalic-webfont.eot │ │ ├── OpenSans-LightItalic-webfont.svg │ │ ├── OpenSans-LightItalic-webfont.woff │ │ ├── OpenSans-Regular-webfont.eot │ │ ├── OpenSans-Regular-webfont.svg │ │ └── OpenSans-Regular-webfont.woff │ ├── scripts │ │ ├── linenumber.js │ │ └── prettify │ │ │ ├── Apache-License-2.0.txt │ │ │ ├── lang-css.js │ │ │ └── prettify.js │ └── styles │ │ ├── jsdoc-default.css │ │ ├── prettify-jsdoc.css │ │ └── prettify-tomorrow.css │ └── tmpl │ ├── augments.tmpl │ ├── container.tmpl │ ├── details.tmpl │ ├── example.tmpl │ ├── examples.tmpl │ ├── exceptions.tmpl │ ├── layout.tmpl │ ├── mainpage.tmpl │ ├── members.tmpl │ ├── method.tmpl │ ├── modifies.tmpl │ ├── params.tmpl │ ├── properties.tmpl │ ├── returns.tmpl │ ├── source.tmpl │ ├── tutorial.tmpl │ └── type.tmpl ├── env.config.js ├── example ├── AuthenticatedPage.jsx ├── ExamplePage.jsx ├── index.jsx ├── index.scss ├── messages.js └── src │ └── i18n │ ├── README.md │ └── messages │ ├── frontend-app-sample │ ├── ar.json │ ├── eo.json │ └── es_419.json │ ├── frontend-component-emptylangs │ └── ar.json │ ├── frontend-component-nolangs │ └── .gitignore │ └── frontend-component-singlelang │ └── ar.json ├── jest.config.js ├── jsdoc.json ├── openedx.yaml ├── package-lock.json ├── package.json ├── public └── index.html ├── renovate.json ├── service-interface.png ├── src ├── analytics │ ├── MockAnalyticsService.js │ ├── SegmentAnalyticsService.js │ ├── index.js │ ├── interface.js │ └── interface.test.js ├── auth │ ├── AxiosCsrfTokenService.js │ ├── AxiosJwtAuthService.js │ ├── AxiosJwtAuthService.test.jsx │ ├── AxiosJwtTokenService.js │ ├── LocalForageCache.js │ ├── MockAuthService.js │ ├── index.js │ ├── interceptors │ │ ├── createCsrfTokenProviderInterceptor.js │ │ ├── createJwtTokenProviderInterceptor.js │ │ ├── createProcessAxiosRequestErrorInterceptor.js │ │ ├── createRetryInterceptor.js │ │ └── createRetryInterceptor.test.js │ ├── interface.js │ └── utils.js ├── config.js ├── constants.js ├── i18n │ ├── countries.js │ ├── index.js │ ├── injectIntlWithShim.jsx │ ├── languages.js │ ├── lib.js │ ├── lib.test.js │ └── scripts │ │ ├── README.md │ │ ├── intl-imports.js │ │ ├── intl-imports.test.js │ │ └── transifex-utils.js ├── index.js ├── initialize.async.function.config.test.js ├── initialize.const.config.test.js ├── initialize.function.config.test.js ├── initialize.js ├── initialize.test.js ├── logging │ ├── MockLoggingService.js │ ├── NewRelicLoggingService.js │ ├── NewRelicLoggingService.test.js │ ├── index.js │ └── interface.js ├── pubSub.js ├── react │ ├── AppContext.jsx │ ├── AppProvider.jsx │ ├── AppProvider.test.jsx │ ├── AuthenticatedPageRoute.jsx │ ├── AuthenticatedPageRoute.test.jsx │ ├── ErrorBoundary.jsx │ ├── ErrorBoundary.test.jsx │ ├── ErrorPage.jsx │ ├── LoginRedirect.jsx │ ├── OptionalReduxProvider.jsx │ ├── PageWrap.jsx │ ├── constants.js │ ├── hooks │ │ ├── index.js │ │ ├── paragon │ │ │ ├── index.js │ │ │ ├── useParagonTheme.js │ │ │ ├── useParagonTheme.test.js │ │ │ ├── useParagonThemeCore.js │ │ │ ├── useParagonThemeCore.test.js │ │ │ ├── useParagonThemeUrls.js │ │ │ ├── useParagonThemeUrls.test.js │ │ │ ├── useParagonThemeVariants.js │ │ │ ├── useParagonThemeVariants.test.js │ │ │ ├── useTrackColorSchemeChoice.js │ │ │ ├── useTrackColorSchemeChoice.test.js │ │ │ ├── utils.js │ │ │ └── utils.test.js │ │ └── useAppEvent.js │ ├── index.js │ └── reducers.js ├── scripts │ ├── GoogleAnalyticsLoader.js │ ├── GoogleAnalyticsLoader.test.js │ └── index.js ├── setupTest.js ├── testing │ ├── index.js │ ├── initializeMockApp.js │ ├── initializeMockApp.test.js │ └── mockMessages.js ├── utils.js └── utils.test.js └── webpack.dev.config.js /.env.development: -------------------------------------------------------------------------------- 1 | EXAMPLE_VAR=Example Value 2 | ACCESS_TOKEN_COOKIE_NAME=edx-jwt-cookie-header-payload 3 | ACCOUNT_PROFILE_URL=http://localhost:1995 4 | ACCOUNT_SETTINGS_URL=http://localhost:1997 5 | BASE_URL=http://localhost:8080 6 | CREDENTIALS_BASE_URL=http://localhost:18150 7 | CSRF_TOKEN_API_PATH=/csrf/api/v1/token 8 | DISCOVERY_API_BASE_URL=http://localhost:18381 9 | PUBLISHER_BASE_URL=http://localhost:18400 10 | ECOMMERCE_BASE_URL=http://localhost:18130 11 | LANGUAGE_PREFERENCE_COOKIE_NAME=openedx-language-preference 12 | LEARNING_BASE_URL=http://localhost:2000 13 | LMS_BASE_URL=http://localhost:18000 14 | LOGIN_URL=http://localhost:18000/login 15 | LOGOUT_URL=http://localhost:18000/logout 16 | STUDIO_BASE_URL=http://localhost:18010 17 | MARKETING_SITE_BASE_URL=http://localhost:18000 18 | ORDER_HISTORY_URL=http://localhost:1996/orders 19 | REFRESH_ACCESS_TOKEN_ENDPOINT=http://localhost:18000/login_refresh 20 | SEGMENT_KEY='' 21 | SITE_NAME=localhost 22 | USER_INFO_COOKIE_NAME=edx-user-info 23 | LOGO_URL=https://edx-cdn.org/v3/default/logo.svg 24 | LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg 25 | LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg 26 | FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico 27 | IGNORED_ERROR_REGEX= 28 | MFE_CONFIG_API_URL= 29 | APP_ID= 30 | SUPPORT_URL=https://support.edx.org 31 | PARAGON_THEME_URLS={} 32 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | EXAMPLE_VAR=Example Value 2 | ACCESS_TOKEN_COOKIE_NAME=edx-jwt-cookie-header-payload 3 | ACCOUNT_PROFILE_URL=http://localhost:1995 4 | ACCOUNT_SETTINGS_URL=http://localhost:1997 5 | BASE_URL=http://localhost:8080 6 | CREDENTIALS_BASE_URL=http://localhost:18150 7 | CSRF_TOKEN_API_PATH=/csrf/api/v1/token 8 | DISCOVERY_API_BASE_URL=http://localhost:18381 9 | PUBLISHER_BASE_URL=http://localhost:18400 10 | ECOMMERCE_BASE_URL=http://localhost:18130 11 | LANGUAGE_PREFERENCE_COOKIE_NAME=openedx-language-preference 12 | LEARNING_BASE_URL=http://localhost:2000 13 | LMS_BASE_URL=http://localhost:18000 14 | LOGIN_URL=http://localhost:18000/login 15 | LOGOUT_URL=http://localhost:18000/logout 16 | STUDIO_BASE_URL=http://localhost:18010 17 | MARKETING_SITE_BASE_URL=http://localhost:18000 18 | ORDER_HISTORY_URL=http://localhost:1996/orders 19 | REFRESH_ACCESS_TOKEN_ENDPOINT=http://localhost:18000/login_refresh 20 | SEGMENT_KEY='' 21 | SITE_NAME=localhost 22 | USER_INFO_COOKIE_NAME=edx-user-info 23 | LOGO_URL=https://edx-cdn.org/v3/default/logo.svg 24 | LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg 25 | LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg 26 | FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico 27 | IGNORED_ERROR_REGEX= 28 | MFE_CONFIG_API_URL= 29 | APP_ID= 30 | SUPPORT_URL=https://support.edx.org 31 | PARAGON_THEME_URLS={} 32 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | .idea 2 | coverage 3 | dist 4 | docs 5 | node_modules 6 | src/analytics/segment.js 7 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | const { getBaseConfig } = require('@openedx/frontend-build'); 3 | 4 | const config = getBaseConfig('eslint'); 5 | 6 | config.rules = { 7 | 'import/no-extraneous-dependencies': ['error', { 8 | devDependencies: [ 9 | '**/*.config.js', 10 | '**/*.test.jsx', 11 | '**/*.test.js', 12 | 'example/*', 13 | ], 14 | }], 15 | 'import/extensions': ['error', { 16 | ignore: ['@edx/frontend-platform*'], 17 | }], 18 | 'import/no-unresolved': ['error', { 19 | ignore: ['@edx/frontend-platform*'], 20 | }], 21 | 'jsx-a11y/anchor-is-valid': ['error', { 22 | components: ['Link'], 23 | specialLink: ['to'], 24 | aspects: ['noHref', 'invalidHref', 'preferButton'], 25 | }], 26 | }; 27 | 28 | module.exports = config; 29 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Description:** 2 | 3 | Describe what this pull request changes, and why. Include implications for people using this change. 4 | 5 | **Merge checklist:** 6 | 7 | - [ ] Consider running your code modifications in the included example app within `frontend-platform`. This can be done by running `npm start` and opening http://localhost:8080. 8 | - [ ] Consider testing your code modifications in another local micro-frontend using local aliases configured via [the `module.config.js` file in `frontend-build`](https://github.com/openedx/frontend-build#local-module-configuration-for-webpack). 9 | - [ ] Verify your commit title/body conforms to the conventional commits format (e.g., `fix`, `feat`) and is appropriate for your code change. Consider whether your code is a breaking change, and modify your commit accordingly. 10 | 11 | **Post merge:** 12 | 13 | - [ ] After the build finishes for the merged commit, verify the new release has been pushed to [NPM](https://www.npmjs.com/package/@edx/frontend-platform). 14 | -------------------------------------------------------------------------------- /.github/workflows/add-depr-ticket-to-depr-board.yml: -------------------------------------------------------------------------------- 1 | # Run the workflow that adds new tickets that are either: 2 | # - labelled "DEPR" 3 | # - title starts with "[DEPR]" 4 | # - body starts with "Proposal Date" (this is the first template field) 5 | # to the org-wide DEPR project board 6 | 7 | name: Add newly created DEPR issues to the DEPR project board 8 | 9 | on: 10 | issues: 11 | types: [opened] 12 | 13 | jobs: 14 | routeissue: 15 | uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master 16 | secrets: 17 | GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }} 18 | GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }} 19 | SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/add-remove-label-on-comment.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs when a comment is made on the ticket 2 | # If the comment starts with "label: " it tries to apply 3 | # the label indicated in rest of comment. 4 | # If the comment starts with "remove label: ", it tries 5 | # to remove the indicated label. 6 | # Note: Labels are allowed to have spaces and this script does 7 | # not parse spaces (as often a space is legitimate), so the command 8 | # "label: really long lots of words label" will apply the 9 | # label "really long lots of words label" 10 | 11 | name: Allows for the adding and removing of labels via comment 12 | 13 | on: 14 | issue_comment: 15 | types: [created] 16 | 17 | jobs: 18 | add_remove_labels: 19 | uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master 20 | 21 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: node_js CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - "**" 10 | 11 | jobs: 12 | tests: 13 | runs-on: ubuntu-22.04 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v4 17 | 18 | - name: Setup Nodejs 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version-file: '.nvmrc' 22 | 23 | - name: Install dependencies 24 | run: npm ci 25 | 26 | - name: Lint 27 | run: npm run lint 28 | 29 | - name: Test 30 | run: npm run test 31 | 32 | - name: Build 33 | run: npm run build 34 | 35 | - name: Docs 36 | run: npm run docs 37 | 38 | - name: Run Coverage 39 | uses: codecov/codecov-action@v4 40 | with: 41 | fail_ci_if_error: true 42 | token: ${{ secrets.CODECOV_TOKEN }} 43 | -------------------------------------------------------------------------------- /.github/workflows/commitlint.yml: -------------------------------------------------------------------------------- 1 | # Run commitlint on the commit messages in a pull request. 2 | 3 | name: Lint Commit Messages 4 | 5 | on: 6 | - pull_request 7 | 8 | jobs: 9 | commitlint: 10 | uses: openedx/.github/.github/workflows/commitlint.yml@master 11 | -------------------------------------------------------------------------------- /.github/workflows/lockfileversion-check.yml: -------------------------------------------------------------------------------- 1 | #check package-lock file version 2 | 3 | name: lockfileVersion check 4 | 5 | on: 6 | push: 7 | branches: 8 | - master 9 | pull_request: 10 | 11 | jobs: 12 | version-check: 13 | uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master 14 | -------------------------------------------------------------------------------- /.github/workflows/manual-publish.yml: -------------------------------------------------------------------------------- 1 | name: Manual Publish 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | tag: 6 | required: true 7 | description: NPM distribution tag to use for the backported release (npm publish --tag ) 8 | jobs: 9 | release: 10 | name: Manual Publish 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | - name: Setup Nodejs Env 18 | run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV 19 | - name: Setup Node.js 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ env.NODE_VER }} 23 | - name: Install dependencies 24 | run: npm ci 25 | - name: Lint 26 | run: npm run lint 27 | - name: Test 28 | run: npm run test 29 | - name: Coverage 30 | uses: codecov/codecov-action@v4 31 | with: 32 | fail_ci_if_error: false 33 | token: ${{ secrets.CODECOV_TOKEN }} 34 | - name: Build 35 | run: npm run build 36 | # NPM expects to be authenticated for publishing. This step will fail CI if NPM is not authenticated 37 | - name: Check NPM authentication 38 | run: | 39 | echo "//registry.npmjs.org/:_authToken=${{ secrets.SEMANTIC_RELEASE_NPM_TOKEN }}" >> .npmrc 40 | npm whoami 41 | - name: Release 42 | env: 43 | GITHUB_TOKEN: ${{ secrets.SEMANTIC_RELEASE_GITHUB_TOKEN }} 44 | NPM_TOKEN: ${{ secrets.SEMANTIC_RELEASE_NPM_TOKEN }} 45 | # `npm publish` relies on version specified in package.json file 46 | run: npm publish ./dist --tag ${{github.event.inputs.tag}} # e.g., old-version 47 | -------------------------------------------------------------------------------- /.github/workflows/npm-deprecate.yml: -------------------------------------------------------------------------------- 1 | name: NPM Deprecate 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version: 6 | description: 'Version to deprecate' 7 | required: true 8 | message: 9 | description: 'Reason for deprecation' 10 | required: true 11 | 12 | jobs: 13 | deprecate: 14 | name: Deprecate 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check NPM authentication 18 | run: | 19 | echo "//registry.npmjs.org/:_authToken=${{ secrets.SEMANTIC_RELEASE_NPM_TOKEN }}" >> .npmrc 20 | npm whoami 21 | - name: NPM deprecate 22 | run: npm deprecate @edx/frontend-platform@"${{ github.event.inputs.version }}" "${{ github.event.inputs.message }}" 23 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release CI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - alpha 7 | 8 | jobs: 9 | release: 10 | name: Release 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Setup Nodejs Env 19 | run: echo "NODE_VER=`cat .nvmrc`" >> $GITHUB_ENV 20 | 21 | - name: Setup Node.js 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ env.NODE_VER }} 25 | 26 | - name: Install dependencies 27 | run: npm ci 28 | 29 | - name: Create Build 30 | run: npm run build 31 | 32 | - name: Release to npm/Github 33 | run: npx semantic-release@22 34 | env: 35 | GITHUB_TOKEN: ${{ secrets.SEMANTIC_RELEASE_GITHUB_TOKEN }} 36 | NPM_TOKEN: ${{ secrets.SEMANTIC_RELEASE_NPM_TOKEN }} 37 | 38 | - name: Docs 39 | run: npm run docs 40 | 41 | - name: Deploy to GitHub Pages 42 | uses: JamesIves/github-pages-deploy-action@v4.7.3 43 | with: 44 | branch: gh-pages # The branch the action should deploy to. 45 | folder: docs/api/@edx/frontend-platform/1.0.0-semantically-released # The folder the action should deploy. 46 | clean: true # Automatically remove deleted files from the deploy branch 47 | -------------------------------------------------------------------------------- /.github/workflows/self-assign-issue.yml: -------------------------------------------------------------------------------- 1 | # This workflow runs when a comment is made on the ticket 2 | # If the comment starts with "assign me" it assigns the author to the 3 | # ticket (case insensitive) 4 | 5 | name: Assign comment author to ticket if they say "assign me" 6 | on: 7 | issue_comment: 8 | types: [created] 9 | 10 | jobs: 11 | self_assign_by_comment: 12 | uses: openedx/.github/.github/workflows/self-assign-issue.yml@master 13 | -------------------------------------------------------------------------------- /.github/workflows/update-browserslist-db.yml: -------------------------------------------------------------------------------- 1 | name: Update Browserslist DB 2 | on: 3 | schedule: 4 | - cron: '0 0 * * 1' 5 | workflow_dispatch: 6 | 7 | jobs: 8 | update-browserslist: 9 | uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master 10 | 11 | secrets: 12 | requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .vscode 4 | coverage 5 | dist 6 | src/i18n/transifex_input.json 7 | node_modules 8 | /docs/api 9 | .env.private 10 | /temp/ 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | __mocks__ 2 | babel.config.js 3 | codecov.yml 4 | commitlint.config.js 5 | coverage 6 | example 7 | jest.config.js 8 | Makefile 9 | public 10 | renovate.json 11 | src/**/*.test.js 12 | src/**/*.test.jsx 13 | src/**/tests 14 | src/**/setupTest.js 15 | webpack.dev.config.js 16 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "master", 4 | {name: "alpha", prerelease: true} 5 | ], 6 | "tagFormat": "v${version}", 7 | "verifyConditions": [ 8 | { 9 | "path": "@semantic-release/npm", 10 | "pkgRoot": "dist" 11 | }, 12 | { 13 | "path": "@semantic-release/github" 14 | } 15 | ], 16 | "analyzeCommits": "@semantic-release/commit-analyzer", 17 | "generateNotes": "@semantic-release/release-notes-generator", 18 | "prepare": [ 19 | { 20 | "path": "@semantic-release/npm", 21 | "pkgRoot": "dist" 22 | } 23 | ], 24 | "publish": [ 25 | { 26 | "path": "@semantic-release/npm", 27 | "pkgRoot": "dist" 28 | }, 29 | { 30 | "path": "@semantic-release/github" 31 | } 32 | ], 33 | "success": [], 34 | "fail": [] 35 | } 36 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Documentation CLI usage: https://github.com/documentationjs/documentation/blob/master/docs/USAGE.md 2 | 3 | i18n = ./src/i18n 4 | transifex_input = $(i18n)/transifex_input.json 5 | transifex_utils = $(i18n)/scripts/transifex-utils.js 6 | 7 | # This directory must match .babelrc . 8 | transifex_temp = ./temp/babel-plugin-formatjs 9 | 10 | doc_command = ./node_modules/.bin/documentation build src -g -c ./docs/documentation.config.yml -f md -o ./docs/_API-body.md --sort-order alpha 11 | cat_docs_command = cat ./docs/_API-header.md ./docs/_API-body.md > ./docs/API.md 12 | 13 | build: 14 | rm -rf ./dist 15 | ./node_modules/.bin/fedx-scripts babel src --out-dir dist --source-maps --ignore **/*.test.jsx,**/*.test.js,**/setupTest.js --copy-files 16 | @# --copy-files will bring in everything else that wasn't processed by babel. Remove what we don't want. 17 | @find dist -name '*.test.js*' -delete 18 | rm ./dist/setupTest.js 19 | cp ./package.json ./dist/package.json 20 | cp ./LICENSE ./dist/LICENSE 21 | cp ./README.md ./dist/README.md 22 | 23 | docs-build: 24 | ${doc_command} 25 | ${cat_docs_command} 26 | rm ./docs/_API-body.md 27 | 28 | docs-watch: 29 | @echo "NOTE: Please load _API-body.md to see watch results." 30 | ${doc_command} -w 31 | 32 | docs-lint: 33 | ./node_modules/.bin/documentation lint src 34 | 35 | 36 | .PHONY: requirements 37 | requirements: ## install ci requirements 38 | npm ci 39 | 40 | i18n.extract: 41 | # Pulling display strings from .jsx files into .json files... 42 | rm -rf $(transifex_temp) 43 | npm run-script i18n_extract 44 | 45 | i18n.concat: 46 | # Gathering JSON messages into one file... 47 | $(transifex_utils) $(transifex_temp) $(transifex_input) 48 | 49 | extract_translations: | requirements i18n.extract i18n.concat 50 | -------------------------------------------------------------------------------- /__mocks__/universal-cookie.js: -------------------------------------------------------------------------------- 1 | const mockCookiesImplementation = { 2 | get: jest.fn(), 3 | remove: jest.fn(), 4 | }; 5 | 6 | module.exports = () => mockCookiesImplementation; 7 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const { createConfig } = require('@openedx/frontend-build'); 2 | 3 | module.exports = createConfig('babel-preserve-modules'); 4 | -------------------------------------------------------------------------------- /catalog-info.yaml: -------------------------------------------------------------------------------- 1 | # This file records information about this repo. Its use is described in OEP-55: 2 | # https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html 3 | 4 | apiVersion: backstage.io/v1alpha1 5 | kind: Component 6 | metadata: 7 | name: 'frontend-platform' 8 | description: "A modest application framework for Open edX micro-frontend applications and their supporting libraries" 9 | links: 10 | - url: "https://github.com/openedx/frontend-platform" 11 | title: "Frontend Platform" 12 | icon: "Code" 13 | - url: "https://openedx.github.io/frontend-platform/" 14 | title: "Documentation" 15 | icon: "Article" 16 | annotations: 17 | openedx.org/arch-interest-groups: "" 18 | spec: 19 | owner: "group:committers-frontend" 20 | type: 'library' 21 | lifecycle: 'production' 22 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | target: auto 6 | threshold: 0% 7 | patch: 8 | default: 9 | target: auto 10 | threshold: 0% 11 | -------------------------------------------------------------------------------- /docs/addTagsPlugin.js: -------------------------------------------------------------------------------- 1 | exports.defineTags = function (dictionary) { 2 | dictionary.defineTag("service", { 3 | mustHaveValue: true, 4 | canHaveType: false, 5 | canHaveName: true, 6 | onTagged: function (doclet, tag) { 7 | doclet.service = tag.value; 8 | } 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /docs/auth-API.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## LoginRedirect : ReactComponent 4 | **Kind**: global class 5 | 6 | 7 | ## redirectToLogin(redirectUrl) 8 | Redirect the user to login 9 | 10 | **Kind**: global function 11 | 12 | | Param | Type | Description | 13 | | --- | --- | --- | 14 | | redirectUrl | string | the url to redirect to after login | 15 | 16 | 17 | 18 | ## redirectToLogout(redirectUrl) 19 | Redirect the user to logout 20 | 21 | **Kind**: global function 22 | 23 | | Param | Type | Description | 24 | | --- | --- | --- | 25 | | redirectUrl | string | the url to redirect to after logout | 26 | 27 | 28 | 29 | ## getAuthenticatedApiClient(config) ⇒ [HttpClient](#HttpClient) 30 | Gets the apiClient singleton which is an axios instance. 31 | 32 | **Kind**: global function 33 | **Returns**: [HttpClient](#HttpClient) - Singleton. A configured axios http client 34 | 35 | | Param | Type | Description | 36 | | --- | --- | --- | 37 | | config | object | | 38 | | [config.appBaseUrl] | string | | 39 | | [config.authBaseUrl] | string | | 40 | | [config.loginUrl] | string | | 41 | | [config.logoutUrl] | string | | 42 | | [config.loggingService] | object | requires logError and logInfo methods | 43 | | [config.refreshAccessTokenEndpoint] | string | | 44 | | [config.accessTokenCookieName] | string | | 45 | | [config.csrfTokenApiPath] | string | | 46 | 47 | 48 | 49 | ## getAuthenticatedUser() ⇒ [Promise.<UserData>](#UserData) \| Promise.<null> 50 | Gets the authenticated user's access token. Resolves to null if the user is unauthenticated. 51 | 52 | **Kind**: global function 53 | **Returns**: [Promise.<UserData>](#UserData) \| Promise.<null> - Resolves to the user's access token if they are logged in. 54 | 55 | 56 | ## ensureAuthenticatedUser(route) ⇒ [Promise.<UserData>](#UserData) 57 | Ensures a user is authenticated. It will redirect to login when not authenticated. 58 | 59 | **Kind**: global function 60 | 61 | | Param | Type | Description | 62 | | --- | --- | --- | 63 | | route | string | to return user after login when not authenticated. | 64 | 65 | 66 | 67 | ## PrivateRoute() : ReactComponent 68 | **Kind**: global function 69 | 70 | 71 | ## HttpClient 72 | A configured axios client. See axios docs for more 73 | info https://github.com/axios/axios. All the functions 74 | below accept isPublic and isCsrfExempt in the request 75 | config options. Setting these to true will prevent this 76 | client from attempting to refresh the jwt access token 77 | or a csrf token respectively. 78 | 79 | ``` 80 | // A public endpoint (no jwt token refresh) 81 | apiClient.get('/path/to/endpoint', { isPublic: true }); 82 | ``` 83 | 84 | ``` 85 | // A csrf exempt endpoint 86 | apiClient.post('/path/to/endpoint', { data }, { isCsrfExempt: true }); 87 | ``` 88 | 89 | **Kind**: global typedef 90 | **Properties** 91 | 92 | | Name | Type | Description | 93 | | --- | --- | --- | 94 | | get | function | | 95 | | head | function | | 96 | | options | function | | 97 | | delete | function | (csrf protected) | 98 | | post | function | (csrf protected) | 99 | | put | function | (csrf protected) | 100 | | patch | function | (csrf protected) | 101 | 102 | 103 | 104 | ## UserData 105 | **Kind**: global typedef 106 | **Properties** 107 | 108 | | Name | Type | 109 | | --- | --- | 110 | | userId | string | 111 | | username | string | 112 | | roles | array | 113 | | administrator | bool | 114 | 115 | -------------------------------------------------------------------------------- /docs/decisions/0001-record-architecture-decisions.rst: -------------------------------------------------------------------------------- 1 | 1. Record Architecture Decisions 2 | -------------------------------- 3 | 4 | Status 5 | ------ 6 | 7 | Accepted 8 | 9 | Context 10 | ------- 11 | 12 | We would like to keep a historical record on the architectural 13 | decisions we make with this app as it evolves over time. 14 | 15 | Decision 16 | -------- 17 | 18 | We will use Architecture Decision Records, as described by 19 | Michael Nygard in `Documenting Architecture Decisions`_ 20 | 21 | .. _Documenting Architecture Decisions: http://thinkrelevance.com/blog/2011/11/15/documenting-architecture-decisions 22 | 23 | Consequences 24 | ------------ 25 | 26 | See Michael Nygard's article, linked above. 27 | 28 | References 29 | ---------- 30 | 31 | * https://resources.sei.cmu.edu/asset_files/Presentation/2017_017_001_497746.pdf 32 | * https://github.com/npryce/adr-tools/tree/master/doc/adr 33 | -------------------------------------------------------------------------------- /docs/decisions/0003-consolidation-into-frontend-platform.rst: -------------------------------------------------------------------------------- 1 | Consolidation of libraries into frontend-platform 2 | ================================================= 3 | 4 | Status 5 | ------ 6 | 7 | Accepted 8 | 9 | Context 10 | ------- 11 | 12 | The frontend-platform repository replaces five earlier repositories: 13 | 14 | - edx/frontend-analytics 15 | - edx/frontend-auth 16 | - edx/frontend-base 17 | - edx/frontend-i18n 18 | - edx/frontend-logging 19 | 20 | We found that development across these five libraries was growing very difficult in a number of ways. 21 | 22 | Dependency graph complexity 23 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 24 | 25 | The overhead involved in maintaining individual packages and semantic version numbers was very difficult to reason about and maintain. Updates to low-level packages tended to cause cascading updates through the dependency tree. If a breaking change or incompatibility present in the tree forces dependent packages to absorb unrelated work, it can be very difficult to realize it's happening, decreasing developer velocity and destabilizing our applications. 26 | 27 | As a rough illustration of this complexity: 28 | 29 | - 12 Micro-frontends depend on 5 libraries. (60 edges max) 30 | - 12 Micro-frontends depend on 5 reusable organisms (headers, footers, Paragon) (60 edges max) 31 | - 5 Reusable organisms depend on the 5 libraries. (25 edges max) 32 | - The 5 libraries sometimes depend on each other. (8 edges total) 33 | 34 | This graph has 153 possible edges, each of which represents a version number with its own set of compatibilities. By combining the libraries, we drop the number of edges to 77. Further combining some of our reusable organisms could get this number as low as 51. 35 | 36 | For a single micro-frontend, these numbers go from 43 to 11, then 9. 37 | 38 | Difficult cross-cutting feature development 39 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 40 | 41 | Often we've found that features and bug fixes require us to work in several of the above repositories simultaneously. Getting npm to link packages from peer directories is tedious and error prone, making this kind of work difficult to get right. 42 | 43 | Interdependent services 44 | ~~~~~~~~~~~~~~~~~~~~~~~ 45 | 46 | Going along with the difficulty of cross-cutting concerns, we realized at some point that many of packages were simply rather inter-connected. Meaningful features and behaviors span packages, especially with respect to frontend-base's usage of the other libraries. This means that validating correct behavior requires a fully functioning system; developing the libraries in isolation prevented us from doing substantive integration testing. 47 | 48 | Decision 49 | -------- 50 | 51 | By collapsing these libraries into a single repository published together under a single version number, we drastically reduce the layers of complexity involved in publishing new library versions. 52 | Note that this is a tactical technology choice - architecturally and strategically, the libraries are still independent and contain strong boundaries isolating them from each other. 53 | 54 | Note that we've preserved the platform's ability to utilize different service implementations, meaning that using the platform does not preclude applications from providing customized/extended functionality. This means that we haven't really lost much flexibility by the consolidation. 55 | 56 | Alternatives 57 | ------------ 58 | 59 | We strongly considered making a Lerna monorepo with each of the original five packages published with its own version number. There were two problems with this approach - first, it wouldn't address the dependency graph complexity, which was one of the most difficult-to-reason aspects of the original repositories. Secondary to that, our usage of babel and building to a dist directory erases some of the developer experience benefits of using Lerna. One of Lerna's strengths is that it automatically rewires your package.json dependencies to point at other packages in the repository, rather than pulling them down from the package registry. Since we have a build step, we'd have to manually build each package any time we wanted to use it, which isn't all that different from what we had to do with the five separate repos. Lerna just didn't really seem worth it. 60 | 61 | Adoption 62 | -------- 63 | 64 | As of this writing, frontend-platform is in use in four of our existing micro-frontends. It's our intention to help teams migrate the others (approximately 8) to use the platform as well, and to archive the five legacy implementations mentioned above. 65 | 66 | The platform's API surface is very similar to the original five libraries, simply with a different import package. If necessary, we expect that consumers of the old libraries will be able to do gradual, partial upgrades by cutting over individual libraries until they reach full adoption. 67 | 68 | Consequences 69 | ------------ 70 | 71 | If we collapse these libraries into a single versioned platform, consumers of the platform (applications) may still have to absorb undesired breaking changes at times, but we've greatly reduced the complexity of doing so. Updating an application or dependent library involves consulting a single changelog for breaking changes, rather than five and trying to reason which are compatible with each other. 72 | -------------------------------------------------------------------------------- /docs/decisions/0004-axios-caching-implementation.rst: -------------------------------------------------------------------------------- 1 | Axios Caching Layer Implementation 2 | ================================================= 3 | 4 | Status 5 | ------ 6 | 7 | Accepted 8 | 9 | Context 10 | ------- 11 | 12 | It was noticed that on a lot of pages the same API calls are repeatedly being made as the user navigates around the website. The data being returned from these requests doesn't change between page loads leading to the same endpoints reloading the same data repeatedly. We decided that we wanted to avoid repeatedly loading unchanged data and reuse the data that was in the response from the initial load. 13 | 14 | We think it's beneficial to cut as much overhead as possible so each page becomes interactive more quickly. 15 | 16 | We decided to add a frontend caching layer to the AxiosJwtAuthService by leveraging an already existing npm package: `axios-cache-adapter `_ 17 | 18 | Having a frontend caching layer would also allow for a stale version of a page to be displayed for a short amount of time in the case of a backend outage. 19 | 20 | When Frontend Caching Should Be Used 21 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 22 | 23 | The frontend cache should be used to eliminate the reloading of unchanged data as a user navigates between different pages in the application. It shouldn't be used to reduce the number of times multiple components on the same page try to make the same network call. Reducing the number of times the same network call is made on a single page should be done by moving the call higher up in the hierarchy so the call only gets made once and all lower level components are able to access the response data from it. 24 | 25 | Requests that are good candidates to use frontend caching are ones that: 26 | * The data being returned changes very infrequently, and independently from the actions of the user on the page 27 | 28 | - For example changes made by a background job or an admin 29 | 30 | * The data being returned isn't specific to the authenticated user 31 | 32 | * The data returned is specific to the authenticated user but front end does not make the request when there isn't an authenticated user 33 | 34 | It should not be used as the only caching strategy that gets used. It is preferable to use a serverside caching strategy in conjunction with frontend caching. 35 | 36 | How To Use Frontend Caching 37 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 38 | 39 | The frontend caching layer can be used by passing {useCache: true} into the function that gets the httpClient. 40 | 41 | e.g. 42 | service.getAuthenticatedHttpClient({ useCache: true }); 43 | 44 | By default the response for a GET request will be stored in IndexedDB for 5 minutes and that cached value will get invalidated on any POST, PUT, PATCH, or DELETE request made to the same url. The caching layer also works with the standard cache-control headers: max-age, no-cache, and no-store 45 | 46 | Each request can also override the default cache configurations. `The How To document about caching `_ has more detailed examples about how this caching layer can be used. 47 | 48 | 49 | Implementation Details 50 | ~~~~~~~~~~~~~~~~~~~~~~ 51 | 52 | For both versions of the current AxiosJwtAuthService HTTP clients, the authenticated client and the base unauthenticated clients, there are now two additional versions that use the cache (i.e. CachedAuthenticated and CachedUnauthenticated). Bringing the total number of httpClient instances up to four. 53 | In order to keep the interface more simple the cached clients don’t have their own getter functions exposing this detail, in order to use the cached http client micro frontends pass in a param for using the cache with the current getter functions. 54 | 55 | In the case of the cached clients failing to get created the uncached clients will be returned and used instead. 56 | 57 | The async hydrateAuthenticatedUser function will continue only ever using the uncached authenticated HTTP client so there won't be any concerns about the cache changing expected behavior of authentication. 58 | 59 | Only the AxiosJwtAuthService currently has cached http clients. 60 | 61 | Decisions 62 | --------- 63 | 64 | By using the existing axios-cache-adapter we were able to avoid needing to implement our own caching strategy and forgo having micro frontends need to implement their own front end caching strategies. 65 | 66 | The default max-age for cached data was decided to be 5 minutes for now. 5 minutes was deemed to be a reasonable amount of time that was neither too long or too short. The assumption is made that users won’t be switching to different accounts on their browser frequently enough to have a wide impact on their experience. 67 | If they experience any impact at all then they will only see cached data for a maximum of 5 minutes. Micro frontends are also able to override the default cache time to suit their own needs. 68 | The default max-age can also be changed at a later time if the current default of 5 minutes is deemed to be inadequate. 69 | 70 | "Integration tests were added to ensure that the cache adapter does not break the current behavior of the existing interceptors. These tests make real HTTP requests to https://httpbin.org/ as suggested by the tests in the axios-cache-adapter source code." 71 | 72 | Alternatives 73 | ------------ 74 | 75 | We discussed the pros and cons of having a frontend-platform cache implementation for micro front ends to use versus micro front ends implementing their own front end caching strategies utilizing Local/Session storage, ETags, and endpoints utilizing Cache-Control headers. 76 | Having a frontend-platform caching solution be widely available to micro frontends was decided to be a good thing and micro frontends are still able to use other caching strategies in conjunction with the caching in frontend-platform. 77 | 78 | Adoption 79 | -------- 80 | 81 | As of this writing, only a few HTTP requests within `frontend-app-learner-portal-enterprise `_ have adopted using the cached clients. Usage of the cache clients is on an opt-in basis where micro frontends determine whether or not using the front end cache clients suits their needs. 82 | 83 | 84 | Consequences 85 | ------------ 86 | 87 | Axios is version locked to version 0.18.1 so per request overrides work until this issue gets resolved: https://github.com/RasCarlito/axios-cache-adapter/issues/99. 88 | At of this writing it seems that a fix for the issue might possibly be in version 0.20 89 | -------------------------------------------------------------------------------- /docs/decisions/0005-token-null-after-successful-refresh.rst: -------------------------------------------------------------------------------- 1 | Access Token Null After Successful Refresh 2 | ========================================== 3 | 4 | Status 5 | ------ 6 | 7 | Accepted 8 | 9 | Context 10 | ------- 11 | 12 | There are cases where our frontend authentication code requests an updated access token (JWT cookies), gets a successful response, but the cookies never appear. This results in the JavaScript error: "[frontend-auth] Access token is still null after successful refresh." 13 | 14 | Decision 15 | -------- 16 | 17 | Since we have been unable to determine root cause, the de facto decision is to allow this issue to continue to exist. 18 | 19 | This doc is less about capturing the decision, then to capture the background in-case anyone wishes to understand what has been attempted so far. 20 | 21 | Failed Hypotheses 22 | ----------------- 23 | 24 | Several failed hypotheses have been tested to determine why this is happening: 25 | 26 | * Tested if caused by a race condition between getting the response and having the cookies set. 27 | 28 | * See https://github.com/edx-unsupported/frontend-auth/pull/38/files 29 | 30 | * Tested if caused by cookies being disabled, by creating/reading a separate cookie. 31 | 32 | * Failure noted in https://openedx.atlassian.net/browse/ARCH-948?focusedCommentId=401201 33 | 34 | * Tested if the cookies library has a bug by attempting vanilla javascript. 35 | 36 | * Failure noted in https://openedx.atlassian.net/browse/ARCH-948?focusedCommentId=401201 37 | 38 | * Tested if caused by a difference between the browser time and server time, causing the cookie to expire on arrival. 39 | 40 | * Only a small subset of the errors (~0.8%) had a time difference of >5 minutes. 41 | 42 | * See https://github.com/openedx/frontend-platform/pull/207 43 | 44 | Additional Data 45 | --------------- 46 | 47 | Here is a query used in New Relic to find these errors in the Learning MFE:: 48 | 49 | SELECT count(*) From JavaScriptError 50 | WHERE errorMessage = '[frontend-auth] Access token is still null after successful refresh.' AND 51 | appName = 'prod-frontend-app-learning' 52 | SINCE 4 days ago FACET userAgentName 53 | 54 | Almost all of these errors are seen in Safari (2.9 k), with the rest (166) found in Chrome and Microsoft Edge. 55 | 56 | Here is an adjusted query searching for errors with a difference of >5 minutes between the server and browser times:: 57 | 58 | SELECT count(*) From JavaScriptError 59 | WHERE errorMessage = '[frontend-auth] Access token is still null after successful refresh.' AND 60 | browserDriftSeconds > 60*5 61 | SINCE 4 days ago FACET userAgentName 62 | 63 | There were only 24 of these found in Safari, or ~0.8% of all of these errors found in Safari. 64 | 65 | Lastly, at the time this data was captured (2021-08-17), this error type made up ~12% of all errors in Safari, and ~3.3% of all errors across all browsers in the Learning MFE. 66 | 67 | * It is unclear if these errors can be duplicated with special privacy settings in Safari (or other browsers). 68 | 69 | * We have not learned about this issue from Customer Support, but only from monitoring errors. Since we are not receiving the JWT cookies, we do not know the affected users. 70 | -------------------------------------------------------------------------------- /docs/decisions/0006-middleware-support-for-http-clients.rst: -------------------------------------------------------------------------------- 1 | Middleware Support for HTTP clients 2 | =================================== 3 | 4 | Status 5 | ------ 6 | 7 | Accepted 8 | 9 | Context 10 | ------- 11 | 12 | We currently expose HTTP clients(axios instances) via ``getAuthenticatedHttpClient`` and ``getHttpClient`` used to make API requests 13 | in our MFEs. There are instances where it would be helpful if consumers could apply middleware to these clients. 14 | For example the `axios-case-converter `_ package provides 15 | a middleware that handles snake-cased <-> camelCase conversions via axios interceptors. This middleware would allow our MFEs to 16 | avoid having to do this conversion manually. 17 | 18 | Decision 19 | -------- 20 | 21 | The ``initialize`` function provided in the initialize module initializes the ``AxiosJwtAuthService`` provided by ``@edx/frontend-platform``. 22 | We will add an optional param ``authMiddleware``, an array of middleware functions that will be applied to all http clients in 23 | the ``AxiosJwtAuthService``. 24 | 25 | Consumers will install the middleware they want to use and provide it to ``initialize``:: 26 | 27 | initialize({ 28 | messages: [appMessages], 29 | requireAuthenticatedUser: true, 30 | hydrateAuthenticatedUser: true, 31 | authMiddleware: [axiosCaseConverter, (client) => axiosRetry(client, { retries: 3 })], 32 | }); 33 | 34 | If a consumer chooses not to use ``initialize`` and instead the ``configure`` function, the middleware can be passed in the options param:: 35 | 36 | configure({ 37 | loggingService: getLoggingService(), 38 | config: getConfig(), 39 | options: { 40 | middleware: [axiosCaseConverter, (client) => axiosRetry(client, { retries: 3 })] 41 | } 42 | }); 43 | 44 | We decided to let consumers install their own middleware packages, removing the need to install the dependency as part of ``@edx/frontend-platform``. 45 | -------------------------------------------------------------------------------- /docs/decisions/0007-javascript-file-configuration.rst: -------------------------------------------------------------------------------- 1 | Promote JavaScript file configuration and deprecate environment variable configuration 2 | ====================================================================================== 3 | 4 | Status 5 | ------ 6 | 7 | Accepted 8 | 9 | Context 10 | ------- 11 | 12 | Our webpack build process allows us to set environment variables on the command 13 | line or via .env files. These environment variables are available in the 14 | application via ``process.env``. 15 | 16 | The implementation of this uses templatization and string interpolation to 17 | replace any instance of ``process.env.XXXX`` with the value of the environment 18 | variable named ``XXXX``. As an example, in our source code we may write:: 19 | 20 | const LMS_BASE_URL = process.env.LMS_BASE_URL; 21 | 22 | After the build process runs, the compiled source code will instead read:: 23 | 24 | const LMS_BASE_URL = 'http://localhost:18000'; 25 | 26 | Put another way, `process.env` is not actually an object available at runtime, 27 | it's a templatization token that helps the build replace it with a string 28 | literal. 29 | 30 | This approach has several important limitations: 31 | 32 | - There's no way to add variables without hard-coding process.env.XXXX 33 | somewhere in the file, complicating our ability to add additional 34 | application-specific configuration without explicitly merging it into the 35 | configuration document after it's been created in frontend-platform. 36 | - The method can *only* handle strings. 37 | 38 | Other data types are converted to strings:: 39 | 40 | # Build command: 41 | BOOLEAN_VAR=false NULL_VAR=null NUMBER_VAR=123 npm run build 42 | 43 | ... 44 | 45 | // Source code: 46 | const BOOLEAN_VAR = process.env.BOOLEAN_VAR; 47 | const NULL_VAR = process.env.NULL_VAR; 48 | const NUMBER_VAR = process.env.NUMBER_VAR; 49 | 50 | ... 51 | 52 | // Compiled source after the build runs: 53 | const BOOLEAN_VAR = "false"; 54 | const NULL_VAR = "null"; 55 | const NUMBER_VAR = "123"; 56 | 57 | This is not good! 58 | 59 | - It makes it very difficult to supply array and object configuration 60 | variables, and unreasonable to supply class or function config since we'd 61 | have to ``eval()`` them. 62 | 63 | Related to all this, frontend-platform has long had the ability to replace the 64 | implementations of its analytics, auth, and logging services, but no way to 65 | actually *configure* the app with a new implementation. Because of the above 66 | limitations, there's no reasonable way to configure a JavaScript class via 67 | environment variables. 68 | 69 | Decision 70 | -------- 71 | 72 | For the above reasons, we will deprecate environment variable configuration in 73 | favor of JavaScript file configuration. 74 | 75 | This method makes use of an ``env.config.js`` file to supply configuration 76 | variables to an application:: 77 | 78 | const config = { 79 | LMS_BASE_URL: 'http://localhost:18000', 80 | BOOLEAN_VAR: false, 81 | NULL_VAR: null, 82 | NUMBER_VAR: 123 83 | }; 84 | 85 | export default config; 86 | 87 | This file is imported by the frontend-build webpack build process if it exists, 88 | and expected by frontend-platform as part of its initialization process. If the 89 | file doesn't exist, frontend-build falls back to importing an empty object for 90 | backwards compatibility. This functionality already exists today in 91 | frontend-build in preparation for using it here in frontend-platform. 92 | 93 | This interdependency creates a peerDependency for frontend-platform on `frontend-build v8.1.0 `_ or 94 | later. 95 | 96 | Using a JavaScript file for configuration is standard practice in the 97 | JavaScript/node community. Babel, webpack, eslint, Jest, etc., all accept 98 | configuration via JavaScript files (which we take advantage of in 99 | frontend-build), so there is ample precedent for using a .js file for 100 | configuration. 101 | 102 | In order to achieve deprecation of environment variable configuration, we will 103 | follow the deprecation process described in 104 | `OEP-21: Deprecation and Removal `_. In addition, we will add 105 | build-time warnings to frontend-build indicating the deprecation of environment 106 | variable configuration. Practically speaking, this will mean adjusting build 107 | processes throughout the community and in common tools like Tutor. 108 | 109 | Relationship to runtime configuration 110 | ************************************* 111 | 112 | JavaScript file configuration is compatible with runtime MFE configuration. 113 | frontend-platform loads configuration in a predictable order: 114 | 115 | - environment variable config 116 | - optional handlers (commonly used to merge MFE-specific config in via additional 117 | process.env variables) 118 | - JS file config 119 | - runtime config 120 | 121 | In the end, runtime config wins. That said, JS file config solves some use 122 | cases that runtime config can't solve around extensibility and customization. 123 | 124 | In the future if we deprecate environment variable config, it's likely that 125 | we keep both JS file config and runtime configuration around. JS file config 126 | primarily to handle extensibility, and runtime config for everything else. 127 | 128 | Rejected Alternatives 129 | --------------------- 130 | 131 | Another option was to use JSON files for this purpose. This solves some of our 132 | issues (limited use of non-string primitive data types) but is otherwise not 133 | nearly as expressive or flexible as using a JavaScript file directly. 134 | Anecdotally, in the past frontend-build used JSON versions of many of 135 | its configuration files (Babel, eslint, jest) but over time they were all 136 | converted to JavaScript files so we could express more complicated 137 | configuration needs. Since one of the primary use cases and reasons we need a 138 | new configuration method is to allow developers to supply alternate 139 | implementations of frontend-platform's core services (analytics, logging), JSON 140 | was effectively a non-starter. 141 | 142 | .. _oep21: https://docs.openedx.org/projects/openedx-proposals/en/latest/processes/oep-0021-proc-deprecation.html 143 | .. _frontend_build_810: https://github.com/openedx/frontend-build/releases/tag/v8.1.0 144 | -------------------------------------------------------------------------------- /docs/how_tos/assets/paragon-theme-loader.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/docs/how_tos/assets/paragon-theme-loader.png -------------------------------------------------------------------------------- /docs/how_tos/automatic-case-conversion.rst: -------------------------------------------------------------------------------- 1 | ##################################################################### 2 | How to: Convert SnakeCase to CamelCase automatically for API Requests 3 | ##################################################################### 4 | 5 | Introduction 6 | ************ 7 | 8 | When using the HTTP client from ``@edx/frontend-platform``, you are making an API request to an 9 | Open edX service which requires you to handle snake-cased <-> camelCase conversions manually. The manual conversion quickly gets 10 | tedious, and is error prone if you forget to do it. 11 | 12 | Here is how you can configure the HTTP client to automatically convert snake_case <-> camelCase for you. 13 | 14 | How do I use configure automatic case conversion? 15 | ************************************************* 16 | 17 | You want to install `axios-case-converter `_, and add it 18 | as a middleware when calling ``initialize`` in the consumer:: 19 | 20 | import axiosCaseConverter from 'axios-case-converter'; 21 | 22 | initialize({ 23 | messages: [], 24 | requireAuthenticatedUser: true, 25 | hydrateAuthenticatedUser: true, 26 | authMiddleware: [axiosCaseConverter], 27 | }); 28 | 29 | Or, if you choose to use ``configure`` instead:: 30 | 31 | import axiosCaseConverter from 'axios-case-converter'; 32 | 33 | configure({ 34 | loggingService: getLoggingService(), 35 | config: getConfig(), 36 | options: { 37 | middleware: [axiosCaseConverter, (client) => axiosRetry(client, { retries: 3 })] 38 | } 39 | }); 40 | 41 | By default the middleware will convert camelCase -> snake_case for payloads, and snake_case -> camelCase for responses. 42 | If you want to customize middleware behavior, i.e. only have responses transformed, you can configure it like this:: 43 | initialize({ 44 | messages: [], 45 | requireAuthenticatedUser: true, 46 | hydrateAuthenticatedUser: true, 47 | authMiddleware: [(client) => axiosCaseConverter(client, { 48 | // options for the middleware 49 | ignoreHeaders: true, // don't convert headers 50 | caseMiddleware: { 51 | requestInterceptor: (config) => { 52 | return config; 53 | } 54 | } 55 | })], 56 | }); 57 | 58 | See `axios-case-converter `_ for more details on configurations supported by the package. 59 | -------------------------------------------------------------------------------- /docs/how_tos/caching.rst: -------------------------------------------------------------------------------- 1 | ############################ 2 | How to: Caching API Requests 3 | ############################ 4 | 5 | .. contents:: Table of Contents 6 | 7 | Introduction 8 | ************ 9 | 10 | Often, a web application needs to make the same repeated API calls, where the data returned is mostly static (i.e., does not change often). In such situations, caching is a viable solution to prevent unnecessary and potentially expensive operations, resulting in increased performance and a better user experience. 11 | 12 | When considering caching options, there's a few approaches to consider: 13 | 14 | Server caching 15 | ============== 16 | 17 | Server caching helps to limit the cost of an API request on the server and its dependent systems. When a client makes a request to the API, the server will check for a local copy of the requested resource. If the local resource exists, the server will respond with it; otherwise, the request is processed normally (e.g., performing database lookups). With this approach, the cost savings to the server may be significant, utilizing less resources. 18 | 19 | Client caching 20 | ============== 21 | 22 | Client caching helps to limit the cost of an API request incurred by the user. Rather than relying on a API implementing server caching, commonly referenced data may be cached locally within the client (i.e., browser). Similar to server caching, on the first request of an API, the request will occur as usual but the response data will be stored in the client. 23 | 24 | However, on subsequent requests (within a given timeframe), the client will check if a copy of the requested resource exists. If it does, it will read from the client cache rather than making a network request to the API. This has the benefit of freeing up resources on the server as it no longer needs to handle repeat queries within a given timeframe. 25 | 26 | Typically, client caching stores data for a given time (e.g., 5 minutes) since it was last requested. This allows the client cache to be dynamic in the sense that data will be eventually consistent and unneeded local data can be cleared once it is no longer relevant. 27 | 28 | Hybrid caching 29 | ============== 30 | 31 | Both server and client caching can be combined to provide best of both worlds. Regardless of how an API request is made, utilizing a hybrid approach will ensure data is read from a local cache first, whether that be on the server or the client. 32 | 33 | How do I use client caching with ``@edx/frontend-platform?`` 34 | ************************************************************ 35 | 36 | The ``@edx/frontend-platform`` package supports an Axios HTTP client that uses client caching under the hood through the use of `axios-cache-interceptor `_. 37 | 38 | Currently, `localForage `_ is configured to be the underlying storage; ``localForage`` is a package that provides a similar API to browser's localStorage, except is asynchronous (non-blocking). ``localForage`` is configured to prefer using IndexedDB, falling back to localStorage if browser support for IndexedDB is lacking. If all else fails, an in-memory store is used instead. IndexedDB is more performant and may contain a broader range (and volume) of data in comparison to localStorage (see `browser support `_ for IndexedDB). 39 | 40 | When importing an HTTP client from ``@edx/frontend-platform``, you may specify options for how you wish to configure the HTTP client:: 41 | 42 | import { getAuthenticatedHttpClient } from '@edx/frontend-platform'; 43 | 44 | // ``cachedHttpClient`` is configured to use client caching under the hood. 45 | const cachedHttpClient = getAuthenticatedHttpClient({ useCache: true }); 46 | 47 | The examples below demonstrate how to configure commonly used caching behavior on a per-request basis. For more use-cases, you may refer to the `axios-cache-interceptor documentation `_. 48 | 49 | Overriding the request id 50 | ========================= 51 | 52 | Every request passed through the axios-cache-interceptor interceptor has an id. Each request id is responsible for binding a request to its cache 53 | for referencing (or invalidating) it later. 54 | 55 | const { id: requestId} = cachedHttpClient.get('/courses/', { 56 | id: 'custom-id' 57 | }); 58 | 59 | Modify expiry behavior for a single request 60 | =========================================== 61 | 62 | By default, API requests using the cached HTTP client will be cached for 5 minutes. However, cache options may be configured on a per-request basis:: 63 | 64 | cachedHttpClient.get('/courses/', { 65 | cache: { 66 | ttl: 15 * 60 * 1000, // 15 minutes instead of the default 5. 67 | }, 68 | }); 69 | 70 | Invalidate cache for a single request 71 | ===================================== 72 | 73 | In certain situations, it may be necessary to invalidate cached data to force a true network request to the server:: 74 | 75 | cachedHttpClient.get('/courses/', { 76 | cache: { 77 | override: true, // This will replace the cache when the response arrives. 78 | }, 79 | }); 80 | 81 | To remove the cache of a request:: 82 | 83 | await axios.storage.remove('list-posts'); 84 | 85 | 86 | Check if response is served from network or from cache 87 | ====================================================== 88 | 89 | If there is a need to know whether a response was served from the network (i.e., server) or from the local client cache, you may refer to the ``response.request`` object:: 90 | 91 | cachedHttpClient.get('/courses/').then((response) => { 92 | console.log(response.cached); // will be true if served from the client cache, false otherwise 93 | }); 94 | -------------------------------------------------------------------------------- /docs/removeExport.js: -------------------------------------------------------------------------------- 1 | /** 2 | * JSDoc plugin. 3 | * 4 | * Modifies the source code to remove the "export" keyword before JS doc sees it. This removes 5 | * "exports." prefixes from documented members. 6 | * 7 | * @module plugins/exportKiller 8 | */ 9 | exports.handlers = { 10 | beforeParse(e) { 11 | e.source = e.source.replace(/(\nexport function)/g, $ => { 12 | return `\nfunction`; 13 | }); 14 | e.source = e.source.replace(/(\nexport const)/g, $ => { 15 | return `\nconst`; 16 | }); 17 | e.source = e.source.replace(/(\nexport default)/g, $ => { 18 | return `\n`; 19 | }); 20 | e.source = e.source.replace(/(\nexport async function)/g, $ => { 21 | return `\nasync function`; 22 | }); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /docs/template/edx/README.md: -------------------------------------------------------------------------------- 1 | The default template for JSDoc 4 uses: the [Underscore Template library](http://underscorejs.org/). 2 | 3 | 4 | ## Generating Typeface Fonts 5 | 6 | The default template uses the [OpenSans](https://www.google.com/fonts/specimen/Open+Sans) typeface. The font files can be regenerated as follows: 7 | 8 | 1. Open the [OpenSans page at Font Squirrel](). 9 | 2. Click on the 'Webfont Kit' tab. 10 | 3. Either leave the subset drop-down as 'Western Latin (Default)', or, if we decide we need more glyphs, than change it to 'No Subsetting'. 11 | 4. Click the 'DOWNLOAD @FONT-FACE KIT' button. 12 | 5. For each typeface variant we plan to use, copy the 'eot', 'svg' and 'woff' files into the 'templates/default/static/fonts' directory. 13 | -------------------------------------------------------------------------------- /docs/template/edx/static/fonts/OpenSans-Bold-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/docs/template/edx/static/fonts/OpenSans-Bold-webfont.eot -------------------------------------------------------------------------------- /docs/template/edx/static/fonts/OpenSans-Bold-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/docs/template/edx/static/fonts/OpenSans-Bold-webfont.woff -------------------------------------------------------------------------------- /docs/template/edx/static/fonts/OpenSans-BoldItalic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/docs/template/edx/static/fonts/OpenSans-BoldItalic-webfont.eot -------------------------------------------------------------------------------- /docs/template/edx/static/fonts/OpenSans-BoldItalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/docs/template/edx/static/fonts/OpenSans-BoldItalic-webfont.woff -------------------------------------------------------------------------------- /docs/template/edx/static/fonts/OpenSans-Italic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/docs/template/edx/static/fonts/OpenSans-Italic-webfont.eot -------------------------------------------------------------------------------- /docs/template/edx/static/fonts/OpenSans-Italic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/docs/template/edx/static/fonts/OpenSans-Italic-webfont.woff -------------------------------------------------------------------------------- /docs/template/edx/static/fonts/OpenSans-Light-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/docs/template/edx/static/fonts/OpenSans-Light-webfont.eot -------------------------------------------------------------------------------- /docs/template/edx/static/fonts/OpenSans-Light-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/docs/template/edx/static/fonts/OpenSans-Light-webfont.woff -------------------------------------------------------------------------------- /docs/template/edx/static/fonts/OpenSans-LightItalic-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/docs/template/edx/static/fonts/OpenSans-LightItalic-webfont.eot -------------------------------------------------------------------------------- /docs/template/edx/static/fonts/OpenSans-LightItalic-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/docs/template/edx/static/fonts/OpenSans-LightItalic-webfont.woff -------------------------------------------------------------------------------- /docs/template/edx/static/fonts/OpenSans-Regular-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/docs/template/edx/static/fonts/OpenSans-Regular-webfont.eot -------------------------------------------------------------------------------- /docs/template/edx/static/fonts/OpenSans-Regular-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/docs/template/edx/static/fonts/OpenSans-Regular-webfont.woff -------------------------------------------------------------------------------- /docs/template/edx/static/scripts/linenumber.js: -------------------------------------------------------------------------------- 1 | /*global document */ 2 | (() => { 3 | const source = document.getElementsByClassName('prettyprint source linenums'); 4 | let i = 0; 5 | let lineNumber = 0; 6 | let lineId; 7 | let lines; 8 | let totalLines; 9 | let anchorHash; 10 | 11 | if (source && source[0]) { 12 | anchorHash = document.location.hash.substring(1); 13 | lines = source[0].getElementsByTagName('li'); 14 | totalLines = lines.length; 15 | 16 | for (; i < totalLines; i++) { 17 | lineNumber++; 18 | lineId = `line${lineNumber}`; 19 | lines[i].id = lineId; 20 | if (lineId === anchorHash) { 21 | lines[i].className += ' selected'; 22 | } 23 | } 24 | } 25 | })(); 26 | -------------------------------------------------------------------------------- /docs/template/edx/static/scripts/prettify/lang-css.js: -------------------------------------------------------------------------------- 1 | PR.registerLangHandler(PR.createSimpleLexer([["pln",/^[\t\n\f\r ]+/,null," \t\r\n "]],[["str",/^"(?:[^\n\f\r"\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*"/,null],["str",/^'(?:[^\n\f\r'\\]|\\(?:\r\n?|\n|\f)|\\[\S\s])*'/,null],["lang-css-str",/^url\(([^"')]*)\)/i],["kwd",/^(?:url|rgb|!important|@import|@page|@media|@charset|inherit)(?=[^\w-]|$)/i,null],["lang-css-kw",/^(-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*)\s*:/i],["com",/^\/\*[^*]*\*+(?:[^*/][^*]*\*+)*\//],["com", 2 | /^(?:<\!--|--\>)/],["lit",/^(?:\d+|\d*\.\d+)(?:%|[a-z]+)?/i],["lit",/^#[\da-f]{3,6}/i],["pln",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i],["pun",/^[^\s\w"']+/]]),["css"]);PR.registerLangHandler(PR.createSimpleLexer([],[["kwd",/^-?(?:[_a-z]|\\[\da-f]+ ?)(?:[\w-]|\\\\[\da-f]+ ?)*/i]]),["css-kw"]);PR.registerLangHandler(PR.createSimpleLexer([],[["str",/^[^"')]+/]]),["css-str"]); 3 | -------------------------------------------------------------------------------- /docs/template/edx/static/styles/prettify-jsdoc.css: -------------------------------------------------------------------------------- 1 | /* JSDoc prettify.js theme */ 2 | 3 | /* plain text */ 4 | .pln { 5 | color: #000000; 6 | font-weight: normal; 7 | font-style: normal; 8 | } 9 | 10 | /* string content */ 11 | .str { 12 | color: #006400; 13 | font-weight: normal; 14 | font-style: normal; 15 | } 16 | 17 | /* a keyword */ 18 | .kwd { 19 | color: #000000; 20 | font-weight: bold; 21 | font-style: normal; 22 | } 23 | 24 | /* a comment */ 25 | .com { 26 | font-weight: normal; 27 | font-style: italic; 28 | } 29 | 30 | /* a type name */ 31 | .typ { 32 | color: #000000; 33 | font-weight: normal; 34 | font-style: normal; 35 | } 36 | 37 | /* a literal value */ 38 | .lit { 39 | color: #006400; 40 | font-weight: normal; 41 | font-style: normal; 42 | } 43 | 44 | /* punctuation */ 45 | .pun { 46 | color: #000000; 47 | font-weight: bold; 48 | font-style: normal; 49 | } 50 | 51 | /* lisp open bracket */ 52 | .opn { 53 | color: #000000; 54 | font-weight: bold; 55 | font-style: normal; 56 | } 57 | 58 | /* lisp close bracket */ 59 | .clo { 60 | color: #000000; 61 | font-weight: bold; 62 | font-style: normal; 63 | } 64 | 65 | /* a markup tag name */ 66 | .tag { 67 | color: #006400; 68 | font-weight: normal; 69 | font-style: normal; 70 | } 71 | 72 | /* a markup attribute name */ 73 | .atn { 74 | color: #006400; 75 | font-weight: normal; 76 | font-style: normal; 77 | } 78 | 79 | /* a markup attribute value */ 80 | .atv { 81 | color: #006400; 82 | font-weight: normal; 83 | font-style: normal; 84 | } 85 | 86 | /* a declaration */ 87 | .dec { 88 | color: #000000; 89 | font-weight: bold; 90 | font-style: normal; 91 | } 92 | 93 | /* a variable name */ 94 | .var { 95 | color: #000000; 96 | font-weight: normal; 97 | font-style: normal; 98 | } 99 | 100 | /* a function name */ 101 | .fun { 102 | color: #000000; 103 | font-weight: bold; 104 | font-style: normal; 105 | } 106 | 107 | /* Specify class=linenums on a pre to get line numbering */ 108 | ol.linenums { 109 | margin-top: 0; 110 | margin-bottom: 0; 111 | } 112 | -------------------------------------------------------------------------------- /docs/template/edx/static/styles/prettify-tomorrow.css: -------------------------------------------------------------------------------- 1 | /* Tomorrow Theme */ 2 | /* Original theme - https://github.com/chriskempson/tomorrow-theme */ 3 | /* Pretty printing styles. Used with prettify.js. */ 4 | /* SPAN elements with the classes below are added by prettyprint. */ 5 | /* plain text */ 6 | .pln { 7 | color: #4d4d4c; } 8 | 9 | @media screen { 10 | /* string content */ 11 | .str { 12 | color: #718c00; } 13 | 14 | /* a keyword */ 15 | .kwd { 16 | color: #8959a8; } 17 | 18 | /* a comment */ 19 | .com { 20 | color: #8e908c; } 21 | 22 | /* a type name */ 23 | .typ { 24 | color: #4271ae; } 25 | 26 | /* a literal value */ 27 | .lit { 28 | color: #f5871f; } 29 | 30 | /* punctuation */ 31 | .pun { 32 | color: #4d4d4c; } 33 | 34 | /* lisp open bracket */ 35 | .opn { 36 | color: #4d4d4c; } 37 | 38 | /* lisp close bracket */ 39 | .clo { 40 | color: #4d4d4c; } 41 | 42 | /* a markup tag name */ 43 | .tag { 44 | color: #c82829; } 45 | 46 | /* a markup attribute name */ 47 | .atn { 48 | color: #f5871f; } 49 | 50 | /* a markup attribute value */ 51 | .atv { 52 | color: #3e999f; } 53 | 54 | /* a declaration */ 55 | .dec { 56 | color: #f5871f; } 57 | 58 | /* a variable name */ 59 | .var { 60 | color: #c82829; } 61 | 62 | /* a function name */ 63 | .fun { 64 | color: #4271ae; } } 65 | /* Use higher contrast and text-weight for printable form. */ 66 | @media print, projection { 67 | .str { 68 | color: #060; } 69 | 70 | .kwd { 71 | color: #006; 72 | font-weight: bold; } 73 | 74 | .com { 75 | color: #600; 76 | font-style: italic; } 77 | 78 | .typ { 79 | color: #404; 80 | font-weight: bold; } 81 | 82 | .lit { 83 | color: #044; } 84 | 85 | .pun, .opn, .clo { 86 | color: #440; } 87 | 88 | .tag { 89 | color: #006; 90 | font-weight: bold; } 91 | 92 | .atn { 93 | color: #404; } 94 | 95 | .atv { 96 | color: #060; } } 97 | /* Style */ 98 | /* 99 | pre.prettyprint { 100 | background: white; 101 | font-family: Consolas, Monaco, 'Andale Mono', monospace; 102 | font-size: 12px; 103 | line-height: 1.5; 104 | border: 1px solid #ccc; 105 | padding: 10px; } 106 | */ 107 | 108 | /* Specify class=linenums on a pre to get line numbering */ 109 | ol.linenums { 110 | margin-top: 0; 111 | margin-bottom: 0; } 112 | 113 | /* IE indents via margin-left */ 114 | li.L0, 115 | li.L1, 116 | li.L2, 117 | li.L3, 118 | li.L4, 119 | li.L5, 120 | li.L6, 121 | li.L7, 122 | li.L8, 123 | li.L9 { 124 | /* */ } 125 | 126 | /* Alternate shading for lines */ 127 | li.L1, 128 | li.L3, 129 | li.L5, 130 | li.L7, 131 | li.L9 { 132 | /* */ } 133 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/augments.tmpl: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 |
    8 |
  • 9 |
10 | 11 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/details.tmpl: -------------------------------------------------------------------------------- 1 | " + data.defaultvalue + ""; 9 | defaultObjectClass = ' class="object-value"'; 10 | } 11 | ?> 12 | 16 | 17 |
Properties:
18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 |
Version:
27 |
28 | 29 | 30 | 31 |
Since:
32 |
33 | 34 | 35 | 36 |
Inherited From:
37 |
  • 38 | 39 |
40 | 41 | 42 | 43 |
Overrides:
44 |
  • 45 | 46 |
47 | 48 | 49 | 50 |
Implementations:
51 |
    52 | 53 |
  • 54 | 55 |
56 | 57 | 58 | 59 |
Implements:
60 |
    61 | 62 |
  • 63 | 64 |
65 | 66 | 67 | 68 |
Mixes In:
69 | 70 |
    71 | 72 |
  • 73 | 74 |
75 | 76 | 77 | 78 |
Deprecated:
  • Yes
82 | 83 | 84 | 85 |
Author:
86 |
87 |
    88 |
  • 89 |
90 |
91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 |
License:
100 |
101 | 102 | 103 | 104 |
Default Value:
105 |
    106 | > 107 |
108 | 109 | 110 | 111 |
Source:
112 |
  • 113 | , 114 |
115 | 116 | 117 | 118 |
Tutorials:
119 |
120 |
    121 |
  • 122 |
123 |
124 | 125 | 126 | 127 |
See:
128 |
129 |
    130 |
  • 131 |
132 |
133 | 134 | 135 | 136 |
To Do:
137 |
138 |
    139 |
  • 140 |
141 |
142 | 143 |
144 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/example.tmpl: -------------------------------------------------------------------------------- 1 | 2 |
3 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/examples.tmpl: -------------------------------------------------------------------------------- 1 | 8 |

9 | 10 |
11 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/exceptions.tmpl: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |
7 |
8 | 9 |
10 |
11 |
12 |
13 |
14 |
15 | Type 16 |
17 |
18 | 19 |
20 |
21 |
22 |
23 |
24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
32 | 33 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/layout.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | JSDoc: <?js= title ?> 6 | 7 | 8 | 9 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 |
21 |

22 | 23 | 24 |
25 | 26 | 29 | 30 |
31 | 32 |
33 | Documentation generated by JSDoc on 34 |
35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/mainpage.tmpl: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 |
8 |
9 |
10 | 11 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/members.tmpl: -------------------------------------------------------------------------------- 1 | 5 |

6 | 7 | 8 |

9 | 10 | 11 | 12 |
13 | 14 |
15 | 16 | 17 | 18 |
Type:
19 |
    20 |
  • 21 | 22 |
  • 23 |
24 | 25 | 26 | 27 | 28 | 29 |
Fires:
30 |
    31 |
  • 32 |
33 | 34 | 35 | 36 |
Example 1? 's':'' ?>
37 | 38 | 39 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/method.tmpl: -------------------------------------------------------------------------------- 1 | 5 | 6 | 7 |

Constructor

8 | 9 | 10 | 11 |

13 | 14 | 15 | 16 |

17 | 18 | 19 | 20 | 21 |
22 | 23 |
24 | 25 | 26 | 27 |
Extends:
28 | 29 | 30 | 31 | 32 |
Type:
33 |
    34 |
  • 35 | 36 |
  • 37 |
38 | 39 | 40 | 41 |
This:
42 |
43 | 44 | 45 | 46 |
Parameters:
47 | 48 | 49 | 50 | 51 | 52 | 53 |
Requires:
54 |
    55 |
  • 56 |
57 | 58 | 59 | 60 |
Fires:
61 |
    62 |
  • 63 |
64 | 65 | 66 | 67 |
Listens to Events:
68 |
    69 |
  • 70 |
71 | 72 | 73 | 74 |
Listeners of This Event:
75 |
    76 |
  • 77 |
78 | 79 | 80 | 81 |
Modifies:
82 | 1) { ?>
    84 |
  • 85 |
88 | 89 | 91 | 92 | 93 |
Throws:
94 | 1) { ?>
    96 |
  • 97 |
100 | 101 | 103 | 104 | 105 |
Returns:
106 | 1) { ?>
    108 |
  • 109 |
112 | 113 | 115 | 116 | 117 |
Yields:
118 | 1) { ?>
    120 |
  • 121 |
124 | 125 | 127 | 128 | 129 |
Example 1? 's':'' ?>
130 | 131 | 132 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/modifies.tmpl: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 |
7 |
8 | Type 9 |
10 |
11 | 12 |
13 |
14 | 15 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/params.tmpl: -------------------------------------------------------------------------------- 1 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 98 | 99 | 100 | 113 | 114 | 115 | 116 | 121 | 122 | 123 | 127 | 128 | 129 | 130 | 131 |
NameTypeAttributesDefaultDescription
94 | 95 | 96 | 97 | 101 | 102 | <optional>
103 | 104 | 105 | 106 | <nullable>
107 | 108 | 109 | 110 | <repeatable>
111 | 112 |
117 | 118 | 119 | 120 | 124 |
Properties
125 | 126 |
132 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/properties.tmpl: -------------------------------------------------------------------------------- 1 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 80 | 81 | 82 | 91 | 92 | 93 | 94 | 99 | 100 | 101 | 104 | 105 | 106 | 107 | 108 |
NameTypeAttributesDefaultDescription
76 | 77 | 78 | 79 | 83 | 84 | <optional>
85 | 86 | 87 | 88 | <nullable>
89 | 90 |
95 | 96 | 97 | 98 | 102 |
Properties
103 |
109 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/returns.tmpl: -------------------------------------------------------------------------------- 1 | 5 |
6 | 7 |
8 | 9 | 10 | 11 |
12 |
13 | Type 14 |
15 |
16 | 17 |
18 |
19 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/source.tmpl: -------------------------------------------------------------------------------- 1 | 4 |
5 |
6 |
7 |
8 |
-------------------------------------------------------------------------------- /docs/template/edx/tmpl/tutorial.tmpl: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 0) { ?> 5 |
    8 |
  • 9 |
10 | 11 | 12 |

13 |
14 | 15 |
16 | 17 |
18 | 19 |
20 | -------------------------------------------------------------------------------- /docs/template/edx/tmpl/type.tmpl: -------------------------------------------------------------------------------- 1 | 5 | 6 | | 7 | -------------------------------------------------------------------------------- /env.config.js: -------------------------------------------------------------------------------- 1 | // NOTE: This file is used by the example app. frontend-build expects the file 2 | // to be in the root of the repository. This is not used by the actual frontend-platform library. 3 | // Also note that in an actual application this file would be added to .gitignore. 4 | const config = { 5 | JS_FILE_VAR: 'JS_FILE_VAR_VALUE_FOR_EXAMPLE_APP', 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /example/AuthenticatedPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | /* eslint-disable import/no-extraneous-dependencies */ 5 | import { AppContext } from '@edx/frontend-platform/react'; 6 | /* eslint-enable import/no-extraneous-dependencies */ 7 | 8 | export default function AuthenticatedPage() { 9 | const { authenticatedUser, config } = useContext(AppContext); 10 | 11 | return ( 12 |
13 |

{config.SITE_NAME} authenticated page.

14 |

Hi there, {authenticatedUser.username}.

15 |

Visit public page.

16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /example/ExamplePage.jsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | /* eslint-disable import/no-extraneous-dependencies */ 5 | import { injectIntl, useIntl } from '@edx/frontend-platform/i18n'; 6 | import { logInfo } from '@edx/frontend-platform/logging'; 7 | import { AppContext } from '@edx/frontend-platform/react'; 8 | import { ensureConfig, mergeConfig, getConfig } from '@edx/frontend-platform'; 9 | /* eslint-enable import/no-extraneous-dependencies */ 10 | import messages from './messages'; 11 | 12 | mergeConfig({ 13 | EXAMPLE_VAR: process.env.EXAMPLE_VAR, 14 | }); 15 | 16 | ensureConfig([ 17 | 'EXAMPLE_VAR', 18 | 'JS_FILE_VAR', 19 | ], 'ExamplePage'); 20 | 21 | function AuthenticatedUser() { 22 | const { authenticatedUser } = useContext(AppContext); 23 | if (authenticatedUser === null) { 24 | return null; 25 | } 26 | return ( 27 |
28 |

Authenticated Username: {authenticatedUser.username}

29 |

30 | Authenticated user's name: 31 | {authenticatedUser.name} 32 | (Only available if user account has been fetched) 33 |

34 |
35 | ); 36 | } 37 | 38 | function ExamplePage() { 39 | const intl = useIntl(); 40 | 41 | useEffect(() => { 42 | logInfo('The example page can log info, which means logging is configured correctly.'); 43 | }, []); 44 | 45 | return ( 46 |
47 |

{getConfig().SITE_NAME} example page.

48 |

{intl.formatMessage(messages['example.message'])}

49 | 50 |

EXAMPLE_VAR env var came through: {getConfig().EXAMPLE_VAR}

51 |

JS_FILE_VAR var came through: {getConfig().JS_FILE_VAR}

52 |

Visit authenticated page.

53 |

Visit error page.

54 |
55 | ); 56 | } 57 | 58 | export default injectIntl(ExamplePage); 59 | -------------------------------------------------------------------------------- /example/index.jsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | /* eslint-disable import/no-extraneous-dependencies */ 4 | import { 5 | AppProvider, 6 | AuthenticatedPageRoute, 7 | ErrorPage, 8 | PageWrap, 9 | } from '@edx/frontend-platform/react'; 10 | import { APP_INIT_ERROR, APP_READY, initialize } from '@edx/frontend-platform'; 11 | import { subscribe } from '@edx/frontend-platform/pubSub'; 12 | /* eslint-enable import/no-extraneous-dependencies */ 13 | import { Routes, Route } from 'react-router-dom'; 14 | 15 | import './index.scss'; 16 | import ExamplePage from './ExamplePage'; 17 | import AuthenticatedPage from './AuthenticatedPage'; 18 | 19 | const container = document.getElementById('root'); 20 | const root = createRoot(container); 21 | 22 | subscribe(APP_READY, () => { 23 | root.render( 24 | 25 | 26 | 27 | } /> 28 | } 31 | /> 32 | } /> 33 | 34 | 35 | , 36 | ); 37 | }); 38 | 39 | subscribe(APP_INIT_ERROR, (error) => { 40 | root.render(); 41 | }); 42 | 43 | initialize({ 44 | messages: [], 45 | requireAuthenticatedUser: false, 46 | hydrateAuthenticatedUser: true, 47 | }); 48 | -------------------------------------------------------------------------------- /example/index.scss: -------------------------------------------------------------------------------- 1 | @use "@openedx/paragon/styles/css/core/custom-media-breakpoints.css" as paragonCustomMediaBreakpoints; 2 | -------------------------------------------------------------------------------- /example/messages.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-extraneous-dependencies */ 2 | import { defineMessages } from '@edx/frontend-platform/i18n'; 3 | /* eslint-enable import/no-extraneous-dependencies */ 4 | 5 | const messages = defineMessages({ 6 | 'example.message': { 7 | id: 'example.message', 8 | defaultMessage: 'This message proves that i18n is working.', 9 | description: 'A message that proves that internationalization is working.', 10 | }, 11 | }); 12 | 13 | export default messages; 14 | -------------------------------------------------------------------------------- /example/src/i18n/README.md: -------------------------------------------------------------------------------- 1 | # Test i18n directories 2 | 3 | These test files are used by the `src/i18n/scripts/intl-imports.test.js` file. 4 | -------------------------------------------------------------------------------- /example/src/i18n/messages/frontend-app-sample/ar.json: -------------------------------------------------------------------------------- 1 | { 2 | "learning.accessExpiration.deadline": "قم بالترقية قبل {date} للاستفادة من دخول غير محدود للمساق طالما هو موجود على الموقع.", 3 | "learning.accessExpiration.header": "تنتهي صلاحية دخول المساق كمستمع في {date}" 4 | } -------------------------------------------------------------------------------- /example/src/i18n/messages/frontend-app-sample/eo.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /example/src/i18n/messages/frontend-app-sample/es_419.json: -------------------------------------------------------------------------------- 1 | { 2 | "learning.accessExpiration.deadline": "Mejora de categoría antes del {fecha} para obtener acceso ilimitado al curso mientras exista en el sitio.", 3 | "learning.accessExpiration.header": "El acceso a tomar el curso de forma gratuita expira el {fecha}" 4 | } -------------------------------------------------------------------------------- /example/src/i18n/messages/frontend-component-emptylangs/ar.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /example/src/i18n/messages/frontend-component-nolangs/.gitignore: -------------------------------------------------------------------------------- 1 | # Placeholder file -------------------------------------------------------------------------------- /example/src/i18n/messages/frontend-component-singlelang/ar.json: -------------------------------------------------------------------------------- 1 | { 2 | "learning.accessExpiration.header3": "تنتهي صلاحية دخول المساق كمستمع في {date}" 3 | } -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const { createConfig } = require('@openedx/frontend-build'); 2 | 3 | module.exports = createConfig('jest', { 4 | setupFilesAfterEnv: [ 5 | '/src/setupTest.js', 6 | ], 7 | modulePathIgnorePatterns: [ 8 | '/dist/', 9 | ], 10 | testTimeout: 20000, 11 | }); 12 | -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "tags": { 3 | "allowUnknownTags": true, 4 | "dictionaries": ["jsdoc"] 5 | }, 6 | "source": { 7 | "include": ["src", "package.json", "README.md"], 8 | "includePattern": ".+\\.js(doc|x)?$", 9 | "excludePattern": "(node_modules/|docs)" 10 | }, 11 | "plugins": [ 12 | "plugins/markdown", 13 | "docs/addTagsPlugin", 14 | "docs/removeExport" 15 | ], 16 | "templates": { 17 | "cleverLinks": false, 18 | "monospaceLinks": true, 19 | "useLongnameInNav": false, 20 | "showInheritedInNav": true, 21 | "default": { 22 | "staticFiles": { 23 | "include": [ 24 | "./service-interface.png" 25 | ] 26 | } 27 | } 28 | }, 29 | "opts": { 30 | "destination": "./docs/api", 31 | "encoding": "utf8", 32 | "private": true, 33 | "recurse": true, 34 | "template": "docs/template/edx" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /openedx.yaml: -------------------------------------------------------------------------------- 1 | # openedx.yaml 2 | 3 | --- 4 | owner: edx/fedx-team 5 | tags: 6 | - library 7 | - platform 8 | - react 9 | - i18n 10 | - analytics 11 | - logging 12 | - authentication 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@edx/frontend-platform", 3 | "version": "1.0.0-semantically-released", 4 | "description": "Foundational application framework for Open edX micro-frontend applications.", 5 | "main": "index.js", 6 | "publishConfig": { 7 | "access": "public" 8 | }, 9 | "sideEffects": false, 10 | "scripts": { 11 | "build": "make build", 12 | "docs": "jsdoc -c jsdoc.json", 13 | "docs-watch": "nodemon -w src -w docs/template -w README.md -e js,jsx --exec npm run docs", 14 | "lint": "fedx-scripts eslint --ext .js --ext .jsx .", 15 | "i18n_extract": "fedx-scripts formatjs extract", 16 | "snapshot": "fedx-scripts jest --updateSnapshot", 17 | "start": "fedx-scripts webpack-dev-server --progress", 18 | "test": "fedx-scripts jest --coverage", 19 | "test:watch": "npm run test -- --watch" 20 | }, 21 | "bin": { 22 | "intl-imports.js": "i18n/scripts/intl-imports.js", 23 | "transifex-utils.js": "i18n/scripts/transifex-utils.js" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/openedx/frontend-platform.git" 28 | }, 29 | "author": "edX", 30 | "license": "AGPL-3.0", 31 | "bugs": { 32 | "url": "https://github.com/openedx/frontend-platform/issues" 33 | }, 34 | "homepage": "https://github.com/openedx/frontend-platform#readme", 35 | "devDependencies": { 36 | "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", 37 | "@edx/browserslist-config": "1.5.0", 38 | "@openedx/frontend-build": "^14.3.0", 39 | "@openedx/paragon": "^23.3.0", 40 | "@testing-library/dom": "10.4.0", 41 | "@testing-library/jest-dom": "6.6.3", 42 | "@testing-library/react": "16.2.0", 43 | "@testing-library/user-event": "14.6.1", 44 | "axios-mock-adapter": "^1.22.0", 45 | "jest-environment-jsdom": "29.7.0", 46 | "jest-localstorage-mock": "^2.4.26", 47 | "jsdoc": "^4.0.0", 48 | "nodemon": "3.1.10", 49 | "prop-types": "15.8.1", 50 | "react": "18.3.1", 51 | "react-dom": "18.3.1", 52 | "react-redux": "^8.1.1", 53 | "react-router-dom": "^6.6.1", 54 | "redux": "4.2.1" 55 | }, 56 | "dependencies": { 57 | "@cospired/i18n-iso-languages": "4.2.0", 58 | "@formatjs/intl-pluralrules": "4.3.3", 59 | "@formatjs/intl-relativetimeformat": "10.0.1", 60 | "axios": "1.9.0", 61 | "axios-cache-interceptor": "1.8.0", 62 | "form-urlencoded": "4.1.4", 63 | "glob": "7.2.3", 64 | "history": "4.10.1", 65 | "i18n-iso-countries": "4.3.1", 66 | "jwt-decode": "3.1.2", 67 | "localforage": "1.10.0", 68 | "localforage-memoryStorageDriver": "0.9.2", 69 | "lodash.camelcase": "4.3.0", 70 | "lodash.memoize": "4.1.2", 71 | "lodash.merge": "4.6.2", 72 | "lodash.snakecase": "4.1.1", 73 | "pubsub-js": "1.9.5", 74 | "react-intl": "6.8.9", 75 | "universal-cookie": "4.0.4" 76 | }, 77 | "peerDependencies": { 78 | "@openedx/frontend-build": ">= 14.0.0", 79 | "@openedx/paragon": ">= 21.5.7 < 24.0.0", 80 | "prop-types": ">=15.7.2 <16.0.0", 81 | "react": "^16.9.0 || ^17.0.0 || ^18.0.0", 82 | "react-dom": "^16.9.0 || ^17.0.0 || ^18.0.0", 83 | "react-redux": "^7.1.1 || ^8.1.1", 84 | "react-router-dom": "^6.0.0", 85 | "redux": "^4.0.4" 86 | }, 87 | "peerDependenciesMeta": { 88 | "@openedx/frontend-build": { 89 | "optional": true, 90 | "reason": "This package is only a peer dependency to ensure using a minimum compatible version that provides env.config and PARAGON_THEME support. It is not needed at runtime, and may be omitted with `--omit=optional`." 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Example 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | "schedule:weekly", 5 | ":automergeLinters", 6 | ":automergeMinor", 7 | ":automergeTesters", 8 | ":enableVulnerabilityAlerts", 9 | ":rebaseStalePrs", 10 | ":semanticCommits", 11 | ":updateNotScheduled" 12 | ], 13 | "packageRules": [ 14 | { 15 | "matchDepTypes": [ 16 | "devDependencies" 17 | ], 18 | "matchUpdateTypes": [ 19 | "lockFileMaintenance", 20 | "minor", 21 | "patch", 22 | "pin" 23 | ], 24 | "automerge": true 25 | }, 26 | { 27 | "matchPackagePatterns": ["@edx", "@openedx"], 28 | "matchUpdateTypes": ["minor", "patch"], 29 | "automerge": true 30 | } 31 | ], 32 | "timezone": "America/New_York" 33 | } 34 | -------------------------------------------------------------------------------- /service-interface.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openedx/frontend-platform/79d211a3ede966a6889329b932b1961b6cdb4ac6/service-interface.png -------------------------------------------------------------------------------- /src/analytics/MockAnalyticsService.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The MockAnalyticsService implements all functions of AnalyticsService as Jest mocks (jest.fn())). 3 | * It emulates the behavior of a real analytics service but witohut making any requests. It has no 4 | * other functionality. 5 | * 6 | * @implements {AnalyticsService} 7 | * @memberof module:Analytics 8 | */ 9 | class MockAnalyticsService { 10 | static hasIdentifyBeenCalled = false; 11 | 12 | constructor({ httpClient, loggingService }) { 13 | this.loggingService = loggingService; 14 | this.httpClient = httpClient; 15 | } 16 | 17 | checkIdentifyCalled = jest.fn(() => { 18 | if (!this.hasIdentifyBeenCalled) { 19 | this.loggingService.logError('Identify must be called before other tracking events.'); 20 | } 21 | }); 22 | 23 | /** 24 | * Returns a resolved promise. 25 | * 26 | * @returns {Promise} The promise returned by HttpClient.post. 27 | */ 28 | sendTrackingLogEvent = jest.fn(() => Promise.resolve()); 29 | 30 | /** 31 | * No-op, but records that identify has been called. 32 | * 33 | * @param {string} userId 34 | * @throws {Error} If userId argument is not supplied. 35 | */ 36 | identifyAuthenticatedUser = jest.fn((userId) => { 37 | if (!userId) { 38 | throw new Error('UserId is required for identifyAuthenticatedUser.'); 39 | } 40 | this.hasIdentifyBeenCalled = true; 41 | }); 42 | 43 | /** 44 | * No-op, but records that it has been called to prevent double-identification. 45 | * @returns {Promise} A resolved promise. 46 | */ 47 | identifyAnonymousUser = jest.fn(() => { 48 | this.hasIdentifyBeenCalled = true; 49 | return Promise.resolve(); 50 | }); 51 | 52 | /** 53 | * Logs the event to the console. 54 | * 55 | * Checks whether identify has been called, logging an error to the logging service if not. 56 | */ 57 | sendTrackEvent = jest.fn(() => { 58 | this.checkIdentifyCalled(); 59 | }); 60 | 61 | /** 62 | * Logs the event to the console. 63 | * 64 | * Checks whether identify has been called, logging an error to the logging service if not. 65 | */ 66 | sendPageEvent = jest.fn(() => { 67 | this.checkIdentifyCalled(); 68 | }); 69 | } 70 | 71 | export default MockAnalyticsService; 72 | -------------------------------------------------------------------------------- /src/analytics/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | configure, 3 | identifyAnonymousUser, 4 | identifyAuthenticatedUser, 5 | sendPageEvent, 6 | sendTrackEvent, 7 | sendTrackingLogEvent, 8 | getAnalyticsService, 9 | resetAnalyticsService, 10 | } from './interface'; 11 | export { default as SegmentAnalyticsService } from './SegmentAnalyticsService'; 12 | export { default as MockAnalyticsService } from './MockAnalyticsService'; 13 | -------------------------------------------------------------------------------- /src/analytics/interface.js: -------------------------------------------------------------------------------- 1 | /** 2 | * #### Import members from **@edx/frontend-platform/analytics** 3 | * 4 | * Contains a shared interface for tracking events. Has a default implementation of 5 | * SegmentAnalyticsService, which supports Segment and the Tracking Log API (hosted in LMS). 6 | * 7 | * The `initialize` function performs much of the analytics configuration for you. If, however, 8 | * you're not using the `initialize` function, analytics can be configured via: 9 | * 10 | * ``` 11 | * import { configure, SegmentAnalyticsService } from '@edx/frontend-platform/analytics'; 12 | * import { getConfig } from '@edx/frontend-platform'; 13 | * import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; 14 | * import { getLoggingService } from '@edx/frontend-platform/logging'; 15 | * 16 | * configure(SegmentAnalyticsService, { 17 | * config: getConfig(), 18 | * loggingService: getLoggingService(), 19 | * httpClient: getAuthenticatedHttpClient(), 20 | * }); 21 | * ``` 22 | * 23 | * As shown in this example, analytics depends on the configuration document, logging, and having 24 | * an authenticated HTTP client. 25 | * 26 | * @module Analytics 27 | */ 28 | import PropTypes from 'prop-types'; 29 | 30 | const optionsShape = { 31 | config: PropTypes.object.isRequired, 32 | httpClient: PropTypes.oneOfType([PropTypes.func, PropTypes.object]).isRequired, 33 | loggingService: PropTypes.shape({ 34 | logError: PropTypes.func.isRequired, 35 | logInfo: PropTypes.func.isRequired, 36 | }).isRequired, 37 | }; 38 | 39 | const serviceShape = { 40 | sendTrackingLogEvent: PropTypes.func.isRequired, 41 | identifyAuthenticatedUser: PropTypes.func.isRequired, 42 | identifyAnonymousUser: PropTypes.func.isRequired, 43 | sendTrackEvent: PropTypes.func.isRequired, 44 | sendPageEvent: PropTypes.func.isRequired, 45 | }; 46 | 47 | let service; 48 | 49 | /** 50 | * 51 | * @param {class} AnalyticsService 52 | * @param {*} options 53 | * @returns {AnalyticsService} 54 | */ 55 | export function configure(AnalyticsService, options) { 56 | PropTypes.checkPropTypes(optionsShape, options, 'property', 'Analytics'); 57 | service = new AnalyticsService(options); 58 | PropTypes.checkPropTypes(serviceShape, service, 'property', 'AnalyticsService'); 59 | return service; 60 | } 61 | 62 | /** 63 | * 64 | * @param {*} eventName 65 | * @param {*} properties 66 | * @returns {Promise} 67 | */ 68 | export function sendTrackingLogEvent(eventName, properties) { 69 | return service.sendTrackingLogEvent(eventName, properties); 70 | } 71 | 72 | /** 73 | * 74 | * 75 | * @param {*} userId 76 | * @param {*} traits 77 | */ 78 | export function identifyAuthenticatedUser(userId, traits) { 79 | service.identifyAuthenticatedUser(userId, traits); 80 | } 81 | 82 | /** 83 | * 84 | * 85 | * @param {*} traits 86 | * @returns {Promise} 87 | */ 88 | export function identifyAnonymousUser(traits) { 89 | return service.identifyAnonymousUser(traits); 90 | } 91 | 92 | /** 93 | * 94 | * 95 | * @param {*} eventName 96 | * @param {*} properties 97 | */ 98 | export function sendTrackEvent(eventName, properties) { 99 | service.sendTrackEvent(eventName, properties); 100 | } 101 | 102 | /** 103 | * 104 | * 105 | * @param {*} category 106 | * @param {*} name 107 | * @param {*} properties 108 | */ 109 | export function sendPageEvent(category, name, properties) { 110 | service.sendPageEvent(category, name, properties); 111 | } 112 | 113 | /** 114 | * 115 | * 116 | * @returns {AnalyticsService} 117 | */ 118 | export function getAnalyticsService() { 119 | if (!service) { 120 | throw Error('You must first configure the analytics service.'); 121 | } 122 | 123 | return service; 124 | } 125 | 126 | /** 127 | * 128 | */ 129 | export function resetAnalyticsService() { 130 | service = null; 131 | } 132 | 133 | /** 134 | * @name AnalyticsService 135 | * @interface 136 | * @memberof module:Analytics 137 | * @property {function} identifyAnonymousUser 138 | * @property {function} identifyAuthenticatedUser 139 | * @property {function} sendPageEvent 140 | * @property {function} sendTrackEvent 141 | * @property {function} sendTrackingLogEvent 142 | */ 143 | -------------------------------------------------------------------------------- /src/auth/AxiosCsrfTokenService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { getUrlParts, processAxiosErrorAndThrow } from './utils'; 3 | 4 | export default class AxiosCsrfTokenService { 5 | constructor(csrfTokenApiPath) { 6 | this.csrfTokenApiPath = csrfTokenApiPath; 7 | this.httpClient = axios.create(); 8 | // Set withCredentials to true. Enables cross-site Access-Control requests 9 | // to be made using cookies, authorization headers or TLS client 10 | // certificates. More on MDN: 11 | // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials 12 | this.httpClient.defaults.withCredentials = true; 13 | this.httpClient.defaults.headers['USE-JWT-COOKIE'] = true; 14 | 15 | this.csrfTokenCache = {}; 16 | this.csrfTokenRequestPromises = {}; 17 | } 18 | 19 | async getCsrfToken(url) { 20 | let urlParts; 21 | try { 22 | urlParts = getUrlParts(url); 23 | } catch (e) { 24 | // If the url is not parsable it's likely because a relative 25 | // path was supplied as the url. This is acceptable and in 26 | // this case we should use the current origin of the page. 27 | urlParts = getUrlParts(global.location.origin); 28 | } 29 | 30 | const { protocol, domain } = urlParts; 31 | const csrfToken = this.csrfTokenCache[domain]; 32 | 33 | if (csrfToken) { 34 | return csrfToken; 35 | } 36 | 37 | if (!this.csrfTokenRequestPromises[domain]) { 38 | this.csrfTokenRequestPromises[domain] = this.httpClient 39 | .get(`${protocol}://${domain}${this.csrfTokenApiPath}`) 40 | .then((response) => { 41 | this.csrfTokenCache[domain] = response.data.csrfToken; 42 | return this.csrfTokenCache[domain]; 43 | }) 44 | .catch(processAxiosErrorAndThrow) 45 | .finally(() => { 46 | delete this.csrfTokenRequestPromises[domain]; 47 | }); 48 | } 49 | 50 | return this.csrfTokenRequestPromises[domain]; 51 | } 52 | 53 | clearCsrfTokenCache() { 54 | this.csrfTokenCache = {}; 55 | } 56 | 57 | getHttpClient() { 58 | return this.httpClient; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/auth/AxiosJwtTokenService.js: -------------------------------------------------------------------------------- 1 | import Cookies from 'universal-cookie'; 2 | import jwtDecode from 'jwt-decode'; 3 | import axios from 'axios'; 4 | import { logFrontendAuthError, processAxiosErrorAndThrow } from './utils'; 5 | import createRetryInterceptor from './interceptors/createRetryInterceptor'; 6 | 7 | export default class AxiosJwtTokenService { 8 | static isTokenExpired(token) { 9 | return !token || token.exp < Date.now() / 1000; 10 | } 11 | 12 | constructor(loggingService, tokenCookieName, tokenRefreshEndpoint) { 13 | this.loggingService = loggingService; 14 | this.tokenCookieName = tokenCookieName; 15 | this.tokenRefreshEndpoint = tokenRefreshEndpoint; 16 | 17 | this.httpClient = axios.create(); 18 | // Set withCredentials to true. Enables cross-site Access-Control requests 19 | // to be made using cookies, authorization headers or TLS client 20 | // certificates. More on MDN: 21 | // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/withCredentials 22 | this.httpClient.defaults.withCredentials = true; 23 | // Add retries to this axios instance 24 | this.httpClient.interceptors.response.use( 25 | response => response, 26 | createRetryInterceptor({ httpClient: this.httpClient }), 27 | ); 28 | 29 | this.cookies = new Cookies(); 30 | this.refreshRequestPromises = {}; 31 | } 32 | 33 | getHttpClient() { 34 | return this.httpClient; 35 | } 36 | 37 | decodeJwtCookie() { 38 | const cookieValue = this.cookies.get(this.tokenCookieName); 39 | 40 | if (cookieValue) { 41 | try { 42 | return jwtDecode(cookieValue); 43 | } catch (e) { 44 | const error = Object.create(e); 45 | error.message = 'Error decoding JWT token'; 46 | error.customAttributes = { cookieValue }; 47 | throw error; 48 | } 49 | } 50 | 51 | return null; 52 | } 53 | 54 | refresh() { 55 | let responseServerEpochSeconds = 0; 56 | 57 | if (this.refreshRequestPromises[this.tokenCookieName] === undefined) { 58 | const makeRefreshRequest = async () => { 59 | let axiosResponse; 60 | try { 61 | try { 62 | axiosResponse = await this.httpClient.post(this.tokenRefreshEndpoint); 63 | // eslint-disable-next-line max-len 64 | if (axiosResponse.data && axiosResponse.data.response_epoch_seconds) { 65 | responseServerEpochSeconds = axiosResponse.data.response_epoch_seconds; 66 | } 67 | } catch (error) { 68 | processAxiosErrorAndThrow(error); 69 | } 70 | } catch (error) { 71 | const userIsUnauthenticated = error.response && error.response.status === 401; 72 | if (userIsUnauthenticated) { 73 | // Clean up the cookie if it exists to eliminate any situation 74 | // where the cookie is not expired but the jwt is expired. 75 | this.cookies.remove(this.tokenCookieName); 76 | const decodedJwtToken = null; 77 | return decodedJwtToken; 78 | } 79 | 80 | // TODO: Network timeouts and other problems will end up in 81 | // this block of code. We could add logic for retrying token 82 | // refreshes if we wanted to. 83 | throw error; 84 | } 85 | 86 | const browserEpochSeconds = Date.now() / 1000; 87 | const browserDriftSeconds = responseServerEpochSeconds > 0 88 | ? Math.abs(browserEpochSeconds - responseServerEpochSeconds) 89 | : null; 90 | 91 | const decodedJwtToken = this.decodeJwtCookie(); 92 | 93 | if (!decodedJwtToken) { 94 | // This is an unexpected case. The refresh endpoint should set the 95 | // cookie that is needed. 96 | // For more details, see: 97 | // docs/decisions/0005-token-null-after-successful-refresh.rst 98 | const error = new Error('Access token is still null after successful refresh.'); 99 | error.customAttributes = { axiosResponse, browserDriftSeconds, browserEpochSeconds }; 100 | throw error; 101 | } 102 | 103 | return decodedJwtToken; 104 | }; 105 | 106 | this.refreshRequestPromises[this.tokenCookieName] = makeRefreshRequest().finally(() => { 107 | delete this.refreshRequestPromises[this.tokenCookieName]; 108 | }); 109 | } 110 | 111 | return this.refreshRequestPromises[this.tokenCookieName]; 112 | } 113 | 114 | async getJwtToken(forceRefresh = false) { 115 | try { 116 | const decodedJwtToken = this.decodeJwtCookie(this.tokenCookieName); 117 | if (!AxiosJwtTokenService.isTokenExpired(decodedJwtToken) && !forceRefresh) { 118 | return decodedJwtToken; 119 | } 120 | } catch (e) { 121 | // Log unexpected error and continue with attempt to refresh it. 122 | // TODO: Fix these. They're still using loggingService as a singleton. 123 | logFrontendAuthError(this.loggingService, e); 124 | } 125 | 126 | try { 127 | return await this.refresh(); 128 | } catch (e) { 129 | // TODO: Fix these. They're still using loggingService as a singleton. 130 | logFrontendAuthError(this.loggingService, e); 131 | throw e; 132 | } 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/auth/LocalForageCache.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import localforage from 'localforage'; 3 | import memoryDriver from 'localforage-memoryStorageDriver'; 4 | import { 5 | setupCache, 6 | defaultKeyGenerator, 7 | defaultHeaderInterpreter, 8 | buildStorage, 9 | } from 'axios-cache-interceptor'; 10 | import axios from 'axios'; 11 | 12 | /** 13 | * Async function to configure localforage and setup the cache 14 | * 15 | * @returns {Promise} A promise that, when resolved, returns an axios instance configured to 16 | * use localforage as a cache. 17 | */ 18 | export default async function configureCache() { 19 | // Register the imported `memoryDriver` to `localforage` 20 | await localforage.defineDriver(memoryDriver); 21 | 22 | // Create `localforage` instance 23 | const forageStore = localforage.createInstance({ 24 | // List of drivers used 25 | driver: [ 26 | localforage.INDEXEDDB, 27 | localforage.LOCALSTORAGE, 28 | memoryDriver._driver, 29 | ], 30 | name: 'edx-cache', 31 | }); 32 | 33 | const forageStoreAdapter = buildStorage({ 34 | async find(key) { 35 | const result = await forageStore.getItem(`axios-cache:${key}`); 36 | return JSON.parse(result); 37 | }, 38 | 39 | async set(key, value) { 40 | await forageStore.setItem(`axios-cache:${key}`, JSON.stringify(value)); 41 | }, 42 | 43 | async remove(key) { 44 | await forageStore.removeItem(`axios-cache:${key}`); 45 | }, 46 | }); 47 | 48 | // only GET methods are cached by default 49 | return setupCache( 50 | // axios instance 51 | axios.create(), 52 | { 53 | ttl: 5 * 60 * 1000, // default maxAge of 5 minutes 54 | // The storage to save the cache data. There are more available by default. 55 | // 56 | // https://axios-cache-interceptor.js.org/#/pages/storages 57 | storage: forageStoreAdapter, 58 | 59 | // The mechanism to generate a unique key for each request. 60 | // 61 | // https://axios-cache-interceptor.js.org/#/pages/request-id 62 | generateKey: defaultKeyGenerator, 63 | 64 | // The mechanism to interpret headers (when cache.interpretHeader is true). 65 | // 66 | // https://axios-cache-interceptor.js.org/#/pages/global-configuration?id=headerinterpreter 67 | headerInterpreter: defaultHeaderInterpreter, 68 | 69 | // The function that will receive debug information. 70 | // NOTE: For this to work, you need to enable development mode. 71 | // 72 | // https://axios-cache-interceptor.js.org/#/pages/development-mode 73 | // https://axios-cache-interceptor.js.org/#/pages/global-configuration?id=debug 74 | // eslint-disable-next-line no-console 75 | debug: console.log, 76 | }, 77 | ); 78 | } 79 | -------------------------------------------------------------------------------- /src/auth/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | AUTHENTICATED_USER_TOPIC, 3 | AUTHENTICATED_USER_CHANGED, 4 | configure, 5 | getAuthenticatedHttpClient, 6 | getAuthService, 7 | getHttpClient, 8 | getLoginRedirectUrl, 9 | redirectToLogin, 10 | getLogoutRedirectUrl, 11 | redirectToLogout, 12 | getAuthenticatedUser, 13 | setAuthenticatedUser, 14 | fetchAuthenticatedUser, 15 | ensureAuthenticatedUser, 16 | hydrateAuthenticatedUser, 17 | } from './interface'; 18 | export { default as AxiosJwtAuthService } from './AxiosJwtAuthService'; 19 | export { default as MockAuthService } from './MockAuthService'; 20 | -------------------------------------------------------------------------------- /src/auth/interceptors/createCsrfTokenProviderInterceptor.js: -------------------------------------------------------------------------------- 1 | const createCsrfTokenProviderInterceptor = (options) => { 2 | const { csrfTokenService, CSRF_TOKEN_API_PATH, shouldSkip } = options; 3 | 4 | // Creating the interceptor inside this closure to 5 | // maintain reference to the options supplied. 6 | const interceptor = async (axiosRequestConfig) => { 7 | if (shouldSkip(axiosRequestConfig)) { 8 | return axiosRequestConfig; 9 | } 10 | const { url } = axiosRequestConfig; 11 | let csrfToken; 12 | 13 | // Important: the job of this interceptor is to get a csrf token and update 14 | // the original request configuration. Errors thrown getting the csrf token 15 | // should contain the original request config. This allows other interceptors 16 | // (namely our retry request interceptor below) to access the original request 17 | // and handle it appropriately 18 | try { 19 | csrfToken = await csrfTokenService.getCsrfToken(url, CSRF_TOKEN_API_PATH); 20 | } catch (error) { 21 | const requestError = Object.create(error); 22 | requestError.message = `[getCsrfToken] ${requestError.message}`; 23 | // Important: return the original axios request config 24 | requestError.config = axiosRequestConfig; 25 | return Promise.reject(requestError); 26 | } 27 | 28 | const CSRF_HEADER_NAME = 'X-CSRFToken'; 29 | // eslint-disable-next-line no-param-reassign 30 | axiosRequestConfig.headers[CSRF_HEADER_NAME] = csrfToken; 31 | return axiosRequestConfig; 32 | }; 33 | 34 | return interceptor; 35 | }; 36 | 37 | export default createCsrfTokenProviderInterceptor; 38 | -------------------------------------------------------------------------------- /src/auth/interceptors/createJwtTokenProviderInterceptor.js: -------------------------------------------------------------------------------- 1 | const createJwtTokenProviderInterceptor = (options) => { 2 | const { 3 | jwtTokenService, 4 | shouldSkip, 5 | } = options; 6 | 7 | // Creating the interceptor inside this closure to 8 | // maintain reference to the options supplied. 9 | const interceptor = async (axiosRequestConfig) => { 10 | if (shouldSkip(axiosRequestConfig)) { 11 | return axiosRequestConfig; 12 | } 13 | 14 | // Important: the job of this interceptor is to refresh a jwt token and update 15 | // the original request configuration. Errors thrown from fetching the jwt 16 | // should contain the original request config. This allows other interceptors 17 | // (namely our retry request interceptor below) to access the original request 18 | // and handle it appropriately 19 | try { 20 | await jwtTokenService.getJwtToken(); 21 | } catch (error) { 22 | const requestError = Object.create(error); 23 | requestError.message = `[getJwtToken] ${requestError.message}`; 24 | // Important: return the original axios request config 25 | requestError.config = axiosRequestConfig; 26 | return Promise.reject(requestError); 27 | } 28 | 29 | // Add the proper headers to tell the server to look for the jwt cookie 30 | // eslint-disable-next-line no-param-reassign 31 | axiosRequestConfig.headers['USE-JWT-COOKIE'] = true; 32 | return axiosRequestConfig; 33 | }; 34 | 35 | return interceptor; 36 | }; 37 | 38 | export default createJwtTokenProviderInterceptor; 39 | -------------------------------------------------------------------------------- /src/auth/interceptors/createProcessAxiosRequestErrorInterceptor.js: -------------------------------------------------------------------------------- 1 | import { processAxiosError } from '../utils'; 2 | 3 | const createProcessAxiosRequestErrorInterceptor = (options) => { 4 | const { loggingService } = options; 5 | 6 | // Creating the interceptor inside this closure to 7 | // maintain reference to the options supplied. 8 | const interceptor = async (error) => { 9 | const processedError = processAxiosError(error); 10 | const { httpErrorStatus } = processedError.customAttributes; 11 | if (httpErrorStatus === 401 || httpErrorStatus === 403) { 12 | loggingService.logInfo(processedError.message, processedError.customAttributes); 13 | } 14 | return Promise.reject(processedError); 15 | }; 16 | 17 | return interceptor; 18 | }; 19 | 20 | export default createProcessAxiosRequestErrorInterceptor; 21 | -------------------------------------------------------------------------------- /src/auth/interceptors/createRetryInterceptor.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | // This default algorithm is a recreation of what is documented here 4 | // https://cloud.google.com/storage/docs/exponential-backoff 5 | const defaultGetBackoffMilliseconds = (nthRetry, maximumBackoffMilliseconds = 16000) => { 6 | // Retry at exponential intervals (2, 4, 8, 16...) 7 | const exponentialBackoffSeconds = 2 ** nthRetry; 8 | // Add some randomness to avoid sending retries from separate requests all at once 9 | const randomFractionOfASecond = Math.random(); 10 | const backoffSeconds = exponentialBackoffSeconds + randomFractionOfASecond; 11 | const backoffMilliseconds = Math.round(backoffSeconds * 1000); 12 | return Math.min(backoffMilliseconds, maximumBackoffMilliseconds); 13 | }; 14 | 15 | const createRetryInterceptor = (options = {}) => { 16 | const { 17 | httpClient = axios.create(), 18 | getBackoffMilliseconds = defaultGetBackoffMilliseconds, 19 | // By default only retry outbound request failures (not responses) 20 | shouldRetry = (error) => { 21 | const isRequestError = !error.response && error.config; 22 | return isRequestError; 23 | }, 24 | // A per-request maxRetries can be specified in request config. 25 | defaultMaxRetries = 2, 26 | } = options; 27 | 28 | const interceptor = async (error) => { 29 | const { config } = error; 30 | 31 | // If no config exists there was some other error setting up the request 32 | if (!config) { 33 | return Promise.reject(error); 34 | } 35 | 36 | if (!shouldRetry(error)) { 37 | return Promise.reject(error); 38 | } 39 | 40 | const { 41 | maxRetries = defaultMaxRetries, 42 | } = config; 43 | 44 | const retryRequest = async (nthRetry) => { 45 | if (nthRetry > maxRetries) { 46 | // Reject with the original error 47 | return Promise.reject(error); 48 | } 49 | 50 | let retryResponse; 51 | 52 | try { 53 | const backoffDelay = getBackoffMilliseconds(nthRetry); 54 | // Delay (wrapped in a promise so we can await the setTimeout) 55 | await new Promise(resolve => { setTimeout(resolve, backoffDelay); }); 56 | // Make retry request 57 | retryResponse = await httpClient.request(config); 58 | } catch (e) { 59 | return retryRequest(nthRetry + 1); 60 | } 61 | 62 | return retryResponse; 63 | }; 64 | 65 | return retryRequest(1); 66 | }; 67 | 68 | return interceptor; 69 | }; 70 | 71 | export default createRetryInterceptor; 72 | export { defaultGetBackoffMilliseconds }; 73 | -------------------------------------------------------------------------------- /src/auth/interceptors/createRetryInterceptor.test.js: -------------------------------------------------------------------------------- 1 | import { defaultGetBackoffMilliseconds } from './createRetryInterceptor'; 2 | 3 | describe('createRetryInterceptor: defaultGetBackoffMilliseconds', () => { 4 | it('returns a number between 2000 and 3000 on the first retry', () => { 5 | const backoffInMilliseconds = defaultGetBackoffMilliseconds(1); 6 | expect(backoffInMilliseconds).toBeGreaterThanOrEqual(2000); 7 | expect(backoffInMilliseconds).toBeLessThanOrEqual(3000); 8 | }); 9 | it('returns a number between 4000 and 5000 on the second retry', () => { 10 | const backoffInMilliseconds = defaultGetBackoffMilliseconds(2); 11 | expect(backoffInMilliseconds).toBeGreaterThanOrEqual(4000); 12 | expect(backoffInMilliseconds).toBeLessThanOrEqual(5000); 13 | }); 14 | it('returns a number between 8000 and 9000 on the third retry', () => { 15 | const backoffInMilliseconds = defaultGetBackoffMilliseconds(3); 16 | expect(backoffInMilliseconds).toBeGreaterThanOrEqual(8000); 17 | expect(backoffInMilliseconds).toBeLessThanOrEqual(9000); 18 | }); 19 | it('returns 16000 fourth or later retry', () => { 20 | const backoffInMilliseconds = defaultGetBackoffMilliseconds(4); 21 | expect(backoffInMilliseconds).toEqual(16000); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/auth/utils.js: -------------------------------------------------------------------------------- 1 | // Lifted from here: https://regexr.com/3ok5o 2 | const urlRegex = /([a-z]{1,2}tps?):\/\/((?:(?!(?:\/|#|\?|&)).)+)(?:(\/(?:(?:(?:(?!(?:#|\?|&)).)+\/))?))?(?:((?:(?!(?:\.|$|\?|#)).)+))?(?:(\.(?:(?!(?:\?|$|#)).)+))?(?:(\?(?:(?!(?:$|#)).)+))?(?:(#.+))?/; 3 | const getUrlParts = (url) => { 4 | const found = url.match(urlRegex); 5 | try { 6 | const [ 7 | fullUrl, 8 | protocol, 9 | domain, 10 | path, 11 | endFilename, 12 | endFileExtension, 13 | query, 14 | hash, 15 | ] = found; 16 | 17 | return { 18 | fullUrl, 19 | protocol, 20 | domain, 21 | path, 22 | endFilename, 23 | endFileExtension, 24 | query, 25 | hash, 26 | }; 27 | } catch (e) { 28 | throw new Error(`Could not find url parts from ${url}.`); 29 | } 30 | }; 31 | 32 | const logFrontendAuthError = (loggingService, error) => { 33 | const prefixedMessageError = Object.create(error); 34 | prefixedMessageError.message = `[frontend-auth] ${error.message}`; 35 | loggingService.logError(prefixedMessageError, prefixedMessageError.customAttributes); 36 | }; 37 | 38 | const processAxiosError = (axiosErrorObject) => { 39 | const error = Object.create(axiosErrorObject); 40 | const { request, response, config } = error; 41 | 42 | if (!config) { 43 | error.customAttributes = { 44 | ...error.customAttributes, 45 | httpErrorType: 'unknown-api-request-error', 46 | }; 47 | return error; 48 | } 49 | 50 | const { 51 | url: httpErrorRequestUrl, 52 | method: httpErrorRequestMethod, 53 | } = config; 54 | /* istanbul ignore else: difficult to enter the request-only error case in a unit test */ 55 | if (response) { 56 | const { status, data } = response; 57 | const stringifiedData = JSON.stringify(data) || '(empty response)'; 58 | const responseIsHTML = stringifiedData.includes(''); 59 | // Don't include data if it is just an HTML document, like a 500 error page. 60 | /* istanbul ignore next */ 61 | const httpErrorResponseData = responseIsHTML ? '' : stringifiedData; 62 | error.customAttributes = { 63 | ...error.customAttributes, 64 | httpErrorType: 'api-response-error', 65 | httpErrorStatus: status, 66 | httpErrorResponseData, 67 | httpErrorRequestUrl, 68 | httpErrorRequestMethod, 69 | }; 70 | error.message = `Axios Error (Response): ${status} - See custom attributes for details.`; 71 | } else if (request) { 72 | error.customAttributes = { 73 | ...error.customAttributes, 74 | httpErrorType: 'api-request-error', 75 | httpErrorMessage: error.message, 76 | httpErrorRequestUrl, 77 | httpErrorRequestMethod, 78 | }; 79 | // This case occurs most likely because of intermittent internet connection issues 80 | // but it also, though less often, catches CORS or server configuration problems. 81 | error.message = 'Axios Error (Request): (Possible local connectivity issue.) See custom attributes for details.'; 82 | } else { 83 | error.customAttributes = { 84 | ...error.customAttributes, 85 | httpErrorType: 'api-request-config-error', 86 | httpErrorMessage: error.message, 87 | httpErrorRequestUrl, 88 | httpErrorRequestMethod, 89 | }; 90 | error.message = 'Axios Error (Config): See custom attributes for details.'; 91 | } 92 | 93 | return error; 94 | }; 95 | 96 | const processAxiosErrorAndThrow = (axiosErrorObject) => { 97 | throw processAxiosError(axiosErrorObject); 98 | }; 99 | 100 | export { 101 | getUrlParts, 102 | logFrontendAuthError, 103 | processAxiosError, 104 | processAxiosErrorAndThrow, 105 | }; 106 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | /** @constant */ 2 | export const APP_TOPIC = 'APP'; 3 | 4 | export const APP_PUBSUB_INITIALIZED = `${APP_TOPIC}.PUBSUB_INITIALIZED`; 5 | 6 | /** 7 | * Event published when the application initialization sequence has finished loading any dynamic 8 | * configuration setup in a custom config handler. 9 | * 10 | * @event 11 | */ 12 | export const APP_CONFIG_INITIALIZED = `${APP_TOPIC}.CONFIG_INITIALIZED`; 13 | 14 | /** 15 | * Event published when the application initialization sequence has finished determining the user's 16 | * authentication state, creating an authenticated API client, and executing auth handlers. 17 | * 18 | * @event 19 | */ 20 | export const APP_AUTH_INITIALIZED = `${APP_TOPIC}.AUTH_INITIALIZED`; 21 | 22 | /** 23 | * Event published when the application initialization sequence has finished initializing 24 | * internationalization and executing any i18n handlers. 25 | * 26 | * @event 27 | */ 28 | export const APP_I18N_INITIALIZED = `${APP_TOPIC}.I18N_INITIALIZED`; 29 | 30 | /** 31 | * Event published when the application initialization sequence has finished initializing the 32 | * logging service and executing any logging handlers. 33 | * 34 | * @event 35 | */ 36 | export const APP_LOGGING_INITIALIZED = `${APP_TOPIC}.LOGGING_INITIALIZED`; 37 | 38 | /** 39 | * Event published when the application initialization sequence has finished initializing the 40 | * analytics service and executing any analytics handlers. 41 | * 42 | * @event 43 | */ 44 | export const APP_ANALYTICS_INITIALIZED = `${APP_TOPIC}.ANALYTICS_INITIALIZED`; 45 | 46 | /** 47 | * Event published when the application initialization sequence has finished. Applications should 48 | * subscribe to this event and start rendering the UI when it has fired. 49 | * 50 | * @event 51 | */ 52 | export const APP_READY = `${APP_TOPIC}.READY`; 53 | 54 | /** 55 | * Event published when the application initialization sequence has aborted. This is frequently 56 | * used to show an error page when an initialization error has occurred. 57 | * 58 | * @see {@link module:React~ErrorPage} 59 | * @event 60 | */ 61 | export const APP_INIT_ERROR = `${APP_TOPIC}.INIT_ERROR`; 62 | 63 | /** @constant */ 64 | export const CONFIG_TOPIC = 'CONFIG'; 65 | 66 | export const CONFIG_CHANGED = `${CONFIG_TOPIC}.CHANGED`; 67 | -------------------------------------------------------------------------------- /src/i18n/countries.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | import COUNTRIES, { langs as countryLangs } from 'i18n-iso-countries'; 3 | 4 | import arLocale from 'i18n-iso-countries/langs/ar.json'; 5 | import enLocale from 'i18n-iso-countries/langs/en.json'; 6 | import esLocale from 'i18n-iso-countries/langs/es.json'; 7 | import frLocale from 'i18n-iso-countries/langs/fr.json'; 8 | import zhLocale from 'i18n-iso-countries/langs/zh.json'; 9 | import caLocale from 'i18n-iso-countries/langs/ca.json'; 10 | import heLocale from 'i18n-iso-countries/langs/he.json'; 11 | import idLocale from 'i18n-iso-countries/langs/id.json'; 12 | import koLocale from 'i18n-iso-countries/langs/ko.json'; 13 | import plLocale from 'i18n-iso-countries/langs/pl.json'; 14 | import ptLocale from 'i18n-iso-countries/langs/pt.json'; 15 | import ruLocale from 'i18n-iso-countries/langs/ru.json'; 16 | import ukLocale from 'i18n-iso-countries/langs/uk.json'; 17 | 18 | import { getPrimaryLanguageSubtag } from './lib'; 19 | 20 | /* 21 | * COUNTRY LISTS 22 | * 23 | * Lists of country names localized in supported languages. 24 | * 25 | * TODO: When we start dynamically loading translations only for the current locale, change this. 26 | */ 27 | 28 | COUNTRIES.registerLocale(arLocale); 29 | COUNTRIES.registerLocale(enLocale); 30 | COUNTRIES.registerLocale(esLocale); 31 | COUNTRIES.registerLocale(frLocale); 32 | COUNTRIES.registerLocale(zhLocale); 33 | COUNTRIES.registerLocale(caLocale); 34 | COUNTRIES.registerLocale(heLocale); 35 | COUNTRIES.registerLocale(idLocale); 36 | COUNTRIES.registerLocale(koLocale); 37 | COUNTRIES.registerLocale(plLocale); 38 | COUNTRIES.registerLocale(ptLocale); 39 | COUNTRIES.registerLocale(ruLocale); 40 | // COUNTRIES.registerLocale(thLocale); // Doesn't exist in lib. 41 | COUNTRIES.registerLocale(ukLocale); 42 | 43 | /** 44 | * Provides a lookup table of country IDs to country names for the current locale. 45 | * 46 | * @memberof module:I18n 47 | */ 48 | export function getCountryMessages(locale) { 49 | const primaryLanguageSubtag = getPrimaryLanguageSubtag(locale); 50 | const languageCode = countryLangs().includes(primaryLanguageSubtag) ? primaryLanguageSubtag : 'en'; 51 | 52 | return COUNTRIES.getNames(languageCode); 53 | } 54 | 55 | /** 56 | * Provides a list of countries represented as objects of the following shape: 57 | * 58 | * { 59 | * key, // The ID of the country 60 | * name // The localized name of the country 61 | * } 62 | * 63 | * TODO: ARCH-878: The list should be sorted alphabetically in the current locale. 64 | * This is useful for populating dropdowns. 65 | * 66 | * @memberof module:I18n 67 | */ 68 | export function getCountryList(locale) { 69 | const countryMessages = getCountryMessages(locale); 70 | return Object.entries(countryMessages).map(([code, name]) => ({ code, name })); 71 | } 72 | -------------------------------------------------------------------------------- /src/i18n/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * #### Import members from **@edx/frontend-platform/i18n** 3 | * The i18n module relies on react-intl and re-exports all of that package's exports. 4 | * 5 | * For each locale we want to support, react-intl needs 1) the locale-data, which includes 6 | * information about how to format numbers, handle plurals, etc., and 2) the translations, as an 7 | * object holding message id / translated string pairs. A locale string and the messages object are 8 | * passed into the IntlProvider element that wraps your element hierarchy. 9 | * 10 | * Note that react-intl has no way of checking if the translations you give it actually have 11 | * anything to do with the locale you pass it; it will happily use whatever messages object you pass 12 | * in. However, if the locale data for the locale you passed into the IntlProvider was not 13 | * correctly installed with addLocaleData, all of your translations will fall back to the default 14 | * (in our case English), *even if you gave IntlProvider the correct messages object for that 15 | * locale*. 16 | * 17 | * Messages are provided to this module via the configure() function below. 18 | * 19 | * 20 | * @module Internationalization 21 | * @see {@link https://github.com/openedx/frontend-platform/blob/master/docs/how_tos/i18n.rst} 22 | * @see {@link https://formatjs.io/docs/react-intl/components/ Intl} for components exported from this module. 23 | * 24 | */ 25 | 26 | /** 27 | * @name createIntl 28 | * @kind function 29 | * @see {@link https://formatjs.io/docs/react-intl/api#createIntl Intl} 30 | */ 31 | 32 | /** 33 | * @name FormattedDate 34 | * @kind class 35 | * @see {@link https://formatjs.io/docs/react-intl/components/#formatteddate Intl} 36 | */ 37 | 38 | /** 39 | * @name FormattedTime 40 | * @kind class 41 | * @see {@link https://formatjs.io/docs/react-intl/components/#formattedtime Intl} 42 | */ 43 | 44 | /** 45 | * @name FormattedRelativeTime 46 | * @kind class 47 | * @see {@link https://formatjs.io/docs/react-intl/components/#formattedrelativetime Intl} 48 | */ 49 | 50 | /** 51 | * @name FormattedNumber 52 | * @kind class 53 | * @see {@link https://formatjs.io/docs/react-intl/components/#formattednumber Intl} 54 | */ 55 | 56 | /** 57 | * @name FormattedPlural 58 | * @kind class 59 | * @see {@link https://formatjs.io/docs/react-intl/components/#formattedplural Intl} 60 | */ 61 | 62 | /** 63 | * @name FormattedMessage 64 | * @kind class 65 | * @see {@link https://formatjs.io/docs/react-intl/components/#formattedmessage Intl} 66 | */ 67 | 68 | /** 69 | * @name IntlProvider 70 | * @kind class 71 | * @see {@link https://formatjs.io/docs/react-intl/components/#intlprovider Intl} 72 | */ 73 | 74 | /** 75 | * @name defineMessages 76 | * @kind function 77 | * @see {@link https://formatjs.io/docs/react-intl/api#definemessagesdefinemessage Intl} 78 | */ 79 | 80 | /** 81 | * @name useIntl 82 | * @kind function 83 | * @see {@link https://formatjs.io/docs/react-intl/api#useIntl Intl} 84 | */ 85 | 86 | export { 87 | createIntl, 88 | FormattedDate, 89 | FormattedTime, 90 | FormattedRelativeTime, 91 | FormattedNumber, 92 | FormattedPlural, 93 | FormattedMessage, 94 | defineMessages, 95 | IntlProvider, 96 | useIntl, 97 | } from 'react-intl'; 98 | 99 | export { 100 | intlShape, 101 | configure, 102 | getPrimaryLanguageSubtag, 103 | getLocale, 104 | getMessages, 105 | isRtl, 106 | handleRtl, 107 | mergeMessages, 108 | LOCALE_CHANGED, 109 | LOCALE_TOPIC, 110 | } from './lib'; 111 | 112 | export { 113 | default as injectIntl, 114 | } from './injectIntlWithShim'; 115 | 116 | export { 117 | getCountryList, 118 | getCountryMessages, 119 | } from './countries'; 120 | 121 | export { 122 | getLanguageList, 123 | getLanguageMessages, 124 | } from './languages'; 125 | -------------------------------------------------------------------------------- /src/i18n/injectIntlWithShim.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { injectIntl } from 'react-intl'; 3 | import { getLoggingService, intlShape } from './lib'; 4 | 5 | /** 6 | * This function wraps react-intl's injectIntl function in order to add error logging to the intl 7 | * property's formatMessage function. 8 | * 9 | * @memberof I18n 10 | */ 11 | const injectIntlWithShim = (WrappedComponent) => { 12 | class ShimmedIntlComponent extends React.Component { 13 | constructor(props) { 14 | super(props); 15 | this.shimmedIntl = Object.create(this.props.intl, { 16 | formatMessage: { 17 | value: (definition, ...args) => { 18 | if (definition === undefined || definition.id === undefined) { 19 | const error = new Error('i18n error: An undefined message was supplied to intl.formatMessage.'); 20 | if (process.env.NODE_ENV !== 'production') { 21 | console.error(error); // eslint-disable-line no-console 22 | return '!!! Missing message supplied to intl.formatMessage !!!'; 23 | } 24 | getLoggingService().logError(error); 25 | return ''; // Fail silently in production 26 | } 27 | return this.props.intl.formatMessage(definition, ...args); 28 | }, 29 | }, 30 | }); 31 | } 32 | 33 | render() { 34 | return ; 35 | } 36 | } 37 | 38 | ShimmedIntlComponent.propTypes = { 39 | intl: intlShape.isRequired, 40 | }; 41 | 42 | return injectIntl(ShimmedIntlComponent); 43 | }; 44 | 45 | export default injectIntlWithShim; 46 | -------------------------------------------------------------------------------- /src/i18n/languages.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/extensions */ 2 | import LANGUAGES, { langs as languageLangs } from '@cospired/i18n-iso-languages'; 3 | 4 | // import arLocale from '@cospired/i18n-iso-languages/langs/ar.json'; 5 | import enLocale from '@cospired/i18n-iso-languages/langs/en.json'; 6 | import esLocale from '@cospired/i18n-iso-languages/langs/es.json'; 7 | import frLocale from '@cospired/i18n-iso-languages/langs/fr.json'; 8 | // import zhLocale from '@cospired/i18n-iso-languages/langs/zh.json'; 9 | // import caLocale from '@cospired/i18n-iso-languages/langs/ca.json'; 10 | // import heLocale from '@cospired/i18n-iso-languages/langs/he.json'; 11 | // import idLocale from '@cospired/i18n-iso-languages/langs/id.json'; 12 | // import koLocale from '@cospired/i18n-iso-languages/langs/ko.json'; 13 | import plLocale from '@cospired/i18n-iso-languages/langs/pl.json'; 14 | import ptLocale from '@cospired/i18n-iso-languages/langs/pt.json'; 15 | // import ruLocale from '@cospired/i18n-iso-languages/langs/ru.json'; 16 | // import thLocale from '@cospired/i18n-iso-languages/langs/th.json'; 17 | // import ukLocale from '@cospired/i18n-iso-languages/langs/uk.json'; 18 | 19 | import { getPrimaryLanguageSubtag } from './lib'; 20 | 21 | /* 22 | * LANGUAGE LISTS 23 | * 24 | * Lists of language names localized in supported languages. 25 | * 26 | * TODO: When we start dynamically loading translations only for the current locale, change this. 27 | * TODO: Also note that a bunch of languages are missing here. They're present but commented out 28 | * for reference. That's because they're not implemented in this library. If you read this and it's 29 | * been a while, go check and see if that's changed! 30 | */ 31 | 32 | // LANGUAGES.registerLocale(arLocale); 33 | LANGUAGES.registerLocale(enLocale); 34 | LANGUAGES.registerLocale(esLocale); 35 | LANGUAGES.registerLocale(frLocale); 36 | // LANGUAGES.registerLocale(zhLocale); 37 | // LANGUAGES.registerLocale(caLocale); 38 | // LANGUAGES.registerLocale(heLocale); 39 | // LANGUAGES.registerLocale(idLocale); 40 | // LANGUAGES.registerLocale(koLocale); 41 | LANGUAGES.registerLocale(plLocale); 42 | LANGUAGES.registerLocale(ptLocale); 43 | // LANGUAGES.registerLocale(ruLocale); 44 | // LANGUAGES.registerLocale(thLocale); 45 | // LANGUAGES.registerLocale(ukLocale); 46 | 47 | /** 48 | * Provides a lookup table of language IDs to language names for the current locale. 49 | * 50 | * @memberof I18n 51 | */ 52 | export const getLanguageMessages = (locale) => { 53 | const primaryLanguageSubtag = getPrimaryLanguageSubtag(locale); 54 | const languageCode = languageLangs().includes(primaryLanguageSubtag) ? primaryLanguageSubtag : 'en'; 55 | 56 | return LANGUAGES.getNames(languageCode); 57 | }; 58 | 59 | /** 60 | * Provides a list of languages represented as objects of the following shape: 61 | * 62 | * { 63 | * key, // The ID of the language 64 | * name // The localized name of the language 65 | * } 66 | * 67 | * TODO: ARCH-878: The list should be sorted alphabetically in the current locale. 68 | * This is useful for populating dropdowns. 69 | * 70 | * @memberof I18n 71 | */ 72 | export const getLanguageList = (locale) => { 73 | const languageMessages = getLanguageMessages(locale); 74 | return Object.entries(languageMessages).map(([code, name]) => ({ code, name })); 75 | }; 76 | -------------------------------------------------------------------------------- /src/i18n/scripts/README.md: -------------------------------------------------------------------------------- 1 | # i18n/scripts 2 | 3 | This directory contains the `transifex-utils.js` and `intl-imports.js` files which are shared across all micro-frontends. 4 | 5 | The package.json of `frontend-platform` includes the following sections: 6 | 7 | ``` 8 | "bin": { 9 | "intl-imports.js": "i18n/scripts/intl-imports.js" 10 | "transifex-utils.js": "i18n/scripts/transifex-utils.js" 11 | }, 12 | ``` 13 | 14 | This config block causes boths scripts to be copied to the following path when `frontend-platform` is installed as a 15 | dependency of a micro-frontend: 16 | 17 | ``` 18 | /node_modules/.bin/intl-imports.js 19 | /node_modules/.bin/transifex-utils.js 20 | ``` 21 | 22 | All micro-frontends have a `Makefile` with a line that loads the scripts from the above path: 23 | 24 | ``` 25 | intl_imports = ./node_modules/.bin/intl-imports.js 26 | transifex_utils = ./node_modules/.bin/transifex-utils.js 27 | ``` 28 | 29 | So if you delete either of the files or the `scripts` directory, you'll break all micro-frontend builds. Happy coding! 30 | -------------------------------------------------------------------------------- /src/i18n/scripts/intl-imports.test.js: -------------------------------------------------------------------------------- 1 | // Tests for the intl-imports.js command line. 2 | 3 | import path from 'path'; 4 | import { main as realMain } from './intl-imports'; 5 | 6 | const sempleAppDirectory = path.join(__dirname, '../../../example'); 7 | 8 | // History for `process.stdout.write` mock calls. 9 | const logHistory = { 10 | log: [], 11 | latest: '', 12 | }; 13 | 14 | // History for `fs.writeFileSync` mock calls. 15 | const writeFileHistory = { 16 | log: [], 17 | latest: null, 18 | }; 19 | 20 | // Mock for process.stdout.write 21 | const log = (text) => { 22 | logHistory.latest = text; 23 | logHistory.log.push(text); 24 | }; 25 | 26 | // Mock for fs.writeFileSync 27 | const writeFileSync = (filename, content) => { 28 | const entry = { filename, content }; 29 | writeFileHistory.latest = entry; 30 | writeFileHistory.log.push(entry); 31 | }; 32 | 33 | // Main with mocked output 34 | const main = (...directories) => realMain({ 35 | directories, 36 | log, 37 | writeFileSync, 38 | pwd: sempleAppDirectory, 39 | }); 40 | 41 | // Clean up mock histories 42 | afterEach(() => { 43 | logHistory.log = []; 44 | logHistory.latest = null; 45 | writeFileHistory.log = []; 46 | writeFileHistory.latest = null; 47 | }); 48 | 49 | describe('help document', () => { 50 | it('should print help for --help', () => { 51 | main('--help'); 52 | expect(logHistory.latest).toMatch('Script to generate the src/i18n/index.js'); 53 | }); 54 | 55 | it('should print help for -h', () => { 56 | main('-h'); 57 | expect(logHistory.latest).toMatch('Script to generate the src/i18n/index.js'); 58 | }); 59 | }); 60 | 61 | describe('error validation', () => { 62 | it('expects a list of directories', () => { 63 | main(); 64 | expect(logHistory.log.join('\n')).toMatch('Script to generate the src/i18n/index.js'); // Print help error 65 | expect(logHistory.latest).toMatch('Error: A list of directories is required'); // Print error message 66 | }); 67 | 68 | it('expects a directory with a relative path of "src/i18n"', () => { 69 | realMain({ 70 | directories: ['frontend-app-example'], 71 | log, 72 | writeFileSync, 73 | pwd: path.join(__dirname), // __dirname === `scripts` which has no sub-dir `src/i18n` 74 | }); 75 | 76 | expect(logHistory.log.join('\n')).toMatch('Script to generate the src/i18n/index.js'); // Print help on error 77 | expect(logHistory.latest).toMatch('Error: src/i18n directory was not found.'); // Print error message 78 | }); 79 | }); 80 | 81 | describe('generated files', () => { 82 | it('writes a correct src/i18n/index.js file', () => { 83 | main('frontend-component-singlelang', 'frontend-component-nolangs', 'frontend-component-emptylangs', 'frontend-app-sample'); 84 | const mainFileActualContent = writeFileHistory.log.find(file => file.filename.endsWith('src/i18n/index.js')).content; 85 | 86 | const mainFileExpectedContent = `// This file is generated by the openedx/frontend-platform's "intl-import.js" script. 87 | // 88 | // Refer to the i18n documents in https://docs.openedx.org/en/latest/developers/references/i18n.html to update 89 | // the file and use the Micro-frontend i18n pattern in new repositories. 90 | // 91 | 92 | import messagesFromFrontendComponentSinglelang from './messages/frontend-component-singlelang'; 93 | // Skipped import due to missing 'frontend-component-nolangs/index.js' likely due to empty translations.. 94 | // Skipped import due to missing 'frontend-component-emptylangs/index.js' likely due to empty translations.. 95 | import messagesFromFrontendAppSample from './messages/frontend-app-sample'; 96 | 97 | export default [ 98 | messagesFromFrontendComponentSinglelang, 99 | messagesFromFrontendAppSample, 100 | ]; 101 | `; 102 | 103 | expect(mainFileActualContent).toEqual(mainFileExpectedContent); 104 | }); 105 | 106 | it('writes a correct frontend-component-singlelang/index.js file', () => { 107 | main('frontend-component-singlelang', 'frontend-component-nolangs', 'frontend-component-emptylangs', 'frontend-app-sample'); 108 | const mainFileActualContent = writeFileHistory.log.find(file => file.filename.endsWith('frontend-component-singlelang/index.js')).content; 109 | 110 | const singleLangExpectedContent = `// This file is generated by the openedx/frontend-platform's "intl-import.js" script. 111 | // 112 | // Refer to the i18n documents in https://docs.openedx.org/en/latest/developers/references/i18n.html to update 113 | // the file and use the Micro-frontend i18n pattern in new repositories. 114 | // 115 | 116 | import messagesOfArLanguage from './ar.json'; 117 | 118 | export default { 119 | 'ar': messagesOfArLanguage, 120 | }; 121 | `; 122 | 123 | expect(mainFileActualContent).toEqual(singleLangExpectedContent); 124 | }); 125 | 126 | it('writes a correct frontend-app-sample/index.js file', () => { 127 | main('frontend-component-singlelang', 'frontend-component-nolangs', 'frontend-component-emptylangs', 'frontend-app-sample'); 128 | const mainFileActualContent = writeFileHistory.log.find(file => file.filename.endsWith('frontend-app-sample/index.js')).content; 129 | 130 | const singleLangExpectedContent = `// This file is generated by the openedx/frontend-platform's "intl-import.js" script. 131 | // 132 | // Refer to the i18n documents in https://docs.openedx.org/en/latest/developers/references/i18n.html to update 133 | // the file and use the Micro-frontend i18n pattern in new repositories. 134 | // 135 | 136 | import messagesOfArLanguage from './ar.json'; 137 | // Note: Skipped empty 'eo.json' messages file. 138 | import messagesOfEs419Language from './es_419.json'; 139 | 140 | export default { 141 | 'ar': messagesOfArLanguage, 142 | 'es-419': messagesOfEs419Language, 143 | }; 144 | `; 145 | 146 | expect(mainFileActualContent).toEqual(singleLangExpectedContent); 147 | }); 148 | }); 149 | 150 | describe('list of generated index.js files', () => { 151 | it('writes only non-empty languages in addition to the main file', () => { 152 | main('frontend-component-singlelang', 'frontend-component-nolangs', 'frontend-component-emptylangs', 'frontend-app-sample'); 153 | const writtenFiles = writeFileHistory.log 154 | .map(file => file.filename) 155 | .map(file => path.relative(sempleAppDirectory, file)); 156 | expect(writtenFiles).toEqual([ 157 | 'src/i18n/messages/frontend-component-singlelang/index.js', 158 | 'src/i18n/messages/frontend-app-sample/index.js', 159 | 'src/i18n/index.js', 160 | ]); 161 | }); 162 | }); 163 | -------------------------------------------------------------------------------- /src/i18n/scripts/transifex-utils.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const fs = require('fs'); 4 | const glob = require('glob'); 5 | const path = require('path'); 6 | 7 | /* 8 | * See the Makefile for how the required hash file is downloaded from Transifex. 9 | */ 10 | 11 | /* 12 | * Expected input: a directory, possibly containing subdirectories, with .json files. Each .json 13 | * file is an array of translation triplets (id, description, defaultMessage). 14 | * 15 | * 16 | */ 17 | function gatherJson(dir) { 18 | const ret = []; 19 | const files = glob.sync(`${dir}/**/*.json`); 20 | 21 | files.forEach((filename) => { 22 | const messages = JSON.parse(fs.readFileSync(filename)); 23 | ret.push(...messages); 24 | }); 25 | return ret; 26 | } 27 | 28 | // the hash file returns ids whose periods are "escaped" (sort of), like this: 29 | // "key": "profile\\.sociallinks\\.social\\.links" 30 | // so our regular messageIds won't match them out of the box 31 | function escapeDots(messageId) { 32 | return messageId.replace(/\./g, '\\.'); 33 | } 34 | 35 | const jsonDir = process.argv[2]; 36 | const messageObjects = gatherJson(jsonDir); 37 | 38 | if (messageObjects.length === 0) { 39 | process.exitCode = 1; 40 | throw new Error('Found no messages'); 41 | } 42 | 43 | if (process.argv[3] === '--comments') { // prepare to handle the translator notes 44 | const loggingPrefix = path.basename(`${__filename}`); // the name of this JS file 45 | const bashScriptsPath = ( 46 | process.argv[4] && process.argv[4] === '--v3-scripts-path' 47 | ? './node_modules/@edx/reactifex/bash_scripts' 48 | : './node_modules/reactifex/bash_scripts'); 49 | 50 | const hashFile = `${bashScriptsPath}/hashmap.json`; 51 | process.stdout.write(`${loggingPrefix}: reading hash file ${hashFile}\n`); 52 | const messageInfo = JSON.parse(fs.readFileSync(hashFile)); 53 | 54 | const outputFile = `${bashScriptsPath}/hashed_data.txt`; 55 | process.stdout.write(`${loggingPrefix}: writing to output file ${outputFile}\n`); 56 | fs.writeFileSync(outputFile, ''); 57 | 58 | messageObjects.forEach((message) => { 59 | const transifexFormatId = escapeDots(message.id); 60 | 61 | const info = messageInfo.find(mi => mi.key === transifexFormatId); 62 | if (info) { 63 | fs.appendFileSync(outputFile, `${info.string_hash}|${message.description}\n`); 64 | } else { 65 | process.stdout.write(`${loggingPrefix}: string ${message.id} does not yet exist on transifex!\n`); 66 | } 67 | }); 68 | } else { 69 | const output = {}; 70 | 71 | messageObjects.forEach((message) => { 72 | output[message.id] = message.defaultMessage; 73 | }); 74 | fs.writeFileSync(process.argv[3], JSON.stringify(output, null, 2)); 75 | } 76 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | modifyObjectKeys, 3 | camelCaseObject, 4 | snakeCaseObject, 5 | convertKeyNames, 6 | getQueryParameters, 7 | ensureDefinedConfig, 8 | parseURL, 9 | getPath, 10 | } from './utils'; 11 | export { 12 | APP_TOPIC, 13 | APP_PUBSUB_INITIALIZED, 14 | APP_CONFIG_INITIALIZED, 15 | APP_AUTH_INITIALIZED, 16 | APP_I18N_INITIALIZED, 17 | APP_LOGGING_INITIALIZED, 18 | APP_ANALYTICS_INITIALIZED, 19 | APP_READY, 20 | APP_INIT_ERROR, 21 | CONFIG_TOPIC, 22 | CONFIG_CHANGED, 23 | } from './constants'; 24 | export { 25 | initialize, 26 | history, 27 | initError, 28 | auth, 29 | } from './initialize'; 30 | export { 31 | publish, 32 | subscribe, 33 | unsubscribe, 34 | } from './pubSub'; 35 | export { 36 | getConfig, 37 | setConfig, 38 | mergeConfig, 39 | ensureConfig, 40 | } from './config'; 41 | export { 42 | initializeMockApp, 43 | mockMessages, 44 | } from './testing'; 45 | -------------------------------------------------------------------------------- /src/initialize.async.function.config.test.js: -------------------------------------------------------------------------------- 1 | import PubSub from 'pubsub-js'; 2 | import { initialize } from './initialize'; 3 | 4 | import { 5 | logError, 6 | } from './logging'; 7 | import { 8 | ensureAuthenticatedUser, 9 | fetchAuthenticatedUser, 10 | hydrateAuthenticatedUser, 11 | } from './auth'; 12 | import { getConfig } from './config'; 13 | 14 | jest.mock('./logging'); 15 | jest.mock('./auth'); 16 | jest.mock('./analytics'); 17 | jest.mock('./i18n'); 18 | jest.mock('./auth/LocalForageCache'); 19 | jest.mock('env.config.js', () => async () => new Promise((resolve) => { 20 | resolve({ 21 | JS_FILE_VAR: 'JS_FILE_VAR_VALUE_ASYNC_FUNCTION', 22 | }); 23 | })); 24 | 25 | let config = null; 26 | 27 | describe('initialize with async function js file config', () => { 28 | beforeEach(() => { 29 | jest.resetModules(); 30 | config = getConfig(); 31 | fetchAuthenticatedUser.mockReset(); 32 | ensureAuthenticatedUser.mockReset(); 33 | hydrateAuthenticatedUser.mockReset(); 34 | logError.mockReset(); 35 | PubSub.clearAllSubscriptions(); 36 | }); 37 | 38 | it('should initialize the app with async function javascript file configuration', async () => { 39 | const messages = { i_am: 'a message' }; 40 | await initialize({ messages }); 41 | 42 | expect(config.JS_FILE_VAR).toEqual('JS_FILE_VAR_VALUE_ASYNC_FUNCTION'); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/initialize.const.config.test.js: -------------------------------------------------------------------------------- 1 | import PubSub from 'pubsub-js'; 2 | import { initialize } from './initialize'; 3 | 4 | import { 5 | logError, 6 | } from './logging'; 7 | import { 8 | ensureAuthenticatedUser, 9 | fetchAuthenticatedUser, 10 | hydrateAuthenticatedUser, 11 | } from './auth'; 12 | import { getConfig } from './config'; 13 | 14 | jest.mock('./logging'); 15 | jest.mock('./auth'); 16 | jest.mock('./analytics'); 17 | jest.mock('./i18n'); 18 | jest.mock('./auth/LocalForageCache'); 19 | jest.mock('env.config.js', () => ({ 20 | JS_FILE_VAR: 'JS_FILE_VAR_VALUE_CONSTANT', 21 | })); 22 | 23 | let config = null; 24 | 25 | describe('initialize with constant js file config', () => { 26 | beforeEach(() => { 27 | jest.resetModules(); 28 | config = getConfig(); 29 | fetchAuthenticatedUser.mockReset(); 30 | ensureAuthenticatedUser.mockReset(); 31 | hydrateAuthenticatedUser.mockReset(); 32 | logError.mockReset(); 33 | PubSub.clearAllSubscriptions(); 34 | }); 35 | 36 | it('should initialize the app with javascript file configuration', async () => { 37 | const messages = { i_am: 'a message' }; 38 | await initialize({ messages }); 39 | 40 | expect(config.JS_FILE_VAR).toEqual('JS_FILE_VAR_VALUE_CONSTANT'); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/initialize.function.config.test.js: -------------------------------------------------------------------------------- 1 | import PubSub from 'pubsub-js'; 2 | import { initialize } from './initialize'; 3 | 4 | import { 5 | logError, 6 | } from './logging'; 7 | import { 8 | ensureAuthenticatedUser, 9 | fetchAuthenticatedUser, 10 | hydrateAuthenticatedUser, 11 | } from './auth'; 12 | import { getConfig } from './config'; 13 | 14 | jest.mock('./logging'); 15 | jest.mock('./auth'); 16 | jest.mock('./analytics'); 17 | jest.mock('./i18n'); 18 | jest.mock('./auth/LocalForageCache'); 19 | jest.mock('env.config.js', () => () => ({ 20 | JS_FILE_VAR: 'JS_FILE_VAR_VALUE_FUNCTION', 21 | })); 22 | 23 | let config = null; 24 | 25 | describe('initialize with function js file config', () => { 26 | beforeEach(() => { 27 | jest.resetModules(); 28 | config = getConfig(); 29 | fetchAuthenticatedUser.mockReset(); 30 | ensureAuthenticatedUser.mockReset(); 31 | hydrateAuthenticatedUser.mockReset(); 32 | logError.mockReset(); 33 | PubSub.clearAllSubscriptions(); 34 | }); 35 | 36 | it('should initialize the app with javascript file configuration', async () => { 37 | const messages = { i_am: 'a message' }; 38 | await initialize({ messages }); 39 | 40 | expect(config.JS_FILE_VAR).toEqual('JS_FILE_VAR_VALUE_FUNCTION'); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/logging/MockLoggingService.js: -------------------------------------------------------------------------------- 1 | /** 2 | * The MockLoggingService implements both logInfo and logError as jest mock functions via 3 | * jest.fn(). It has no other functionality. 4 | * 5 | * @implements {LoggingService} 6 | * @memberof module:Logging 7 | */ 8 | class MockLoggingService { 9 | /** 10 | * Implemented as a jest.fn() 11 | * 12 | * @memberof MockLoggingService 13 | */ 14 | logInfo = jest.fn(); 15 | 16 | /** 17 | * Implemented as a jest.fn() 18 | * 19 | * @memberof MockLoggingService 20 | */ 21 | logError = jest.fn(); 22 | 23 | /** 24 | * Implemented as a jest.fn() 25 | * 26 | * @memberof MockLoggingService 27 | */ 28 | setCustomAttribute = jest.fn(); 29 | } 30 | 31 | export default MockLoggingService; 32 | -------------------------------------------------------------------------------- /src/logging/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | getLoggingService, 3 | resetLoggingService, 4 | configure, 5 | logInfo, 6 | logError, 7 | } from './interface'; 8 | export { default as NewRelicLoggingService } from './NewRelicLoggingService'; 9 | export { default as MockLoggingService } from './MockLoggingService'; 10 | -------------------------------------------------------------------------------- /src/logging/interface.js: -------------------------------------------------------------------------------- 1 | /** 2 | * #### Import members from **@edx/frontend-platform/logging** 3 | * 4 | * Contains a shared interface for logging information. (The default implementation is in 5 | * NewRelicLoggingService.js.) When in development mode, all messages will instead be sent to the console. 6 | * 7 | * The `initialize` function performs much of the logging configuration for you. If, however, 8 | * you're not using the `initialize` function, logging (via New Relic) can be configured via: 9 | * 10 | * ``` 11 | * import { configure, NewRelicLoggingService, logInfo, logError } from '@edx/frontend-platform/logging'; 12 | * import { geConfig } from '@edx/frontend-platform'; 13 | * 14 | * configureLogging(NewRelicLoggingService, { 15 | * config: getConfig(), 16 | * }); 17 | * 18 | * logInfo('Just so you know...'); 19 | * logInfo(new Error('Unimportant error'), { type: 'unimportant' }); 20 | * logError('Uhoh!'); 21 | * logError(new Error('Uhoh error!')); 22 | * ``` 23 | * 24 | * As shown in this example, logging depends on the configuration document. 25 | * 26 | * @module Logging 27 | */ 28 | 29 | import PropTypes from 'prop-types'; 30 | 31 | const optionsShape = { 32 | config: PropTypes.object.isRequired, 33 | }; 34 | 35 | const serviceShape = { 36 | logInfo: PropTypes.func.isRequired, 37 | logError: PropTypes.func.isRequired, 38 | }; 39 | 40 | let service = null; 41 | 42 | /** 43 | * 44 | */ 45 | export function configure(LoggingService, options) { 46 | PropTypes.checkPropTypes(optionsShape, options, 'property', 'Logging'); 47 | service = new LoggingService(options); 48 | PropTypes.checkPropTypes(serviceShape, service, 'property', 'LoggingService'); 49 | return service; 50 | } 51 | 52 | /** 53 | * Logs a message to the 'info' log level. Can accept custom attributes as a property of the error 54 | * object, or as an optional second parameter. 55 | * 56 | * @param {string|Error} infoStringOrErrorObject 57 | * @param {Object} [customAttributes={}] 58 | */ 59 | export function logInfo(infoStringOrErrorObject, customAttributes) { 60 | return service.logInfo(infoStringOrErrorObject, customAttributes); 61 | } 62 | 63 | /** 64 | * Logs a message to the 'error' log level. Can accept custom attributes as a property of the error 65 | * object, or as an optional second parameter. 66 | * 67 | * @param {string|Error} errorStringOrObject 68 | * @param {Object} [customAttributes={}] 69 | */ 70 | export function logError(errorStringOrObject, customAttributes) { 71 | return service.logError(errorStringOrObject, customAttributes); 72 | } 73 | 74 | /** 75 | * Sets a custom attribute that will be included with all subsequent log messages. 76 | * 77 | * @param {string} name 78 | * @param {string|number|null} value 79 | */ 80 | export function setCustomAttribute(name, value) { 81 | return service.setCustomAttribute(name, value); 82 | } 83 | 84 | /** 85 | * 86 | * @throws {Error} Thrown if the logging service has not yet been configured via {@link configure}. 87 | * @returns {LoggingService} 88 | */ 89 | export function getLoggingService() { 90 | if (!service) { 91 | throw Error('You must first configure the logging service.'); 92 | } 93 | return service; 94 | } 95 | 96 | /** 97 | * Sets the configured logging service back to null. 98 | * 99 | */ 100 | export function resetLoggingService() { 101 | service = null; 102 | } 103 | 104 | /** 105 | * @name LoggingService 106 | * @interface 107 | * @memberof module:Logging 108 | * @property {function} logError 109 | * @property {function} logInfo 110 | */ 111 | -------------------------------------------------------------------------------- /src/pubSub.js: -------------------------------------------------------------------------------- 1 | /** 2 | * #### Import members from **@edx/frontend-platform** 3 | * 4 | * The PubSub module is a thin wrapper around the base functionality of 5 | * [PubSubJS](https://github.com/mroderick/PubSubJS). For the sake of simplicity and not relying 6 | * too heavily on implementation-specific features, it maintains a fairly simple API (subscribe, 7 | * unsubscribe, and publish). 8 | * 9 | * Publish/Subscribe events should be used mindfully, especially in relation to application UI 10 | * frameworks like React. Given React's unidirectional data flow and prop/state management 11 | * capabilities, using a pub/sub mechanism is at odds with that framework's best practices. 12 | * 13 | * That said, we use pub/sub in our application initialization sequence to allow applications to 14 | * hook into the initialization lifecycle, and we also use them to publish when the application 15 | * state has changed, i.e., when the config document or user's authentication state have changed. 16 | * 17 | * @module PubSub 18 | */ 19 | 20 | import PubSub from 'pubsub-js'; 21 | 22 | /** 23 | * 24 | * @param {string} type 25 | * @param {function} callback 26 | * @returns {string} A subscription token that can be passed to `unsubscribe` 27 | */ 28 | export function subscribe(type, callback) { 29 | return PubSub.subscribe(type, callback); 30 | } 31 | 32 | /** 33 | * 34 | * @param {string} token A subscription token provided by `subscribe` 35 | */ 36 | export function unsubscribe(token) { 37 | return PubSub.unsubscribe(token); 38 | } 39 | 40 | /** 41 | * 42 | * @param {string} type 43 | * @param {Object} data 44 | */ 45 | export function publish(type, data) { 46 | return PubSub.publish(type, data); 47 | } 48 | -------------------------------------------------------------------------------- /src/react/AppContext.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * `AppContext` provides data from `App` in a way that React components can readily consume, even 5 | * if it's mutable data. `AppContext` contains the following data structure: 6 | * 7 | * ``` 8 | * { 9 | * authenticatedUser: , 10 | * config: 11 | * } 12 | * ``` 13 | * If the `App.authenticatedUser` or `App.config` data changes, `AppContext` will be updated 14 | * accordingly and pass those changes onto React components using the context. 15 | * 16 | * `AppContext` is used in a React application like any other `[React Context](https://reactjs.org/docs/context.html) 17 | * @memberof module:React 18 | */ 19 | const AppContext = React.createContext({ 20 | authenticatedUser: null, 21 | config: {}, 22 | }); 23 | 24 | export default AppContext; 25 | -------------------------------------------------------------------------------- /src/react/AppProvider.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useMemo } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { BrowserRouter as Router } from 'react-router-dom'; 4 | 5 | import OptionalReduxProvider from './OptionalReduxProvider'; 6 | 7 | import ErrorBoundary from './ErrorBoundary'; 8 | import AppContext from './AppContext'; 9 | import { 10 | useAppEvent, 11 | useParagonTheme, 12 | useTrackColorSchemeChoice, 13 | } from './hooks'; 14 | import { paragonThemeActions } from './reducers'; 15 | import { getAuthenticatedUser, AUTHENTICATED_USER_CHANGED } from '../auth'; 16 | import { getConfig } from '../config'; 17 | import { CONFIG_CHANGED } from '../constants'; 18 | import { 19 | getLocale, 20 | getMessages, 21 | IntlProvider, 22 | LOCALE_CHANGED, 23 | } from '../i18n'; 24 | import { basename } from '../initialize'; 25 | import { SELECTED_THEME_VARIANT_KEY } from './constants'; 26 | 27 | /** 28 | * A wrapper component for React-based micro-frontends to initialize a number of common data/ 29 | * context providers. 30 | * 31 | * ``` 32 | * subscribe(APP_READY, () => { 33 | * ReactDOM.render( 34 | * 35 | * 36 | * 37 | * ) 38 | * }); 39 | * ``` 40 | * 41 | * This will provide the following to HelloWorld: 42 | * - An error boundary as described above. 43 | * - An `AppContext` provider for React context data. 44 | * - IntlProvider for @edx/frontend-i18n internationalization 45 | * - Optionally a redux `Provider`. Will only be included if a `store` property is passed to 46 | * `AppProvider`. 47 | * - A `Router` for react-router. 48 | * - A theme manager for Paragon. 49 | * 50 | * @param {Object} props 51 | * @param {Object} [props.store] A redux store. 52 | * @memberof module:React 53 | */ 54 | export default function AppProvider({ store = null, children, wrapWithRouter = true }) { 55 | const [config, setConfig] = useState(getConfig()); 56 | const [authenticatedUser, setAuthenticatedUser] = useState(getAuthenticatedUser()); 57 | const [locale, setLocale] = useState(getLocale()); 58 | 59 | useAppEvent(AUTHENTICATED_USER_CHANGED, () => { 60 | setAuthenticatedUser(getAuthenticatedUser()); 61 | }); 62 | 63 | useAppEvent(CONFIG_CHANGED, () => { 64 | setConfig(getConfig()); 65 | }); 66 | 67 | useAppEvent(LOCALE_CHANGED, () => { 68 | setLocale(getLocale()); 69 | }); 70 | 71 | useTrackColorSchemeChoice(); 72 | const [paragonThemeState, paragonThemeDispatch] = useParagonTheme(); 73 | 74 | const appContextValue = useMemo(() => ({ 75 | authenticatedUser, 76 | config, 77 | locale, 78 | paragonTheme: { 79 | state: paragonThemeState, 80 | setThemeVariant: (themeVariant) => { 81 | paragonThemeDispatch(paragonThemeActions.setParagonThemeVariant(themeVariant)); 82 | 83 | // Persist selected theme variant to localStorage. 84 | window.localStorage.setItem(SELECTED_THEME_VARIANT_KEY, themeVariant); 85 | }, 86 | }, 87 | }), [authenticatedUser, config, locale, paragonThemeState, paragonThemeDispatch]); 88 | 89 | if (!paragonThemeState?.isThemeLoaded) { 90 | return null; 91 | } 92 | 93 | return ( 94 | 95 | 96 | 99 | 100 | {wrapWithRouter ? ( 101 | 102 |
103 | {children} 104 |
105 |
106 | ) : children} 107 |
108 |
109 |
110 |
111 | ); 112 | } 113 | 114 | AppProvider.propTypes = { 115 | store: PropTypes.shape({}), 116 | children: PropTypes.node.isRequired, 117 | wrapWithRouter: PropTypes.bool, 118 | }; 119 | -------------------------------------------------------------------------------- /src/react/AuthenticatedPageRoute.jsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import AppContext from './AppContext'; 5 | import PageWrap from './PageWrap'; 6 | import { getLoginRedirectUrl } from '../auth'; 7 | 8 | /** 9 | * A react-router route that redirects to the login page when the route becomes active and the user 10 | * is not authenticated. If the application has been initialized with `requireAuthenticatedUser` 11 | * false, an authenticatedPageRoute can be used to protect a subset of the application's routes, 12 | * rather than the entire application. 13 | * 14 | * It can optionally accept an override URL to redirect to instead of the login page. 15 | * 16 | * Like a `PageWrap`, also calls `sendPageEvent` when the route becomes active. 17 | * 18 | * @see PageWrap 19 | * @see {@link module:frontend-platform/analytics~sendPageEvent} 20 | * @memberof module:React 21 | * @param {Object} props 22 | * @param {string} [props.redirectUrl] The URL anonymous users should be redirected to, rather than 23 | * viewing the route's contents. 24 | */ 25 | export default function AuthenticatedPageRoute({ redirectUrl = null, children }) { 26 | const { authenticatedUser } = useContext(AppContext); 27 | if (authenticatedUser === null) { 28 | const destination = redirectUrl || getLoginRedirectUrl(global.location.href); 29 | global.location.assign(destination); 30 | 31 | return null; 32 | } 33 | 34 | return ( 35 | 36 | {children} 37 | 38 | ); 39 | } 40 | 41 | AuthenticatedPageRoute.propTypes = { 42 | redirectUrl: PropTypes.string, 43 | children: PropTypes.node.isRequired, 44 | }; 45 | -------------------------------------------------------------------------------- /src/react/AuthenticatedPageRoute.test.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-no-constructed-context-values */ 2 | import React from 'react'; 3 | import { render } from '@testing-library/react'; 4 | import { Route, Routes, MemoryRouter } from 'react-router-dom'; 5 | import { getAuthenticatedUser, getLoginRedirectUrl } from '../auth'; 6 | import AuthenticatedPageRoute from './AuthenticatedPageRoute'; 7 | import AppContext from './AppContext'; 8 | import { getConfig } from '../config'; 9 | import { sendPageEvent } from '../analytics'; 10 | 11 | jest.mock('../analytics'); 12 | jest.mock('../auth'); 13 | 14 | describe('AuthenticatedPageRoute', () => { 15 | const { location } = global; 16 | 17 | beforeEach(() => { 18 | delete global.location; 19 | global.location = { 20 | assign: jest.fn(), 21 | }; 22 | sendPageEvent.mockReset(); 23 | getLoginRedirectUrl.mockReset(); 24 | getAuthenticatedUser.mockReset(); 25 | }); 26 | 27 | afterEach(() => { 28 | global.location = location; 29 | }); 30 | 31 | it('should redirect to login if not authenticated', () => { 32 | getAuthenticatedUser.mockReturnValue(null); 33 | getLoginRedirectUrl.mockReturnValue('http://localhost/login?next=http%3A%2F%2Flocalhost%2Fauthenticated'); 34 | const component = ( 35 | 41 | 42 | 43 |

Anonymous

} /> 44 |

Authenticated

} /> 45 |
46 |
47 |
48 | ); 49 | global.location.href = 'http://localhost/authenticated'; 50 | render(component); 51 | expect(getLoginRedirectUrl).toHaveBeenCalledWith('http://localhost/authenticated'); 52 | expect(sendPageEvent).not.toHaveBeenCalled(); 53 | expect(global.location.assign).toHaveBeenCalledWith('http://localhost/login?next=http%3A%2F%2Flocalhost%2Fauthenticated'); 54 | }); 55 | 56 | it('should redirect to custom redirect URL if not authenticated', () => { 57 | getAuthenticatedUser.mockReturnValue(null); 58 | getLoginRedirectUrl.mockReturnValue('http://localhost/login?next=http%3A%2F%2Flocalhost%2Fauthenticated'); 59 | const authenticatedElement = ( 60 | 61 |

Authenticated

62 |
63 | ); 64 | const component = ( 65 | 71 | 72 | 73 |

Anonymous

} /> 74 | 75 |
76 |
77 |
78 | ); 79 | render(component); 80 | expect(getLoginRedirectUrl).not.toHaveBeenCalled(); 81 | expect(sendPageEvent).not.toHaveBeenCalled(); 82 | expect(global.location.assign).toHaveBeenCalledWith('http://localhost/elsewhere'); 83 | }); 84 | 85 | it('should not call login if not the current route', () => { 86 | getAuthenticatedUser.mockReturnValue(null); 87 | getLoginRedirectUrl.mockReturnValue('http://localhost/login?next=http%3A%2F%2Flocalhost%2Fauthenticated'); 88 | const component = ( 89 | 95 | 96 | 97 | Anonymous

} /> 98 |

Authenticated

} /> 99 |
100 |
101 |
102 | ); 103 | const wrapper = render(component); 104 | 105 | expect(getLoginRedirectUrl).not.toHaveBeenCalled(); 106 | expect(global.location.assign).not.toHaveBeenCalled(); 107 | expect(sendPageEvent).not.toHaveBeenCalled(); 108 | const element = wrapper.container.querySelector('p'); 109 | expect(element.textContent).toEqual('Anonymous'); // This is just a sanity check on our setup. 110 | }); 111 | 112 | it('should render authenticated route if authenticated', () => { 113 | const component = ( 114 | 120 | 121 | 122 | Anonymous

} /> 123 |

Authenticated

} /> 124 |
125 |
126 |
127 | ); 128 | const wrapper = render(component); 129 | expect(getLoginRedirectUrl).not.toHaveBeenCalled(); 130 | expect(global.location.assign).not.toHaveBeenCalled(); 131 | expect(sendPageEvent).toHaveBeenCalled(); 132 | const element = wrapper.container.querySelector('p'); 133 | expect(element.textContent).toEqual('Authenticated'); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/react/ErrorBoundary.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { logError } from '../logging'; 5 | 6 | import ErrorPage from './ErrorPage'; 7 | 8 | /** 9 | * Error boundary component used to log caught errors and display the error page. 10 | * 11 | * @memberof module:React 12 | * @extends {Component} 13 | */ 14 | class ErrorBoundary extends Component { 15 | constructor(props) { 16 | super(props); 17 | this.state = { hasError: false }; 18 | } 19 | 20 | static getDerivedStateFromError() { 21 | // Update state so the next render will show the fallback UI. 22 | return { hasError: true }; 23 | } 24 | 25 | componentDidCatch(error, info) { 26 | logError(error, { stack: info.componentStack }); 27 | } 28 | 29 | render() { 30 | if (this.state.hasError) { 31 | return this.props.fallbackComponent || ; 32 | } 33 | return this.props.children; 34 | } 35 | } 36 | 37 | ErrorBoundary.propTypes = { 38 | children: PropTypes.node, 39 | fallbackComponent: PropTypes.node, 40 | }; 41 | 42 | ErrorBoundary.defaultProps = { 43 | children: null, 44 | fallbackComponent: undefined, 45 | }; 46 | 47 | export default ErrorBoundary; 48 | -------------------------------------------------------------------------------- /src/react/ErrorBoundary.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | 4 | import ErrorBoundary from './ErrorBoundary'; 5 | import { initializeMockApp } from '..'; 6 | 7 | describe('ErrorBoundary', () => { 8 | let logError = jest.fn(); 9 | 10 | beforeEach(async () => { 11 | // This is a gross hack to suppress error logs in the invalid parentSelector test 12 | jest.spyOn(console, 'error'); 13 | global.console.error.mockImplementation(() => {}); 14 | 15 | const { loggingService } = initializeMockApp(); 16 | logError = loggingService.logError; 17 | }); 18 | 19 | afterEach(() => { 20 | global.console.error.mockRestore(); 21 | jest.clearAllMocks(); 22 | }); 23 | 24 | it('should render children if no error', () => { 25 | const component = ( 26 | 27 |
Yay
28 |
29 | ); 30 | const { container: wrapper } = render(component); 31 | 32 | const element = wrapper.querySelector('div'); 33 | expect(element.textContent).toEqual('Yay'); 34 | }); 35 | 36 | it('should render ErrorPage if it has an error', () => { 37 | const ExplodingComponent = () => { 38 | throw new Error('booyah'); 39 | }; 40 | 41 | const component = ( 42 | 43 | 44 | 45 | ); 46 | 47 | render(component); 48 | 49 | expect(logError).toHaveBeenCalledTimes(1); 50 | expect(logError).toHaveBeenCalledWith( 51 | new Error('booyah'), 52 | expect.objectContaining({ 53 | stack: expect.stringContaining('ExplodingComponent'), 54 | }), 55 | ); 56 | }); 57 | it('should render the fallback component when an error occurs', () => { 58 | function FallbackComponent() { 59 | return
Oops, something went wrong!
; 60 | } 61 | function ComponentError() { 62 | throw new Error('An error occurred during the click event!'); 63 | } 64 | const wrapper = render( 65 | }> 66 | 67 | , 68 | ); 69 | 70 | expect(wrapper.queryByTestId('fallback-component')).toBeInTheDocument(); 71 | }); 72 | 73 | it('should render the ErrorPage fallbackComponent is null', () => { 74 | function ComponentError() { 75 | throw new Error('An error occurred during the click event!'); 76 | } 77 | const wrapper = render( 78 | 79 | 80 | , 81 | ); 82 | expect(wrapper.queryByTestId('error-page')).toBeInTheDocument(); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/react/ErrorPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { 4 | Button, Container, Row, Col, 5 | } from '@openedx/paragon'; 6 | 7 | import { useAppEvent } from './hooks'; 8 | import { 9 | FormattedMessage, 10 | IntlProvider, 11 | getMessages, 12 | getLocale, 13 | LOCALE_CHANGED, 14 | } from '../i18n'; 15 | 16 | /** 17 | * An error page that displays a generic message for unexpected errors. Also contains a "Try 18 | * Again" button to refresh the page. 19 | * 20 | * @memberof module:React 21 | * @extends {Component} 22 | */ 23 | function ErrorPage({ 24 | message, 25 | }) { 26 | const [locale, setLocale] = useState(getLocale()); 27 | 28 | useAppEvent(LOCALE_CHANGED, () => { 29 | setLocale(getLocale()); 30 | }); 31 | 32 | /* istanbul ignore next */ 33 | const reload = () => { 34 | global.location.reload(); 35 | }; 36 | 37 | return ( 38 | 39 | 40 | 41 | 42 |

43 | 48 |

49 | {message && ( 50 |
51 |

{message}

52 |
53 | )} 54 | 61 | 62 |
63 |
64 |
65 | ); 66 | } 67 | 68 | ErrorPage.propTypes = { 69 | message: PropTypes.string, 70 | }; 71 | 72 | ErrorPage.defaultProps = { 73 | message: null, 74 | }; 75 | 76 | export default ErrorPage; 77 | -------------------------------------------------------------------------------- /src/react/LoginRedirect.jsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { redirectToLogin } from '../auth'; 3 | 4 | /** 5 | * A React component that, when rendered, redirects to the login page as a side effect. Uses 6 | * `redirectToLogin` to perform the redirect. 7 | * 8 | * @see {@link module:frontend-platform/auth~redirectToLogin} 9 | * @memberof module:React 10 | */ 11 | export default function LoginRedirect() { 12 | useEffect(() => { 13 | redirectToLogin(global.location.href); 14 | }, []); 15 | return null; 16 | } 17 | -------------------------------------------------------------------------------- /src/react/OptionalReduxProvider.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import { Provider } from 'react-redux'; 4 | 5 | /** 6 | * @memberof module:React 7 | * @param {Object} props 8 | */ 9 | export default function OptionalReduxProvider({ store = null, children }) { 10 | if (store === null) { 11 | return children; 12 | } 13 | 14 | return ( 15 | 16 |
17 | {children} 18 |
19 |
20 | ); 21 | } 22 | 23 | OptionalReduxProvider.propTypes = { 24 | store: PropTypes.shape(), 25 | children: PropTypes.node.isRequired, 26 | }; 27 | -------------------------------------------------------------------------------- /src/react/PageWrap.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/prop-types */ 2 | // eslint-disable-next-line no-unused-vars 3 | import React, { useEffect } from 'react'; 4 | import { useLocation } from 'react-router-dom'; 5 | 6 | import { sendPageEvent } from '../analytics'; 7 | 8 | /** 9 | * A Wrapper component that calls `sendPageEvent` when it becomes active. 10 | * 11 | * @see {@link module:frontend-platform/analytics~sendPageEvent} 12 | * @memberof module:React 13 | * @param {Object} props 14 | */ 15 | export default function PageWrap({ children }) { 16 | const location = useLocation(); 17 | 18 | useEffect(() => { 19 | sendPageEvent(); 20 | // eslint-disable-next-line react-hooks/exhaustive-deps 21 | }, [location.pathname]); 22 | 23 | return children; 24 | } 25 | -------------------------------------------------------------------------------- /src/react/constants.js: -------------------------------------------------------------------------------- 1 | export const SET_THEME_VARIANT = 'SET_THEME_VARIANT'; 2 | export const SET_IS_THEME_LOADED = 'SET_IS_THEME_LOADED'; 3 | export const SELECTED_THEME_VARIANT_KEY = 'selected-paragon-theme-variant'; 4 | -------------------------------------------------------------------------------- /src/react/hooks/index.js: -------------------------------------------------------------------------------- 1 | export { default as useAppEvent } from './useAppEvent'; 2 | 3 | export * from './paragon'; 4 | -------------------------------------------------------------------------------- /src/react/hooks/paragon/index.js: -------------------------------------------------------------------------------- 1 | export { default as useTrackColorSchemeChoice } from './useTrackColorSchemeChoice'; 2 | export { default as useParagonTheme } from './useParagonTheme'; 3 | -------------------------------------------------------------------------------- /src/react/hooks/paragon/useTrackColorSchemeChoice.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { sendTrackEvent } from '../../../analytics'; 4 | 5 | /** 6 | * A custom React hook that listens for changes in the system's color scheme preference (via `matchMedia`) 7 | * and sends an event with the chosen color scheme (either `light` or `dark`) to the provided tracking service. 8 | * It sends an event both when the hook is first initialized (to capture the user's initial preference) 9 | * and when the system's color scheme preference changes. 10 | * 11 | * @memberof module:React 12 | */ 13 | const useTrackColorSchemeChoice = () => { 14 | useEffect(() => { 15 | const trackColorSchemeChoice = ({ matches }) => { 16 | const preferredColorScheme = matches ? 'dark' : 'light'; 17 | sendTrackEvent('openedx.ui.frontend-platform.prefers-color-scheme.selected', { preferredColorScheme }); 18 | }; 19 | const colorSchemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)'); 20 | if (colorSchemeQuery) { 21 | // send user's initial choice 22 | trackColorSchemeChoice(colorSchemeQuery); 23 | colorSchemeQuery.addEventListener('change', trackColorSchemeChoice); 24 | } 25 | return () => { 26 | if (colorSchemeQuery) { 27 | colorSchemeQuery.removeEventListener('change', trackColorSchemeChoice); 28 | } 29 | }; 30 | }, []); 31 | }; 32 | 33 | export default useTrackColorSchemeChoice; 34 | -------------------------------------------------------------------------------- /src/react/hooks/paragon/useTrackColorSchemeChoice.test.js: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react'; 2 | import { sendTrackEvent } from '../../../analytics'; 3 | import useTrackColorSchemeChoice from './useTrackColorSchemeChoice'; 4 | 5 | jest.mock('../../../analytics', () => ({ 6 | ...jest.requireActual('../../../analytics'), 7 | sendTrackEvent: jest.fn(), 8 | })); 9 | 10 | const mockAddEventListener = jest.fn(); 11 | const mockRemoveEventListener = jest.fn(); 12 | let matchesMock; 13 | 14 | Object.defineProperty(window, 'matchMedia', { 15 | value: jest.fn(() => ({ 16 | addEventListener: mockAddEventListener, 17 | removeEventListener: mockRemoveEventListener, 18 | matches: matchesMock, 19 | })), 20 | }); 21 | 22 | describe('useTrackColorSchemeChoice', () => { 23 | afterEach(() => { 24 | mockAddEventListener.mockClear(); 25 | mockRemoveEventListener.mockClear(); 26 | sendTrackEvent.mockClear(); 27 | }); 28 | 29 | it('sends dark preferred color schema event if query matches', async () => { 30 | matchesMock = true; 31 | renderHook(() => useTrackColorSchemeChoice()); 32 | 33 | expect(sendTrackEvent).toHaveBeenCalledTimes(1); 34 | expect(sendTrackEvent).toHaveBeenCalledWith( 35 | 'openedx.ui.frontend-platform.prefers-color-scheme.selected', 36 | { preferredColorScheme: 'dark' }, 37 | ); 38 | }); 39 | 40 | it('sends light preferred color schema event if query does not match', async () => { 41 | matchesMock = false; 42 | renderHook(() => useTrackColorSchemeChoice()); 43 | 44 | expect(sendTrackEvent).toHaveBeenCalledTimes(1); 45 | expect(sendTrackEvent).toHaveBeenCalledWith( 46 | 'openedx.ui.frontend-platform.prefers-color-scheme.selected', 47 | { preferredColorScheme: 'light' }, 48 | ); 49 | }); 50 | 51 | it('adds change event listener to matchMedia query', async () => { 52 | renderHook(() => useTrackColorSchemeChoice()); 53 | 54 | expect(mockAddEventListener).toHaveBeenCalledTimes(1); 55 | expect(mockAddEventListener).toHaveBeenCalledWith('change', expect.any(Function)); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /src/react/hooks/paragon/utils.js: -------------------------------------------------------------------------------- 1 | import { basename } from '../../../initialize'; 2 | 3 | /** 4 | * Iterates through each given `` element and removes it from the DOM. 5 | * @param {HTMLLinkElement[]} existingLinks 6 | */ 7 | export const removeExistingLinks = (existingLinks) => { 8 | existingLinks.forEach((link) => { 9 | link.remove(); 10 | }); 11 | }; 12 | 13 | /** 14 | * Creates the fallback URL for the given theme file. 15 | * @param {string} url The theme file path. 16 | * @returns {string} The default theme url. 17 | */ 18 | export const fallbackThemeUrl = (url) => { 19 | const baseUrl = window.location?.origin; 20 | 21 | // validates if the baseurl has the protocol to be interpreted correctly by the browser, 22 | // if is not present add '//' to use Protocol-relative URL 23 | const protocol = /^(https?:)?\/\//.test(baseUrl) ? '' : '//'; 24 | 25 | return `${protocol}${baseUrl}${basename}${url}`; 26 | }; 27 | 28 | export const isEmptyObject = (obj) => !obj || Object.keys(obj).length === 0; 29 | -------------------------------------------------------------------------------- /src/react/hooks/paragon/utils.test.js: -------------------------------------------------------------------------------- 1 | import { fallbackThemeUrl } from './utils'; 2 | 3 | describe('fallbackThemeUrl', () => { 4 | const originalWindowLocation = window.location; 5 | const mockWindowLocationOrigin = jest.fn(); 6 | 7 | beforeEach(() => { 8 | Object.defineProperty(window, 'location', { 9 | value: { 10 | get origin() { 11 | return mockWindowLocationOrigin(); 12 | }, 13 | }, 14 | }); 15 | }); 16 | 17 | afterEach(() => { 18 | jest.clearAllMocks(); 19 | }); 20 | 21 | afterAll(() => { 22 | Object.defineProperty(window, 'location', originalWindowLocation); 23 | }); 24 | 25 | it('should return a full url based on the window location', () => { 26 | mockWindowLocationOrigin.mockReturnValue('http://example.com'); 27 | 28 | expect(fallbackThemeUrl('my.css')).toBe('http://example.com/my.css'); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/react/hooks/useAppEvent.js: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { subscribe, unsubscribe } from '../../pubSub'; 4 | 5 | /** 6 | * A React hook that allows functional components to subscribe to application events. This should 7 | * be used sparingly - for the most part, Context should be used higher-up in the application to 8 | * provide necessary data to a given component, rather than utilizing a non-React-like Pub/Sub 9 | * mechanism. 10 | * 11 | * @memberof module:React 12 | * 13 | * @param {string} type 14 | * @param {function} callback 15 | */ 16 | const useAppEvent = (type, callback) => { 17 | useEffect(() => { 18 | const subscriptionToken = subscribe(type, callback); 19 | 20 | return () => unsubscribe(subscriptionToken); 21 | }, [callback, type]); 22 | }; 23 | 24 | export default useAppEvent; 25 | -------------------------------------------------------------------------------- /src/react/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * #### Import members from **@edx/frontend-platform/react** 3 | * The React module provides a variety of React components, hooks, and contexts for use in an 4 | * application. 5 | * 6 | * @module React 7 | */ 8 | 9 | export { default as AppContext } from './AppContext'; 10 | export { default as AppProvider } from './AppProvider'; 11 | export { default as AuthenticatedPageRoute } from './AuthenticatedPageRoute'; 12 | export { default as ErrorBoundary } from './ErrorBoundary'; 13 | export { default as ErrorPage } from './ErrorPage'; 14 | export { default as LoginRedirect } from './LoginRedirect'; 15 | export { default as PageWrap } from './PageWrap'; 16 | export { useAppEvent } from './hooks'; 17 | -------------------------------------------------------------------------------- /src/react/reducers.js: -------------------------------------------------------------------------------- 1 | import { 2 | SET_THEME_VARIANT, 3 | SET_IS_THEME_LOADED, 4 | } from './constants'; 5 | 6 | export function paragonThemeReducer(state, action) { 7 | switch (action.type) { 8 | case SET_THEME_VARIANT: { 9 | const requestedThemeVariant = action.payload; 10 | return { 11 | ...state, 12 | themeVariant: requestedThemeVariant, 13 | }; 14 | } 15 | case SET_IS_THEME_LOADED: { 16 | const requestedIsThemeLoaded = action.payload; 17 | return { 18 | ...state, 19 | isThemeLoaded: requestedIsThemeLoaded, 20 | }; 21 | } 22 | default: 23 | return state; 24 | } 25 | } 26 | 27 | const setParagonThemeVariant = (payload) => ({ 28 | type: SET_THEME_VARIANT, 29 | payload, 30 | }); 31 | 32 | const setParagonThemeLoaded = (payload) => ({ 33 | type: SET_IS_THEME_LOADED, 34 | payload, 35 | }); 36 | 37 | export const paragonThemeActions = { 38 | setParagonThemeVariant, 39 | setParagonThemeLoaded, 40 | }; 41 | -------------------------------------------------------------------------------- /src/scripts/GoogleAnalyticsLoader.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @implements {GoogleAnalyticsLoader} 3 | * @memberof module:GoogleAnalytics 4 | */ 5 | class GoogleAnalyticsLoader { 6 | constructor({ config }) { 7 | this.analyticsId = config.GOOGLE_ANALYTICS_4_ID; 8 | } 9 | 10 | loadScript() { 11 | if (!this.analyticsId) { 12 | return; 13 | } 14 | 15 | global.googleAnalytics = global.googleAnalytics || []; 16 | const { googleAnalytics } = global; 17 | 18 | // If the snippet was invoked do nothing. 19 | if (googleAnalytics.invoked) { 20 | return; 21 | } 22 | 23 | // Invoked flag, to make sure the snippet 24 | // is never invoked twice. 25 | googleAnalytics.invoked = true; 26 | 27 | googleAnalytics.load = (key, options) => { 28 | const scriptSrc = document.createElement('script'); 29 | scriptSrc.type = 'text/javascript'; 30 | scriptSrc.async = true; 31 | scriptSrc.src = `https://www.googletagmanager.com/gtag/js?id=${key}`; 32 | 33 | const scriptGtag = document.createElement('script'); 34 | scriptGtag.innerHTML = ` 35 | window.dataLayer = window.dataLayer || []; 36 | function gtag(){dataLayer.push(arguments);} 37 | gtag('js', new Date()); 38 | gtag('config', '${key}'); 39 | `; 40 | 41 | // Insert our scripts next to the first script element. 42 | const first = document.getElementsByTagName('script')[0]; 43 | first.parentNode.insertBefore(scriptSrc, first); 44 | first.parentNode.insertBefore(scriptGtag, first); 45 | googleAnalytics._loadOptions = options; // eslint-disable-line no-underscore-dangle 46 | }; 47 | 48 | // Load GoogleAnalytics with your key. 49 | googleAnalytics.load(this.analyticsId); 50 | } 51 | } 52 | 53 | export default GoogleAnalyticsLoader; 54 | -------------------------------------------------------------------------------- /src/scripts/GoogleAnalyticsLoader.test.js: -------------------------------------------------------------------------------- 1 | import { GoogleAnalyticsLoader } from './index'; 2 | 3 | const googleAnalyticsId = 'test-key'; 4 | 5 | describe('GoogleAnalytics', () => { 6 | let body; 7 | let gaScriptSrc; 8 | let gaScriptGtag; 9 | let data; 10 | 11 | beforeEach(() => { 12 | window.googleAnalytics = []; 13 | }); 14 | 15 | function loadGoogleAnalytics(scriptData) { 16 | const script = new GoogleAnalyticsLoader(scriptData); 17 | script.loadScript(); 18 | } 19 | 20 | describe('with valid GOOGLE_ANALYTICS_4_ID', () => { 21 | beforeEach(() => { 22 | document.body.innerHTML = '