Compare commits
1 Commits
master
...
abutterwor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a0b2d40bf |
69
.env
69
.env
@@ -1,55 +1,16 @@
|
||||
# See README.rst for explanations of these.
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='production'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME=''
|
||||
APP_ID='learning'
|
||||
BASE_URL=''
|
||||
CONTACT_URL=''
|
||||
CREDENTIALS_BASE_URL=''
|
||||
CREDIT_HELP_LINK_URL=''
|
||||
CSRF_TOKEN_API_PATH=''
|
||||
DISCOVERY_API_BASE_URL=''
|
||||
DISCUSSIONS_MFE_BASE_URL=''
|
||||
DISCOUNT_CODE_INFO_URL=''
|
||||
ECOMMERCE_BASE_URL=''
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME=''
|
||||
ENTERPRISE_LEARNER_PORTAL_URL=''
|
||||
EXAMS_BASE_URL=''
|
||||
FAVICON_URL=''
|
||||
IGNORED_ERROR_REGEX=''
|
||||
INSIGHTS_BASE_URL=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME=''
|
||||
LMS_BASE_URL=''
|
||||
LOGIN_URL=''
|
||||
LOGOUT_URL=''
|
||||
LOGO_URL=''
|
||||
LOGO_TRADEMARK_URL=''
|
||||
LOGO_WHITE_URL=''
|
||||
LEGACY_THEME_NAME=''
|
||||
MARKETING_SITE_BASE_URL=''
|
||||
ORDER_HISTORY_URL=''
|
||||
PROCTORED_EXAM_FAQ_URL=''
|
||||
PROCTORED_EXAM_RULES_URL=''
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=''
|
||||
SEARCH_CATALOG_URL=''
|
||||
SEGMENT_KEY=''
|
||||
SESSION_COOKIE_DOMAIN=''
|
||||
SITE_NAME=''
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN=''
|
||||
STUDIO_BASE_URL=''
|
||||
SUPPORT_URL=''
|
||||
SUPPORT_URL_CALCULATOR_MATH=''
|
||||
SUPPORT_URL_ID_VERIFICATION=''
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE=''
|
||||
TERMS_OF_SERVICE_URL=''
|
||||
TWITTER_HASHTAG=''
|
||||
TWITTER_URL=''
|
||||
USER_INFO_COOKIE_NAME=''
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
ACCESS_TOKEN_COOKIE_NAME=null
|
||||
BASE_URL=null
|
||||
CREDENTIALS_BASE_URL=null
|
||||
CSRF_TOKEN_API_PATH=null
|
||||
ECOMMERCE_BASE_URL=null
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME=null
|
||||
LMS_BASE_URL=null
|
||||
LOGIN_URL=null
|
||||
LOGOUT_URL=null
|
||||
MARKETING_SITE_BASE_URL=null
|
||||
ORDER_HISTORY_URL=null
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT=null
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME=null
|
||||
USER_INFO_COOKIE_NAME=null
|
||||
|
||||
@@ -1,57 +1,17 @@
|
||||
# See README.rst for explanations of these.
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='development'
|
||||
|
||||
PORT=2000
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
APP_ID='learning'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
BASE_URL='localhost:2000'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-college-credit-or-credit-hours-for-my-course'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
DISCOUNT_CODE_INFO_URL=''
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:8734'
|
||||
EXAMS_BASE_URL=''
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LEGACY_THEME_NAME=''
|
||||
LOGOUT_URL='http://localhost:18000/login'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
PROCTORED_EXAM_FAQ_URL=''
|
||||
PROCTORED_EXAM_RULES_URL=''
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=''
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
|
||||
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
|
||||
TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
|
||||
TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
SESSION_COOKIE_DOMAIN='localhost'
|
||||
CHAT_RESPONSE_URL='http://localhost:18000/api/learning_assistant/v1/course_id'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
|
||||
OPTIMIZELY_FULL_STACK_SDK_KEY=''
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||
# Fallback in local style files
|
||||
PARAGON_THEME_URLS={}
|
||||
|
||||
47
.env.test
47
.env.test
@@ -1,54 +1,15 @@
|
||||
# See README.rst for explanations of these.
|
||||
# If you add a new learning MFE-specific variable, please note it there!
|
||||
|
||||
NODE_ENV='test'
|
||||
|
||||
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
|
||||
APP_ID='learning'
|
||||
BASE_URL='http://localhost:2000'
|
||||
CONTACT_URL='http://localhost:18000/contact'
|
||||
BASE_URL='localhost:1995'
|
||||
CREDENTIALS_BASE_URL='http://localhost:18150'
|
||||
CREDIT_HELP_LINK_URL='https://help.edx.org/edxlearner/s/article/Can-I-receive-college-credit-or-credit-hours-for-my-course'
|
||||
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
|
||||
DISCOVERY_API_BASE_URL='http://localhost:18381'
|
||||
DISCUSSIONS_MFE_BASE_URL='http://localhost:2002'
|
||||
DISCOUNT_CODE_INFO_URL=''
|
||||
ECOMMERCE_BASE_URL='http://localhost:18130'
|
||||
ENABLE_JUMPNAV='true'
|
||||
ENABLE_NOTICES=''
|
||||
ENTERPRISE_LEARNER_PORTAL_HOSTNAME='localhost:8734'
|
||||
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:8734'
|
||||
EXAMS_BASE_URL='http://localhost:18740'
|
||||
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
|
||||
IGNORED_ERROR_REGEX=''
|
||||
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
|
||||
LMS_BASE_URL='http://localhost:18000'
|
||||
LOGIN_URL='http://localhost:18000/login'
|
||||
LOGOUT_URL='http://localhost:18000/logout'
|
||||
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
|
||||
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
|
||||
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
|
||||
LEGACY_THEME_NAME=''
|
||||
LOGOUT_URL='http://localhost:18000/login'
|
||||
MARKETING_SITE_BASE_URL='http://localhost:18000'
|
||||
ORDER_HISTORY_URL='http://localhost:1996/orders'
|
||||
PORT=2000
|
||||
PROCTORED_EXAM_FAQ_URL=''
|
||||
PROCTORED_EXAM_RULES_URL=''
|
||||
ORDER_HISTORY_URL='localhost:1996/orders'
|
||||
REFRESH_ACCESS_TOKEN_ENDPOINT='http://localhost:18000/login_refresh'
|
||||
SEARCH_CATALOG_URL='http://localhost:18000/courses'
|
||||
SEGMENT_KEY=''
|
||||
SEGMENT_KEY=null
|
||||
SITE_NAME='edX'
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN='edxmilestone'
|
||||
STUDIO_BASE_URL='http://localhost:18010'
|
||||
SUPPORT_URL='https://support.edx.org'
|
||||
SUPPORT_URL_CALCULATOR_MATH='https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator'
|
||||
SUPPORT_URL_ID_VERIFICATION='https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity'
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE='https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate'
|
||||
TERMS_OF_SERVICE_URL='https://www.edx.org/edx-terms-service'
|
||||
TWITTER_HASHTAG='myedxjourney'
|
||||
TWITTER_URL='https://twitter.com/edXOnline'
|
||||
USER_INFO_COOKIE_NAME='edx-user-info'
|
||||
PRIVACY_POLICY_URL='http://localhost:18000/privacy'
|
||||
SHOW_UNGRADED_ASSIGNMENT_PROGRESS=''
|
||||
ENTERPRISE_LEARNER_PORTAL_URL='http://localhost:Enterprise'
|
||||
FEATURE_ENABLE_CHAT_V2_ENDPOINT='false'
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
coverage/*
|
||||
dist/
|
||||
packages/
|
||||
node_modules/
|
||||
jest.config.js
|
||||
env.config.jsx
|
||||
example.env.config.jsx
|
||||
jest.config.js
|
||||
25
.eslintrc.js
25
.eslintrc.js
@@ -1,24 +1,3 @@
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
const config = createConfig('eslint', {
|
||||
rules: {
|
||||
// TODO: all these rules should be renabled/addressed. temporarily turned off to unblock a release.
|
||||
'react-hooks/rules-of-hooks': 'off',
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
'no-restricted-exports': 'off',
|
||||
'react/jsx-no-useless-fragment': 'off',
|
||||
'react/no-unknown-property': 'off',
|
||||
'func-names': 'off',
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
webpack: {
|
||||
config: 'webpack.prod.config.js',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
module.exports = config;
|
||||
module.exports = createConfig('eslint');
|
||||
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
@@ -1,7 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Adding new check for github-actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
@@ -1,19 +0,0 @@
|
||||
# Run the workflow that adds new tickets that are either:
|
||||
# - labelled "DEPR"
|
||||
# - title starts with "[DEPR]"
|
||||
# - body starts with "Proposal Date" (this is the first template field)
|
||||
# to the org-wide DEPR project board
|
||||
|
||||
name: Add newly created DEPR issues to the DEPR project board
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
routeissue:
|
||||
uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
|
||||
18
.github/workflows/add-issue-to-btr-project.yml
vendored
18
.github/workflows/add-issue-to-btr-project.yml
vendored
@@ -1,18 +0,0 @@
|
||||
# Run the workflow that adds new tickets that are labelled "release testing"
|
||||
# to the org-wide BTR project board
|
||||
|
||||
name: Add release testing issues to the BTR project board
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
# This workflow is triggered when an issue is labeled with 'release testing'.
|
||||
# It adds the issue to the BTR project and applies the 'needs triage' label
|
||||
# if it doesn't already have it.
|
||||
|
||||
jobs:
|
||||
handle-release-testing:
|
||||
uses: openedx/.github/.github/workflows/add-issue-to-btr-project.yml@master
|
||||
secrets:
|
||||
GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
|
||||
GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
|
||||
@@ -1,20 +0,0 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "label: " it tries to apply
|
||||
# the label indicated in rest of comment.
|
||||
# If the comment starts with "remove label: ", it tries
|
||||
# to remove the indicated label.
|
||||
# Note: Labels are allowed to have spaces and this script does
|
||||
# not parse spaces (as often a space is legitimate), so the command
|
||||
# "label: really long lots of words label" will apply the
|
||||
# label "really long lots of words label"
|
||||
|
||||
name: Allows for the adding and removing of labels via comment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
add_remove_labels:
|
||||
uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
|
||||
|
||||
10
.github/workflows/commitlint.yml
vendored
10
.github/workflows/commitlint.yml
vendored
@@ -1,10 +0,0 @@
|
||||
# Run commitlint on the commit messages in a pull request.
|
||||
|
||||
name: Lint Commit Messages
|
||||
|
||||
on:
|
||||
- pull_request
|
||||
|
||||
jobs:
|
||||
commitlint:
|
||||
uses: openedx/.github/.github/workflows/commitlint.yml@master
|
||||
13
.github/workflows/lockfileversion-check.yml
vendored
13
.github/workflows/lockfileversion-check.yml
vendored
@@ -1,13 +0,0 @@
|
||||
#check package-lock file version
|
||||
|
||||
name: Lockfile Version check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
version-check:
|
||||
uses: openedx/.github/.github/workflows/lockfileversion-check-v3.yml@master
|
||||
12
.github/workflows/self-assign-issue.yml
vendored
12
.github/workflows/self-assign-issue.yml
vendored
@@ -1,12 +0,0 @@
|
||||
# This workflow runs when a comment is made on the ticket
|
||||
# If the comment starts with "assign me" it assigns the author to the
|
||||
# ticket (case insensitive)
|
||||
|
||||
name: Assign comment author to ticket if they say "assign me"
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
jobs:
|
||||
self_assign_by_comment:
|
||||
uses: openedx/.github/.github/workflows/self-assign-issue.yml@master
|
||||
12
.github/workflows/update-browserslist-db.yml
vendored
12
.github/workflows/update-browserslist-db.yml
vendored
@@ -1,12 +0,0 @@
|
||||
name: Update Browserslist DB
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 1'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-browserslist:
|
||||
uses: openedx/.github/.github/workflows/update-browserslist-db.yml@master
|
||||
|
||||
secrets:
|
||||
requirements_bot_github_token: ${{ secrets.requirements_bot_github_token }}
|
||||
36
.github/workflows/validate.yml
vendored
36
.github/workflows/validate.yml
vendored
@@ -1,36 +0,0 @@
|
||||
name: validate
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- '**'
|
||||
jobs:
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
- run: make validate.ci
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
coverage:
|
||||
runs-on: ubuntu-latest
|
||||
needs: tests
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: code-coverage-report
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
fail_ci_if_error: true
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -1,32 +1,18 @@
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
node_modules
|
||||
npm-debug.log
|
||||
coverage
|
||||
env.config.*
|
||||
|
||||
dist/
|
||||
src/i18n/transifex_input.json
|
||||
temp/babel-plugin-react-intl
|
||||
logs
|
||||
|
||||
### pyenv ###
|
||||
.python-version
|
||||
|
||||
### Editors ###
|
||||
### Emacs ###
|
||||
*~
|
||||
/temp
|
||||
/.vscode
|
||||
|
||||
# Local package dependencies
|
||||
module.config.js
|
||||
|
||||
# Local environment overrides
|
||||
.env.private
|
||||
|
||||
src/i18n/messages/
|
||||
|
||||
env.config.jsx
|
||||
|
||||
15
.travis.yml
Executable file
15
.travis.yml
Executable file
@@ -0,0 +1,15 @@
|
||||
language: node_js
|
||||
node_js: 12
|
||||
before_install:
|
||||
- npm install -g npm@6
|
||||
install:
|
||||
- npm ci
|
||||
script:
|
||||
- make validate-no-uncommitted-package-lock-changes
|
||||
- npm run i18n_extract
|
||||
- npm run lint
|
||||
- npm run test
|
||||
- npm run build
|
||||
- npm run is-es5
|
||||
after_success:
|
||||
- codecov
|
||||
8
.tx/config
Normal file
8
.tx/config
Normal file
@@ -0,0 +1,8 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[edx-platform.frontend-app-learning]
|
||||
file_filter = src/i18n/messages/<lang>.json
|
||||
source_file = src/i18n/transifex_input.json
|
||||
source_lang = en
|
||||
type = KEYVALUEJSON
|
||||
50
Makefile
Normal file → Executable file
50
Makefile
Normal file → Executable file
@@ -1,17 +1,21 @@
|
||||
intl_imports = ./node_modules/.bin/intl-imports.js
|
||||
transifex_resource = frontend-app-learning
|
||||
transifex_langs = "ar,fr,es_419,zh_CN"
|
||||
|
||||
transifex_utils = ./node_modules/.bin/transifex-utils.js
|
||||
i18n = ./src/i18n
|
||||
transifex_input = $(i18n)/transifex_input.json
|
||||
tx_url1 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/translation/en/strings/
|
||||
tx_url2 = https://www.transifex.com/api/2/project/edx-platform/resource/$(transifex_resource)/source/
|
||||
|
||||
# This directory must match .babelrc .
|
||||
transifex_temp = ./temp/babel-plugin-formatjs
|
||||
transifex_temp = ./temp/babel-plugin-react-intl
|
||||
|
||||
precommit:
|
||||
npm run lint
|
||||
npm audit
|
||||
|
||||
requirements:
|
||||
npm ci
|
||||
npm install
|
||||
|
||||
i18n.extract:
|
||||
# Pulling display strings from .jsx files into .json files...
|
||||
@@ -29,38 +33,22 @@ detect_changed_source_translations:
|
||||
# Checking for changed translations...
|
||||
git diff --exit-code $(i18n)
|
||||
|
||||
# Pushes translations to Transifex. You must run make extract_translations first.
|
||||
push_translations:
|
||||
# Pushing strings to Transifex...
|
||||
tx push -s
|
||||
# Fetching hashes from Transifex...
|
||||
./node_modules/reactifex/bash_scripts/get_hashed_strings.sh $(tx_url1)
|
||||
# Writing out comments to file...
|
||||
$(transifex_utils) $(transifex_temp) --comments
|
||||
# Pushing comments to Transifex...
|
||||
./node_modules/reactifex/bash_scripts/put_comments.sh $(tx_url2)
|
||||
|
||||
# Pulls translations from Transifex.
|
||||
pull_translations:
|
||||
rm -rf src/i18n/messages
|
||||
mkdir src/i18n/messages
|
||||
cd src/i18n/messages \
|
||||
&& atlas pull $(ATLAS_OPTIONS) \
|
||||
translations/frontend-platform/src/i18n/messages:frontend-platform \
|
||||
translations/paragon/src/i18n/messages:paragon \
|
||||
translations/frontend-component-header/src/i18n/messages:frontend-component-header \
|
||||
translations/frontend-component-footer/src/i18n/messages:frontend-component-footer \
|
||||
translations/frontend-lib-special-exams/src/i18n/messages:frontend-lib-special-exams \
|
||||
translations/frontend-app-learning/src/i18n/messages:frontend-app-learning
|
||||
|
||||
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-lib-special-exams frontend-app-learning
|
||||
|
||||
tx pull -f --mode reviewed --language=$(transifex_langs)
|
||||
|
||||
# This target is used by Travis.
|
||||
validate-no-uncommitted-package-lock-changes:
|
||||
# Checking for package-lock.json changes...
|
||||
git diff --exit-code package-lock.json
|
||||
|
||||
.PHONY: validate
|
||||
validate:
|
||||
make validate-no-uncommitted-package-lock-changes
|
||||
npm run i18n_extract
|
||||
npm run lint -- --max-warnings 0
|
||||
npm run types
|
||||
npm run test
|
||||
npm run build
|
||||
npm run bundlewatch
|
||||
|
||||
.PHONY: validate.ci
|
||||
validate.ci:
|
||||
npm ci
|
||||
make validate
|
||||
|
||||
265
README.rst
265
README.rst
@@ -1,255 +1,22 @@
|
||||
|Build Status| |Coveralls| |npm_version| |npm_downloads| |license|
|
||||
|
||||
frontend-app-learning
|
||||
#####################
|
||||
|
||||
|codecov| |license|
|
||||
|
||||
Purpose
|
||||
*******
|
||||
|
||||
This is the Learning MFE (micro-frontend application), which renders all
|
||||
learner-facing course pages (like the course outline, the progress page,
|
||||
actual course content, etc).
|
||||
|
||||
.. |codecov| image:: https://codecov.io/gh/edx/frontend-app-learning/branch/master/graph/badge.svg?token=3z7XvuzTq3
|
||||
:target: https://codecov.io/gh/edx/frontend-app-learning
|
||||
.. |license| image:: https://img.shields.io/badge/license-AGPL-informational
|
||||
:target: https://github.com/openedx/frontend-app-account/blob/master/LICENSE
|
||||
|
||||
Getting Started
|
||||
***************
|
||||
|
||||
Prerequisites
|
||||
=============
|
||||
|
||||
`Tutor`_ is currently recommended as a development environment for the Learning
|
||||
MFE. Most likely, it already has this MFE configured; however, you'll need to
|
||||
make some changes in order to run it in development mode. You can refer
|
||||
to the `relevant tutor-mfe documentation`_ for details, or follow the quick
|
||||
guide below.
|
||||
|
||||
.. _Tutor: https://github.com/overhangio/tutor
|
||||
|
||||
.. _relevant tutor-mfe documentation: https://github.com/overhangio/tutor-mfe#mfe-development
|
||||
|
||||
|
||||
Cloning and Setup
|
||||
=================
|
||||
|
||||
1. Clone your new repo:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
git clone https://github.com/openedx/frontend-app-learning.git
|
||||
|
||||
2. Use the version of Node specified in ``.nvmrc``.
|
||||
|
||||
Using other major versions of node *may* work, but this is unsupported. For
|
||||
convenience, this repository includes an ``.nvmrc`` file to help in setting the
|
||||
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
|
||||
|
||||
3. Stop the Tutor devstack, if it's running: ``tutor dev stop``
|
||||
|
||||
4. Next, we need to tell Tutor that we're going to be running this repo in
|
||||
development mode, and it should be excluded from the ``mfe`` container that
|
||||
otherwise runs every MFE. Run this:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor mounts add /path/to/frontend-app-learning
|
||||
|
||||
5. Start Tutor in development mode. This command will start the LMS and Studio,
|
||||
and other required MFEs like ``authn`` and ``account``, but will not start
|
||||
the learning MFE, which we're going to run on the host instead of in a
|
||||
container managed by Tutor. Run:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor dev start lms cms mfe
|
||||
|
||||
Startup
|
||||
=======
|
||||
|
||||
1. Install npm dependencies:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cd frontend-app-learning && npm ci
|
||||
|
||||
2. Start the dev server:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
npm run dev
|
||||
|
||||
Then you can access the app at http://local.openedx.io:2000/learning/
|
||||
|
||||
Troubleshooting
|
||||
---------------
|
||||
|
||||
If you see an "Invalid Host header" error, then you're probably using a different domain name for your devstack such as
|
||||
``local.edly.io`` or ``local.overhang.io`` (not the new recommended default, ``local.openedx.io``). In that case, run
|
||||
these commands to update your devstack's domain names:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
tutor dev stop
|
||||
tutor config save --set LMS_HOST=local.openedx.io --set CMS_HOST=studio.local.openedx.io
|
||||
tutor dev launch -I --skip-build
|
||||
tutor dev stop learning # We will run this MFE on the host
|
||||
|
||||
Local module development
|
||||
=========================
|
||||
|
||||
To develop locally on modules that are installed into this app, you'll need to create a ``module.config.js``
|
||||
file (which is git-ignored) that defines where to find your local modules, for instance:
|
||||
Please tag **@edx/teaching-and-learning** on any PRs or issues. Thanks.
|
||||
|
||||
.. code-block:: js
|
||||
Introduction
|
||||
------------
|
||||
|
||||
module.exports = {
|
||||
/*
|
||||
Modules you want to use from local source code. Adding a module here means that when this app
|
||||
runs its build, it'll resolve the source from peer directories of this app.
|
||||
React app for edX learning.
|
||||
|
||||
moduleName: the name you use to import code from the module.
|
||||
dir: The relative path to the module's source code.
|
||||
dist: The sub-directory of the source code where it puts its build artifact. Often "dist", though you
|
||||
may want to use "src" if the module installs React as a peer/dev dependency.
|
||||
*/
|
||||
localModules: [
|
||||
{ moduleName: '@openedx/paragon/scss', dir: '../paragon', dist: 'scss' },
|
||||
{ moduleName: '@openedx/paragon', dir: '../paragon', dist: 'dist' },
|
||||
{ moduleName: '@openedx/frontend-enterprise', dir: '../frontend-enterprise', dist: 'src' },
|
||||
{ moduleName: '@openedx/frontend-platform', dir: '../frontend-platform', dist: 'dist' },
|
||||
],
|
||||
};
|
||||
|
||||
See https://github.com/openedx/frontend-build#local-module-configuration-for-webpack for more details.
|
||||
|
||||
Deployment
|
||||
==========
|
||||
|
||||
The Learning MFE is similar to all the other Open edX MFEs. Read the Open
|
||||
edX Developer Guide's section on
|
||||
`MFE applications <https://openedx.github.io/frontend-platform/>`_.
|
||||
|
||||
Plugins
|
||||
=======
|
||||
This MFE can be customized using `Frontend Plugin Framework <https://github.com/openedx/frontend-plugin-framework>`_.
|
||||
|
||||
The parts of this MFE that can be customized in that manner are documented `here </src/plugin-slots>`_.
|
||||
|
||||
Environment Variables
|
||||
=====================
|
||||
|
||||
This MFE is configured via environment variables supplied at build time.
|
||||
All micro-frontends have a shared set of required environment variables,
|
||||
as documented in the Open edX Developer Guide under
|
||||
`Required Environment Variables <https://openedx.github.io/frontend-platform/>`_.
|
||||
|
||||
The learning micro-frontend also supports the following additional variables:
|
||||
|
||||
CREDIT_HELP_LINK_URL
|
||||
A link to resources to help explain what course credit is and how to earn it.
|
||||
|
||||
ENABLE_JUMPNAV
|
||||
Enables the new Jump Navigation feature in the course breadcrumbs, defaulted to the string 'true'.
|
||||
Disable to have simple hyperlinks for breadcrumbs. Setting it to any other value but 'true' ('false','I love flags', 'etc' would disable the Jumpnav).
|
||||
This feature flag is slated to be removed as jumpnav becomes default. Follow the progress of this ticket here:
|
||||
https://openedx.atlassian.net/browse/TNL-8678
|
||||
|
||||
SOCIAL_UTM_MILESTONE_CAMPAIGN
|
||||
This value is passed as the ``utm_campaign`` parameter for social-share
|
||||
links when celebrating learning milestones in the course. Optional.
|
||||
|
||||
Example: ``milestone``
|
||||
|
||||
SUPPORT_URL_CALCULATOR_MATH
|
||||
A link that explains how to use the in-course calculator. You can use the
|
||||
one in the example below if you don't want to have your own branded version.
|
||||
|
||||
Example: https://support.edx.org/hc/en-us/articles/360000038428-Entering-math-expressions-in-assignments-or-the-calculator
|
||||
|
||||
SUPPORT_URL_ID_VERIFICATION
|
||||
A link that explains how to verify your ID. Shown in contexts where you need
|
||||
to verify yourself to earn a certificate. The example link below is probably too
|
||||
edx.org-specific to use for your own site.
|
||||
|
||||
Example: https://support.edx.org/hc/en-us/articles/206503858-How-do-I-verify-my-identity
|
||||
|
||||
SUPPORT_URL_VERIFIED_CERTIFICATE
|
||||
A link that explains what a verified certificate is. You can use the
|
||||
one in the example below if you don't want to have your own branded version.
|
||||
Optional.
|
||||
|
||||
Example: https://support.edx.org/hc/en-us/articles/206502008-What-is-a-verified-certificate
|
||||
|
||||
TWITTER_HASHTAG
|
||||
This value is used in the Twitter social-share link when celebrating learning
|
||||
milestones in the course. Will prefill the suggested post with this hashtag.
|
||||
Optional.
|
||||
|
||||
Example: ``brandedhashtag``
|
||||
|
||||
TWITTER_URL
|
||||
A link to your Twitter account. The Twitter social-share link won't appear
|
||||
unless this is set. Optional.
|
||||
|
||||
Example: https://twitter.com/openedx
|
||||
|
||||
Getting Help
|
||||
============
|
||||
|
||||
If you're having trouble, we have `discussion forums`_
|
||||
where you can connect with others in the community.
|
||||
|
||||
Our real-time conversations are on Slack. You can request a `Slack
|
||||
invitation`_, then join our `community Slack workspace`_. Because this is a
|
||||
frontend repository, the best place to discuss it would be in the `#wg-frontend
|
||||
channel`_.
|
||||
|
||||
For anything non-trivial, the best path is to open an issue in this repository
|
||||
with as many details about the issue you are facing as you can provide.
|
||||
|
||||
https://github.com/openedx/frontend-app-learning/issues
|
||||
|
||||
For more information about these options, see the `Getting Help`_ page.
|
||||
|
||||
.. _Slack invitation: https://openedx.org/slack
|
||||
.. _community Slack workspace: https://openedx.slack.com/
|
||||
.. _#wg-frontend channel: https://openedx.slack.com/archives/C04BM6YC7A6
|
||||
.. _Getting Help: https://openedx.org/community/connect
|
||||
.. _discussion forums: https://discuss.openedx.org
|
||||
|
||||
Contributing
|
||||
============
|
||||
|
||||
Contributions are very welcome. Please read `How To Contribute`_ for details.
|
||||
|
||||
.. _How To Contribute: https://openedx.org/r/how-to-contribute
|
||||
|
||||
This project is currently accepting all types of contributions, bug fixes,
|
||||
security fixes, maintenance work, or new features. However, please make sure
|
||||
to discuss your new feature idea with the maintainers before
|
||||
beginning development to maximize the chances of your change being accepted.
|
||||
You can start a conversation by creating a new issue on this repo summarizing
|
||||
your idea.
|
||||
|
||||
The Open edX Code of Conduct
|
||||
============================
|
||||
|
||||
All community members are expected to follow the `Open edX Code of Conduct`_.
|
||||
|
||||
.. _Open edX Code of Conduct: https://openedx.org/code-of-conduct/
|
||||
|
||||
License
|
||||
=======
|
||||
|
||||
The code in this repository is licensed under the AGPLv3 unless otherwise
|
||||
noted.
|
||||
|
||||
Please see `LICENSE <LICENSE>`_ for details.
|
||||
|
||||
Reporting Security Issues
|
||||
=========================
|
||||
|
||||
Please do not report security issues in public. Please email security@openedx.org.
|
||||
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-learning.svg?branch=master
|
||||
:target: https://travis-ci.org/edx/frontend-app-learning
|
||||
.. |Coveralls| image:: https://img.shields.io/coveralls/edx/frontend-app-learning.svg?branch=master
|
||||
:target: https://coveralls.io/github/edx/frontend-app-learning
|
||||
.. |npm_version| image:: https://img.shields.io/npm/v/@edx/frontend-app-learning.svg
|
||||
:target: @edx/frontend-app-learning
|
||||
.. |npm_downloads| image:: https://img.shields.io/npm/dt/@edx/frontend-app-learning.svg
|
||||
:target: @edx/frontend-app-learning
|
||||
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-learning.svg
|
||||
:target: @edx/frontend-app-learning
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
# This file records information about this repo. Its use is described in OEP-55:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html
|
||||
|
||||
apiVersion: backstage.io/v1alpha1
|
||||
kind: Component
|
||||
metadata:
|
||||
name: 'frontend-app-learning'
|
||||
description: "This is the Learning MFE, which renders all learner-facing course pages."
|
||||
links:
|
||||
- url: "https://github.com/openedx/frontend-app-learning"
|
||||
title: "Learning MFE"
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/arch-interest-groups: ""
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: group:committers-frontend-app-learning
|
||||
type: 'website'
|
||||
lifecycle: 'production'
|
||||
@@ -1,7 +1,5 @@
|
||||
# Courseware Page Decisions
|
||||
|
||||
**See [0009-courseware-api-direction.md](0009-courseware-api-direction.md) for updates!**
|
||||
|
||||
## Courseware data loading
|
||||
|
||||
Today we have strictly hierarchical courses - a course contains sections, which contain sequences, which contain units, which contain components.
|
||||
@@ -39,9 +37,6 @@ Today, if the URL only specifies the course ID, we need to pick a sequence to sh
|
||||
|
||||
Similarly, if the URL doesn't contain a unit ID, we use the `position` field of the sequence to determine which unit we want to display from that sequence. If the position isn't specified in the sequence, we choose the first unit of the sequence. After determining which unit to display, we update the URL to match. After the URL is updated, the application will attempt to load that unit via an iFrame.
|
||||
|
||||
_This URL scheme has been expanded upon in
|
||||
[ADR #8: Liberal courseware path handling](./0008-liberal-courseware-path-handling.md)._
|
||||
|
||||
## "Container" components vs. display components
|
||||
|
||||
This application makes use of a few "container" components at the top level - CoursewareContainer and CourseHomeContainer.
|
||||
|
||||
@@ -4,6 +4,4 @@ Because we have a variety of models in this app (course, section, sequence, unit
|
||||
|
||||
https://redux.js.org/faq/organizing-state#how-do-i-organize-nested-or-duplicate-data-in-my-state
|
||||
|
||||
Different modules of the application maintain individual/lists of IDs that reference data stored in the model store. These are akin to indices in a database, in that they allow you to quickly extract data from the model store without iteration or filtering.
|
||||
|
||||
A common pattern when loading data from an API endpoint is to use the model-store's redux actions (addModel, updateModel, etc.) to load the "models" themselves into the model store by ID, and then dispatch another action to save references elsewhere in the redux store to the data that was just added. When adding courses, sequences, etc., to model-store, we also save the courseId and sequenceId in the 'courseware' part of redux. This means the courseware React Components can extract the data from the model-store quickly by using the courseId as a key: `state.models.courses[state.courseware.courseId]`. For an array, it iterates once over the ID list in order to extract the models from model-store. This iteration is done when React components' re-render, and can be done less often through memoization as necessary.
|
||||
(As an additional data point, djoy has stored data in this format in multiple projects over the years and found it to be very effective)
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Naming API functions and redux thunks
|
||||
|
||||
Because API functions and redux thunks are two parts of a larger process, we've informally settled on some naming conventions for them to help differentiate the type of code we're looking at.
|
||||
|
||||
## API Functions
|
||||
|
||||
This micro-frontend follows a pattern of naming API functions with a prefix for their HTTP verb.
|
||||
|
||||
Examples:
|
||||
|
||||
`getCourseBlocks` - The GET request we make to load course blocks data.
|
||||
`postSequencePosition` - The POST request for saving sequence position.
|
||||
|
||||
## Redux Thunks
|
||||
|
||||
Meanwhile, we use a different set of verbs for redux thunks to differentiate them from the API functions. For instance, we use the `fetch` prefix for loading data (primarily via GET requests), and `save` for sending data back to the server (primarily via POST or PATCH requests)
|
||||
|
||||
Examples:
|
||||
|
||||
`fetchCourse` - The thunk for getting course data across several APIs.
|
||||
`fetchSequence` - The thunk for the process of retrieving sequence data.
|
||||
`saveSequencePosition` - Wraps the POST request for sending sequence position back to the server.
|
||||
|
||||
The verb prefixes for thunks aren't perfect - but they're a little more 'friendly' and semantically meaningful than the HTTP verbs used for APIs. So far we have `fetch`, `save`, `check`, `reset`, etc.
|
||||
@@ -1,66 +0,0 @@
|
||||
# Testing
|
||||
|
||||
## Status
|
||||
Draft
|
||||
|
||||
Let's live with this a bit longer before deciding it's a solid approach and marking this Approved.
|
||||
|
||||
## Context
|
||||
We'd like to all be on the same page about how to approach testing, what is
|
||||
worth testing, and how to do it.
|
||||
|
||||
## React Testing Library
|
||||
We'll use react-testing-library and jest as the main testing tools.
|
||||
|
||||
This has some implications about how to test. You can read the React Testing Library's
|
||||
[Guiding Principles](https://testing-library.com/docs/guiding-principles), but the main
|
||||
takeaway is that you should be interacting with React as closely as possible to the way
|
||||
the user will interact with it.
|
||||
|
||||
For example, they discourage using class or element name selectors to find components
|
||||
during a test. Instead, you should find them by user-oriented attributes like labels,
|
||||
text, or roles. As a last resort, by a `data-testid` tag.
|
||||
|
||||
## Mocking data
|
||||
We'll use [Rosie](https://github.com/rosiejs/rosie) as a tool for building JavaScript objects.
|
||||
Our main use case for Rosie is to use factories in order to mock the data we'd like to fetch when rendering components.
|
||||
[axios-mock-adapter](https://www.npmjs.com/package/axios-mock-adapter) allows us to mock the response of an HTTP request.
|
||||
|
||||
For example, we may use a factory to build a course metadata object:
|
||||
|
||||
`const courseMetadata = Factory.build('courseMetadata');`
|
||||
|
||||
Then we'd pass that `courseMetadata` object into an axios mock call:
|
||||
|
||||
`axiosMock.onGet('example.com').reply(200, courseMetadata);`
|
||||
|
||||
This way, when a component sends a GET request to `example.com` within the test's lifecycle, the request will be intercepted
|
||||
by the axios-mock-adapter, and the courseMetadata object will be returned.
|
||||
|
||||
These factories should live within the data directories they intend to mock
|
||||
```
|
||||
courseware
|
||||
| data
|
||||
| __factories__
|
||||
| courseMetadata.factory.js /* used to define the Rosie factory */
|
||||
| api.js /* getCourseMetadata() lives here */
|
||||
```
|
||||
|
||||
## What to Test
|
||||
We have not found exhaustive unit testing of frontend code to be worth the trouble.
|
||||
Rather, let's focus on testing non-obvious behavior.
|
||||
|
||||
In essence: `test behavior that wouldn't present itself to a developer playing around`.
|
||||
|
||||
Practically speaking, this means error states, interactive components, corner cases,
|
||||
or anything that wouldn't come up in a demo course. Something a developer wouldn't
|
||||
notice in the normal course of working in devstack.
|
||||
|
||||
## Snapshots
|
||||
In practice, we've found snapshots of component trees to be too brittle to be worth it,
|
||||
as refactors occur or external libraries change.
|
||||
|
||||
They can still be useful for data (like redux tests) or tiny isolated components.
|
||||
|
||||
But please avoid for any "interesting" component. Prefer inspecting the explicit behavior
|
||||
under test, rather than just snapshotting the entire component tree.
|
||||
@@ -1,90 +0,0 @@
|
||||
# Liberal courseware path handling
|
||||
|
||||
## Status
|
||||
|
||||
Accepted
|
||||
|
||||
_This updates some of the content in [ADR #2: Courseware page decisions](./0002-courseware-page-decisions.md)._
|
||||
|
||||
## Context
|
||||
|
||||
The courseware container currently accepts three path forms:
|
||||
|
||||
1. `/course/:courseId`
|
||||
2. `/course/:courseId/:sequenceId`
|
||||
3. `/course/:courseId/:sequenceId/:unitId`
|
||||
|
||||
Forms #1 and #2 are always redirected to Form #3 via simple set of rules:
|
||||
|
||||
* If the sequenceId is not specified, choose the first sequence in the course.
|
||||
* If the unitId is not specified, choose the active unit in the sequence,
|
||||
or the first unit if none are active.
|
||||
|
||||
Thus, Form #3 is effectively the canonoical path;
|
||||
all Learning MFE units should be served from it.
|
||||
We acknowledge that the best user experience is to link directly to the canonoical
|
||||
path when possible, since it skips the redirection steps.
|
||||
Still, there are times when it is necessary or prudent to link just to a course or
|
||||
a sequence.
|
||||
|
||||
Through recent work in the LMS, we are realizing that there are _also_ times where it
|
||||
would be simpler or more performant to link a user to an
|
||||
_entire section without specifying a squence_ or to a
|
||||
_unit without including the sequence_.
|
||||
Specifically, this capability would let as avoid further modulestore or
|
||||
block transformer queries in order to discern the course structure when trying to
|
||||
direct a learner to a section or unit.
|
||||
Futhermore, we hypothesize that being able to build a Learning MFE courseware link
|
||||
with just a unit ID or a section ID will be a nice simplifying quality for future
|
||||
development or debugging.
|
||||
|
||||
## Decision
|
||||
|
||||
The courseware container will accept five total path forms:
|
||||
|
||||
1. `/course/:courseId`
|
||||
2. `/course/:courseId/:sectionId`
|
||||
3. `/course/:courseId/:sectionId/:unitId`
|
||||
4. `/course/:courseId/:sequenceId`
|
||||
5. `/course/:courseId/:unitId`
|
||||
6. `/course/:courseId/:sequenceId/:unitId`
|
||||
|
||||
The redirection rules are as follows:
|
||||
|
||||
* Forms #1 redirects to Form #4 by selecting the first sequence in the course.
|
||||
* Form #2 redirects to Form #4 by selecting to the first sequence in the section.
|
||||
* Form #3 redirects to Form #5 by dropping the section ID.
|
||||
* Form #4 redirects to Form #6 by choosing the active unit in the sequence
|
||||
(or the first unit, if none are active).
|
||||
* Form #5 redirects to Form #6 by filling in the ID of the sequence that the
|
||||
specified unit belongs to (in the edge case where the unit belongs to multiple
|
||||
sequences, the first sequence is selected).
|
||||
|
||||
As before, Form #5 is the canonocial courseware path, which is always redirected to
|
||||
by any of the other courseware path forms.
|
||||
|
||||
## Consequences
|
||||
|
||||
The above decision is implemented.
|
||||
|
||||
## Further work
|
||||
|
||||
At some point, we may decide to further extend the URL scheme to be
|
||||
more human-readable.
|
||||
|
||||
We can't make UsageKeys themselves more readable because they're tied to student state,
|
||||
but we could introduce a new optional `slug` field on Sequences,
|
||||
which would be captured and propagated to the learning_sequences API.
|
||||
We could eventually do something similar to Units, since those slugs only have to be sequence-local.
|
||||
|
||||
So eventually, URLs could look less like:
|
||||
|
||||
```
|
||||
|
||||
https://learning.edx.org/course/course-v1:edX+DemoX.1+2T2019/block-v1:edX+DemoX.1+2T2019+type@sequential+block@e0a61b3d5a2046949e76d12cac5df493/block-v1:edX+DemoX.1+2T2019+type@vertical+block@52dbad5a28e140f291a476f92ce11996
|
||||
```
|
||||
|
||||
And more like:
|
||||
```
|
||||
https://learning.edx.org/course/course-v1:edX+DemoX.1+2T2019/Being_Social/Teams
|
||||
```
|
||||
@@ -1,62 +0,0 @@
|
||||
# Direction of Courseware APIs
|
||||
|
||||
In order to allow for greater flexibility and separation of concerns, we're going to stop using the Course Blocks API for navigational data, and pull that data from the Learning Sequences Outlines API instead. The intention is to give us four distinct layers of courseware that can eventually be composed in different ways:
|
||||
|
||||
* Learning Context Metadata
|
||||
* Learning Context Navigation
|
||||
* Sequence Navigation
|
||||
* Unit Rendering
|
||||
|
||||
Note that "Learning Context" is a generalization of "Course" that includes other things like Content Libraries, Learning Pathways, and potentially other logical groupings of content.
|
||||
|
||||
This is a refinement of [0002-courseware-page-decisions.md](0002-courseware-page-decisions.md). The fundamental layers remain the same, but this document tries to better clarify the boundaries and path forward for these layers. We're not making these layers completely swappable/pluggable now, but we should separate the data access in a way that allows for that in the future.
|
||||
|
||||
## Background
|
||||
|
||||
We currently make four primary requests to the LMS when rendering courseware instructional content:
|
||||
|
||||
1. Course Metadata: `/api/courseware/course/{courseId}` (REST API)
|
||||
2. Course Blocks API: `/api/courses/v2/blocks/?course_id={courseId}` (REST API)
|
||||
3. Sequence Metadata: `/api/courseware/sequence/{sequenceUsageKey}` (REST API)
|
||||
4. Unit: `/xblock/{unitBlockUsageKey}` (rendered in an iframe)
|
||||
|
||||
There is a significant amount of overlap between the Course Blocks API and the others at the moment, since Course Blocks takes a static snapshot of the entire tree of course content at once. There are a few problems with the current arrangement:
|
||||
|
||||
* It's slow and complex. The Course Blocks API can be difficult to maintain and reason about, and trickier to optimize.
|
||||
* Assuming that all course structures are the same makes it difficult to support other content types, like LabXchange Learning Pathways or adaptive content.
|
||||
* The overlap between Course Blocks and the other APIs means that there can be conflicts about the state.
|
||||
|
||||
## Motivating Vision
|
||||
|
||||
We have seen a desire to extend or enhance the courseware experience in various ways:
|
||||
|
||||
Learning Context Navigation
|
||||
* Allowing for shorter, human-readable URLs in courseware.
|
||||
* Smaller courses that do not need the current navigational hierarchy.
|
||||
* LabXchange pathways.
|
||||
|
||||
Sequence Navigation
|
||||
* Adaptive content, where the full list of units is not known up front.
|
||||
* More limited navigation, where content is pushed linearly, without the ability to jump ahead.
|
||||
* Different layouts for content browsing.
|
||||
|
||||
Unit Rendering
|
||||
* Use of QTI content (currently serviced by cc2olx conversion).
|
||||
* Desire to experiment with a next-gen version of XBlock.
|
||||
* Use of entirely LTI units.
|
||||
|
||||
The idea would be to insulate each layer from the layers above and below it. Sequence rendering shouldn't be affected by whether or not it's in a two level hierarchy (Course → Section → Sequence), or a flat one (Course → Sequence). Learning Context Navigation should be able to reference Sequences without caring if a Sequence is an adaptive one or not. Sequences should be able to have a common interface to call Unit iframes, whether those Units are rendering XBlocks or QTI content.
|
||||
|
||||
Note that supporting these types of course structures would require downstream changes in other systems as well (e.g. analytics).
|
||||
|
||||
## Next Step: Removing use of the Course Blocks API.
|
||||
|
||||
The next step in this process is to remove the call to the Course Blocks API, and split its responsibilities across just the existing Learning Sequences Outline and Sequence Metadata APIs. This will involve at least a couple of steps.
|
||||
|
||||
### Complete rollout of Learning Sequences Outline calls.
|
||||
|
||||
We're currently in a transitional state between these APIs where the Learning Sequences Outline calls are only rolled out on a small handful of courses.
|
||||
|
||||
### Shift Sequence and Unit metadata to only come from Sequence Metadata API.
|
||||
|
||||
We currently pull this information from both Course Blocks and the Sequence Metadata API. We can consolidate on just the Sequence Metadata API. There is also server side optimization that can be done with the Sequence Metadata API calls as part of this work.
|
||||
@@ -1,24 +0,0 @@
|
||||
import UnitTranslationPlugin from '@edx/unit-translation-selector-plugin';
|
||||
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
|
||||
|
||||
// Load environment variables from .env file
|
||||
const config = {
|
||||
...process.env,
|
||||
pluginSlots: {
|
||||
unit_title_plugin: {
|
||||
plugins: [
|
||||
{
|
||||
op: PLUGIN_OPERATIONS.Insert,
|
||||
widget: {
|
||||
id: 'unit_title_plugin',
|
||||
type: DIRECT_PLUGIN,
|
||||
priority: 1,
|
||||
RenderWidget: UnitTranslationPlugin,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -1,4 +0,0 @@
|
||||
// Force all tests to run in UTC to prevent tests from being sensitive to host timezone.
|
||||
module.exports = async () => {
|
||||
process.env.TZ = 'UTC';
|
||||
};
|
||||
@@ -1,43 +1,11 @@
|
||||
const { createConfig } = require('@openedx/frontend-build');
|
||||
const { createConfig } = require('@edx/frontend-build');
|
||||
|
||||
const config = createConfig('jest', {
|
||||
setupFilesAfterEnv: [
|
||||
module.exports = createConfig('jest', {
|
||||
setupFiles: [
|
||||
'<rootDir>/src/setupTest.js',
|
||||
],
|
||||
coveragePathIgnorePatterns: [
|
||||
'src/setupTest.js',
|
||||
'src/i18n',
|
||||
'src/.*\\.exp\\..*',
|
||||
],
|
||||
moduleNameMapper: {
|
||||
// See https://stackoverflow.com/questions/72382316/jest-encountered-an-unexpected-token-react-markdown
|
||||
'react-markdown': '<rootDir>/node_modules/react-markdown/react-markdown.min.js',
|
||||
'@src/(.*)': '<rootDir>/src/$1',
|
||||
// Explicit mapping to ensure Jest resolves the module correctly
|
||||
'@edx/frontend-lib-special-exams': '<rootDir>/node_modules/@edx/frontend-lib-special-exams',
|
||||
},
|
||||
testTimeout: 30000,
|
||||
globalSetup: "./global-setup.js",
|
||||
verbose: true,
|
||||
testEnvironment: 'jsdom',
|
||||
});
|
||||
|
||||
// delete config.testURL;
|
||||
|
||||
config.reporters = [...(config.reporters || []), ["jest-console-group-reporter", {
|
||||
// change this setting if need to see less details for each test
|
||||
// reportType: "summary" | "details",
|
||||
// enable: true | false,
|
||||
afterEachTest: {
|
||||
enable: true,
|
||||
filePaths: false,
|
||||
reportType: "details",
|
||||
},
|
||||
afterAllTests: {
|
||||
reportType: "summary",
|
||||
enable: true,
|
||||
filePaths: true,
|
||||
},
|
||||
}]];
|
||||
|
||||
module.exports = config;
|
||||
|
||||
5
openedx.yaml
Normal file
5
openedx.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
# This file describes this Open edX repo, as described in OEP-2:
|
||||
# https://open-edx-proposals.readthedocs.io/en/latest/oep-0002-bp-repo-metadata.html#specification
|
||||
|
||||
oeps: {}
|
||||
owner: edx/platform-core-tnl
|
||||
40472
package-lock.json
generated
40472
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
114
package.json
114
package.json
@@ -4,94 +4,64 @@
|
||||
"description": "Frontend learning application.",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/openedx/frontend-app-learning.git"
|
||||
"url": "git+https://github.com/edx/frontend-app-learning.git"
|
||||
},
|
||||
"browserslist": [
|
||||
"extends @edx/browserslist-config"
|
||||
"last 2 versions",
|
||||
"ie 11"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "fedx-scripts webpack",
|
||||
"bundlewatch": "bundlewatch",
|
||||
"i18n_extract": "fedx-scripts formatjs extract",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
||||
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
|
||||
"i18n_extract": "BABEL_ENV=i18n fedx-scripts babel src --quiet > /dev/null",
|
||||
"is-es5": "es-check es5 ./dist/*.js",
|
||||
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
|
||||
"snapshot": "fedx-scripts jest --updateSnapshot",
|
||||
"start": "fedx-scripts webpack-dev-server --progress",
|
||||
"start:with-theme": "paragon install-theme && npm start && npm install",
|
||||
"dev": "PUBLIC_PATH=/learning/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
|
||||
"test": "NODE_ENV=test fedx-scripts jest --coverage --passWithNoTests",
|
||||
"test:watch": "fedx-scripts jest --watch --passWithNoTests",
|
||||
"types": "tsc --noEmit"
|
||||
"test": "fedx-scripts jest --coverage --passWithNoTests"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "npm run lint"
|
||||
}
|
||||
},
|
||||
"author": "edX",
|
||||
"license": "AGPL-3.0",
|
||||
"homepage": "https://github.com/openedx/frontend-app-learning#readme",
|
||||
"homepage": "https://github.com/edx/frontend-app-learning#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/openedx/frontend-app-learning/issues"
|
||||
"url": "https://github.com/edx/frontend-app-learning/issues"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
|
||||
"@edx/browserslist-config": "1.5.1",
|
||||
"@edx/frontend-component-footer": "^14.6.0",
|
||||
"@edx/frontend-component-header": "^8.0.0",
|
||||
"@edx/frontend-lib-learning-assistant": "^2.24.0",
|
||||
"@edx/frontend-lib-special-exams": "^4.0.0",
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@fortawesome/free-brands-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-regular-svg-icons": "5.15.4",
|
||||
"@fortawesome/free-solid-svg-icons": "5.15.4",
|
||||
"@fortawesome/react-fontawesome": "^0.1.4",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^23.4.5",
|
||||
"@popperjs/core": "2.11.8",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"buffer": "^6.0.3",
|
||||
"classnames": "2.5.1",
|
||||
"copy-webpack-plugin": "^12.0.0",
|
||||
"joi": "^17.11.0",
|
||||
"js-cookie": "3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash.camelcase": "4.3.0",
|
||||
"postcss-loader": "^8.1.1",
|
||||
"prop-types": "15.8.1",
|
||||
"query-string": "^7.1.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-helmet": "6.1.0",
|
||||
"react-redux": "7.2.9",
|
||||
"react-router": "6.15.0",
|
||||
"react-router-dom": "6.15.0",
|
||||
"react-share": "4.4.1",
|
||||
"redux": "4.2.1",
|
||||
"reselect": "4.1.8",
|
||||
"sass": "^1.79.3",
|
||||
"sass-loader": "^16.0.2",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"truncate-html": "1.0.4"
|
||||
"@edx/frontend-component-footer": "^10.0.6",
|
||||
"@edx/frontend-component-header": "^2.0.3",
|
||||
"@edx/frontend-platform": "^1.3.1",
|
||||
"@edx/paragon": "^7.2.0",
|
||||
"@fortawesome/fontawesome-svg-core": "^1.2.26",
|
||||
"@fortawesome/free-brands-svg-icons": "^5.12.0",
|
||||
"@fortawesome/free-regular-svg-icons": "^5.12.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^5.12.0",
|
||||
"@fortawesome/react-fontawesome": "^0.1.8",
|
||||
"@reduxjs/toolkit": "^1.2.3",
|
||||
"classnames": "^2.2.6",
|
||||
"core-js": "^3.6.2",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-redux": "^7.1.3",
|
||||
"react-router": "^5.1.2",
|
||||
"react-router-dom": "^5.1.2",
|
||||
"redux": "^4.0.5",
|
||||
"regenerator-runtime": "^0.13.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openedx/frontend-build": "^14.6.2",
|
||||
"@pact-foundation/pact": "^13.0.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"axios-mock-adapter": "2.1.0",
|
||||
"bundlewatch": "^0.4.0",
|
||||
"eslint-import-resolver-webpack": "^0.13.9",
|
||||
"jest-console-group-reporter": "^1.1.1",
|
||||
"jest-when": "^3.6.0",
|
||||
"rosie": "2.1.1"
|
||||
},
|
||||
"bundlewatch": {
|
||||
"files": [
|
||||
{
|
||||
"path": "dist/*.js",
|
||||
"maxSize": "1450kB"
|
||||
}
|
||||
],
|
||||
"normalizeFilenames": "^.+?(\\..+?)\\.\\w+$"
|
||||
"@edx/frontend-build": "^3.0.0",
|
||||
"codecov": "^3.6.1",
|
||||
"es-check": "^5.1.0",
|
||||
"glob": "^7.1.6",
|
||||
"husky": "^3.1.0",
|
||||
"jest": "^24.9.0",
|
||||
"reactifex": "^1.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="en-us">
|
||||
<head>
|
||||
<title>Course | <%= process.env.SITE_NAME %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="<%=htmlWebpackPlugin.options.FAVICON_URL%>" type="image/x-icon" />
|
||||
|
||||
<% if (htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID) { %>
|
||||
<script src="https://www.edx.org/optimizelyjs/<%= htmlWebpackPlugin.options.OPTIMIZELY_PROJECT_ID %>.js"></script>
|
||||
<% } %>
|
||||
<% if (htmlWebpackPlugin.options.META_TAG_ROBOTS_CONTENT_ATTR) { %>
|
||||
<meta name="robots" content="<%= htmlWebpackPlugin.options.META_TAG_ROBOTS_CONTENT_ATTR %>">
|
||||
<% } %>
|
||||
<title>Course | edX</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
body a {
|
||||
color: #00688D;
|
||||
}
|
||||
|
||||
body.inline-link a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body.small {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
@@ -5,12 +5,5 @@
|
||||
"patch": {
|
||||
"automerge": true
|
||||
},
|
||||
"rebaseStalePrs": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["@edx", "@openedx"],
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
}
|
||||
]
|
||||
"rebaseStalePrs": true
|
||||
}
|
||||
|
||||
30
src/CourseContainer.jsx
Normal file
30
src/CourseContainer.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Route, Switch, useParams, useRouteMatch,
|
||||
} from 'react-router-dom';
|
||||
import SequenceContainer from './SequenceContainer';
|
||||
|
||||
export default (props) => {
|
||||
const { path } = useRouteMatch();
|
||||
const { courseId } = useParams();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>CourseContainer</div>
|
||||
<Switch>
|
||||
<Route exact path={path}>
|
||||
<h3>{path} find the sequence and redirect</h3>
|
||||
</Route>
|
||||
|
||||
<Route exact path={`${path}/home`}>
|
||||
<h3>Course Home</h3>
|
||||
</Route>
|
||||
{/* CoursewareContainer ???? */}
|
||||
<Route
|
||||
path={`${path}/sequence/:sequenceId`}
|
||||
component={SequenceContainer}
|
||||
/>
|
||||
</Switch>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import { Spinner } from '@openedx/paragon';
|
||||
|
||||
export default class PageLoading extends Component {
|
||||
renderSrMessage() {
|
||||
if (!this.props.srMessage) {
|
||||
@@ -25,7 +23,9 @@ export default class PageLoading extends Component {
|
||||
height: '50vh',
|
||||
}}
|
||||
>
|
||||
<Spinner animation="border" variant="primary" screenReaderText={this.renderSrMessage()} />
|
||||
<div className="spinner-border text-primary" role="status">
|
||||
{this.renderSrMessage()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -33,5 +33,5 @@ export default class PageLoading extends Component {
|
||||
}
|
||||
|
||||
PageLoading.propTypes = {
|
||||
srMessage: PropTypes.node.isRequired,
|
||||
srMessage: PropTypes.string.isRequired,
|
||||
};
|
||||
34
src/SequenceContainer.jsx
Normal file
34
src/SequenceContainer.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Route, Switch, useParams, useRouteMatch,
|
||||
} from 'react-router-dom';
|
||||
|
||||
export default (props) => {
|
||||
const { path } = useRouteMatch();
|
||||
const { courseId, sequenceId } = useParams();
|
||||
// const { courseId } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>SequenceContainer</div>
|
||||
|
||||
{courseId} <br />
|
||||
{sequenceId}
|
||||
<Switch>
|
||||
<Route exact path={path}>
|
||||
<h3>{path} find the unit and redirect</h3>
|
||||
</Route>
|
||||
<Route exact path={`${path}/home`}>
|
||||
<h3>Course Home</h3>
|
||||
</Route>
|
||||
<Route
|
||||
path={`${path}/sequence/:sequenceId`}
|
||||
render={(routeProps) => (
|
||||
<SequenceContainer {...routeProps} courseId={courseId} />
|
||||
)}
|
||||
/>
|
||||
</Switch>
|
||||
{props.children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,135 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
import { FormattedMessage, FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Alert, Hyperlink } from '@openedx/paragon';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const AccessExpirationAlert = ({ payload }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
accessExpiration,
|
||||
courseId,
|
||||
org,
|
||||
userTimezone,
|
||||
analyticsPageName,
|
||||
} = payload;
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
if (!accessExpiration) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
expirationDate,
|
||||
upgradeDeadline,
|
||||
upgradeUrl,
|
||||
} = accessExpiration;
|
||||
|
||||
const logClick = () => {
|
||||
sendTrackEvent('edx.bi.ecommerce.upsell_links_clicked', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
linkCategory: 'FBE_banner',
|
||||
linkName: `${analyticsPageName}_audit_access_expires`,
|
||||
linkType: 'link',
|
||||
pageName: analyticsPageName,
|
||||
});
|
||||
};
|
||||
|
||||
let deadlineMessage = null;
|
||||
if (upgradeDeadline && upgradeUrl) {
|
||||
deadlineMessage = (
|
||||
<>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.deadline"
|
||||
defaultMessage="Upgrade by {date} to get unlimited access to the course as long as it exists on the site."
|
||||
description="Warning shown to learner to upgrade while they are enrolled on the audit version and it's possible to upgrade"
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationUpgradeDeadline"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={upgradeDeadline}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Hyperlink
|
||||
className="font-weight-bold"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={upgradeUrl}
|
||||
onClick={logClick}
|
||||
>
|
||||
{intl.formatMessage(messages.upgradeNow)}
|
||||
</Hyperlink>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="info" icon={Info}>
|
||||
<span className="font-weight-bold">
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.header"
|
||||
defaultMessage="Audit Access Expires {date}"
|
||||
description="Headline for auditing deadline"
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationHeaderDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.accessExpiration.body"
|
||||
defaultMessage="You lose all access to this course, including your progress, on {date}."
|
||||
description="Message body to tell learner the consequences of course expiration."
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
key="accessExpirationBodyDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
{deadlineMessage}
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
AccessExpirationAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
accessExpiration: PropTypes.shape({
|
||||
expirationDate: PropTypes.string.isRequired,
|
||||
masqueradingExpiredCourse: PropTypes.bool.isRequired,
|
||||
upgradeDeadline: PropTypes.string,
|
||||
upgradeUrl: PropTypes.string,
|
||||
}).isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
org: PropTypes.string.isRequired,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
analyticsPageName: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default AccessExpirationAlert;
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
import { PageBanner } from '@openedx/paragon';
|
||||
|
||||
const AccessExpirationMasqueradeBanner = ({ payload }) => {
|
||||
const {
|
||||
expirationDate,
|
||||
userTimezone,
|
||||
} = payload;
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
return (
|
||||
<PageBanner variant="warning">
|
||||
<FormattedMessage
|
||||
id="instructorToolbar.pageBanner.courseHasExpired"
|
||||
defaultMessage="This learner no longer has access to this course. Their access expired on {date}."
|
||||
description="It's a warning that is shown to course author when being masqueraded as learner, while the course has expired for the real learner."
|
||||
values={{
|
||||
date: <FormattedDate
|
||||
key="instructorToolbar.pageBanner.accessExpirationDate"
|
||||
value={expirationDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>,
|
||||
}}
|
||||
/>
|
||||
</PageBanner>
|
||||
);
|
||||
};
|
||||
|
||||
AccessExpirationMasqueradeBanner.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
expirationDate: PropTypes.string.isRequired,
|
||||
userTimezone: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default AccessExpirationMasqueradeBanner;
|
||||
@@ -1,51 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const AccessExpirationAlert = React.lazy(() => import('./AccessExpirationAlert'));
|
||||
const AccessExpirationMasqueradeBanner = React.lazy(() => import('./AccessExpirationMasqueradeBanner'));
|
||||
|
||||
function useAccessExpirationAlert(accessExpiration, courseId, org, userTimezone, topic, analyticsPageName) {
|
||||
const isVisible = accessExpiration && !accessExpiration.masqueradingExpiredCourse; // If it exists, show it.
|
||||
const payload = useMemo(() => ({
|
||||
accessExpiration,
|
||||
courseId,
|
||||
org,
|
||||
userTimezone,
|
||||
analyticsPageName,
|
||||
}), [accessExpiration, analyticsPageName, courseId, org, userTimezone]);
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientAccessExpirationAlert',
|
||||
payload,
|
||||
topic,
|
||||
});
|
||||
|
||||
return { clientAccessExpirationAlert: AccessExpirationAlert };
|
||||
}
|
||||
|
||||
export function useAccessExpirationMasqueradeBanner(courseId, tab) {
|
||||
const {
|
||||
userTimezone,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
accessExpiration,
|
||||
} = useModel(tab, courseId);
|
||||
|
||||
const isVisible = accessExpiration && accessExpiration.masqueradingExpiredCourse;
|
||||
const expirationDate = accessExpiration && accessExpiration.expirationDate;
|
||||
const payload = useMemo(() => ({
|
||||
expirationDate,
|
||||
userTimezone,
|
||||
}), [expirationDate, userTimezone]);
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientAccessExpirationMasqueradeBanner',
|
||||
payload,
|
||||
topic: 'instructor-toolbar-alerts',
|
||||
});
|
||||
|
||||
return { clientAccessExpirationMasqueradeBanner: AccessExpirationMasqueradeBanner };
|
||||
}
|
||||
|
||||
export default useAccessExpirationAlert;
|
||||
@@ -1 +0,0 @@
|
||||
export { default, useAccessExpirationMasqueradeBanner } from './hooks';
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
upgradeNow: {
|
||||
id: 'learning.accessExpiration.upgradeNow',
|
||||
defaultMessage: 'Upgrade now',
|
||||
description: 'The anchor text for the upgrading link',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,48 +0,0 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Hyperlink } from '@openedx/paragon';
|
||||
import { WarningFilled } from '@openedx/paragon/icons';
|
||||
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import genericMessages from './messages';
|
||||
|
||||
const ActiveEnterpriseAlert = ({ payload }) => {
|
||||
const intl = useIntl();
|
||||
const { text, courseId } = payload;
|
||||
const changeActiveEnterprise = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={
|
||||
`${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=${encodeURIComponent(
|
||||
`${global.location.origin}/course/${courseId}/home`,
|
||||
)}`
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.changeActiveEnterpriseLowercase)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert variant="warning" icon={WarningFilled}>
|
||||
{text}
|
||||
<FormattedMessage
|
||||
id="learning.activeEnterprise.alert"
|
||||
description="Prompts the user to log-in with the correct enterprise to access the course content."
|
||||
defaultMessage=" {changeActiveEnterprise}."
|
||||
values={{
|
||||
changeActiveEnterprise,
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
ActiveEnterpriseAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
text: PropTypes.string,
|
||||
courseId: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default ActiveEnterpriseAlert;
|
||||
@@ -1,25 +0,0 @@
|
||||
import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import {
|
||||
initializeTestStore, render, screen,
|
||||
} from '../../setupTest';
|
||||
import ActiveEnterpriseAlert from './ActiveEnterpriseAlert';
|
||||
|
||||
describe('ActiveEnterpriseAlert', () => {
|
||||
const mockData = {
|
||||
payload: {
|
||||
text: 'test message',
|
||||
courseId: 'test-course-id',
|
||||
},
|
||||
};
|
||||
beforeAll(async () => {
|
||||
await initializeTestStore({ excludeFetchCourse: true, excludeFetchSequence: true });
|
||||
});
|
||||
|
||||
it('Shows alert message and links', () => {
|
||||
render(<ActiveEnterpriseAlert {...mockData} />);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText('test message', { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getByRole('link', { name: 'change enterprise now' })).toHaveAttribute('href', `${getConfig().LMS_BASE_URL}/enterprise/select/active/?success_url=http%3A%2F%2Flocalhost%2Fcourse%2Ftest-course-id%2Fhome`);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const ActiveEnterpriseAlert = React.lazy(() => import('./ActiveEnterpriseAlert'));
|
||||
|
||||
export default function useActiveEnterpriseAlert(courseId) {
|
||||
const { courseAccess } = useModel('courseHomeMeta', courseId);
|
||||
/**
|
||||
* This alert should render if
|
||||
* 1. course access code is incorrect_active_enterprise
|
||||
*/
|
||||
const isVisible = courseAccess && !courseAccess.hasAccess && courseAccess.errorCode === 'incorrect_active_enterprise';
|
||||
|
||||
const payload = useMemo(() => ({
|
||||
text: courseAccess && courseAccess.userMessage,
|
||||
courseId,
|
||||
}), [courseAccess, courseId]);
|
||||
useAlert(isVisible, {
|
||||
code: 'clientActiveEnterpriseAlert',
|
||||
topic: 'outline',
|
||||
dismissible: false,
|
||||
type: ALERT_TYPES.ERROR,
|
||||
payload,
|
||||
});
|
||||
|
||||
return { clientActiveEnterpriseAlert: ActiveEnterpriseAlert };
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import useActiveEnterpriseAlert from './hooks';
|
||||
|
||||
export default useActiveEnterpriseAlert;
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
changeActiveEnterpriseLowercase: {
|
||||
id: 'learning.activeEnterprise.change.alert',
|
||||
defaultMessage: 'change enterprise now',
|
||||
description: 'Text in a link, prompting the user to change active enterprise. Used in learning.activeEnterprise.change.alert"',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,105 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
FormattedDate,
|
||||
FormattedMessage,
|
||||
FormattedRelativeTime,
|
||||
FormattedTime,
|
||||
} from '@edx/frontend-platform/i18n';
|
||||
import { Alert } from '@openedx/paragon';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const DAY_SEC = 24 * 60 * 60; // in seconds
|
||||
const DAY_MS = DAY_SEC * 1000; // in ms
|
||||
const YEAR_SEC = 365 * DAY_SEC; // in seconds
|
||||
|
||||
const CourseStartAlert = ({ payload }) => {
|
||||
const {
|
||||
courseId,
|
||||
} = payload;
|
||||
|
||||
const {
|
||||
start: startDate,
|
||||
userTimezone,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
const delta = new Date(startDate) - new Date();
|
||||
const timeRemaining = (
|
||||
<FormattedRelativeTime
|
||||
key="timeRemaining"
|
||||
value={delta / 1000}
|
||||
numeric="auto"
|
||||
// 1 year interval to help auto format. It won't format without updateIntervalInSeconds.
|
||||
updateIntervalInSeconds={YEAR_SEC}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
);
|
||||
if (delta < DAY_MS) {
|
||||
return (
|
||||
<Alert variant="info" icon={Info}>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.start.short"
|
||||
defaultMessage="Course starts {timeRemaining} at {courseStartTime}."
|
||||
description="Used when the time remaining is less than a day away."
|
||||
values={{
|
||||
courseStartTime: (
|
||||
<FormattedTime
|
||||
key="courseStartTime"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
timeZoneName="short"
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
timeRemaining,
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="info" icon={Info}>
|
||||
<strong>
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.start.long"
|
||||
defaultMessage="Course starts {timeRemaining} on {courseStartDate}."
|
||||
description="Used when the time remaining is more than a day away."
|
||||
values={{
|
||||
courseStartDate: (
|
||||
<FormattedDate
|
||||
key="courseStartDate"
|
||||
day="numeric"
|
||||
month="short"
|
||||
year="numeric"
|
||||
value={startDate}
|
||||
{...timezoneFormatArgs}
|
||||
/>
|
||||
),
|
||||
timeRemaining,
|
||||
}}
|
||||
/>
|
||||
</strong>
|
||||
<br />
|
||||
<FormattedMessage
|
||||
id="learning.outline.alert.start.calendar"
|
||||
defaultMessage="Don’t forget to add a calendar reminder!"
|
||||
description="It's just a recommendation for learners to set a reminder for the course starting date and is shown when the course starting date is more than a day. "
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
CourseStartAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
courseId: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default CourseStartAlert;
|
||||
@@ -1,44 +0,0 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
|
||||
import { PageBanner } from '@openedx/paragon';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const CourseStartMasqueradeBanner = ({ payload }) => {
|
||||
const {
|
||||
courseId,
|
||||
} = payload;
|
||||
|
||||
const {
|
||||
start,
|
||||
userTimezone,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const timezoneFormatArgs = userTimezone ? { timeZone: userTimezone } : {};
|
||||
|
||||
return (
|
||||
<PageBanner variant="warning">
|
||||
<FormattedMessage
|
||||
id="instructorToolbar.pageBanner.courseHasNotStarted"
|
||||
defaultMessage="This learner does not yet have access to this course. The course starts on {date}."
|
||||
description="It's a warning that is shown to course author when being masqueraded as learner, while the course hasn't started for the real learner yet."
|
||||
values={{
|
||||
date: <FormattedDate
|
||||
key="instructorToolbar.pageBanner.courseStartDate"
|
||||
value={start}
|
||||
{...timezoneFormatArgs}
|
||||
/>,
|
||||
}}
|
||||
/>
|
||||
</PageBanner>
|
||||
);
|
||||
};
|
||||
|
||||
CourseStartMasqueradeBanner.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
courseId: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default CourseStartMasqueradeBanner;
|
||||
@@ -1,62 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const CourseStartAlert = React.lazy(() => import('./CourseStartAlert'));
|
||||
const CourseStartMasqueradeBanner = React.lazy(() => import('./CourseStartMasqueradeBanner'));
|
||||
|
||||
function IsStartDateInFuture(courseId) {
|
||||
const {
|
||||
start,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const today = new Date();
|
||||
const startDate = new Date(start);
|
||||
return startDate > today;
|
||||
}
|
||||
|
||||
function useCourseStartAlert(courseId) {
|
||||
const {
|
||||
isEnrolled,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const isVisible = isEnrolled && IsStartDateInFuture(courseId);
|
||||
|
||||
const payload = useMemo(() => ({
|
||||
courseId,
|
||||
}), [courseId]);
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCourseStartAlert',
|
||||
payload,
|
||||
topic: 'outline-course-alerts',
|
||||
});
|
||||
|
||||
return {
|
||||
clientCourseStartAlert: CourseStartAlert,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCourseStartMasqueradeBanner(courseId, tab) {
|
||||
const {
|
||||
isMasquerading,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const isVisible = isMasquerading && tab === 'progress' && IsStartDateInFuture(courseId);
|
||||
|
||||
const payload = useMemo(() => ({
|
||||
courseId,
|
||||
}), [courseId]);
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientCourseStartMasqueradeBanner',
|
||||
payload,
|
||||
topic: 'instructor-toolbar-alerts',
|
||||
});
|
||||
|
||||
return {
|
||||
clientCourseStartMasqueradeBanner: CourseStartMasqueradeBanner,
|
||||
};
|
||||
}
|
||||
|
||||
export default useCourseStartAlert;
|
||||
@@ -1 +0,0 @@
|
||||
export { default, useCourseStartMasqueradeBanner } from './hooks';
|
||||
@@ -1,70 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Alert, Button } from '@openedx/paragon';
|
||||
import { Info, WarningFilled } from '@openedx/paragon/icons';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
import messages from './messages';
|
||||
import useEnrollClickHandler from './clickHook';
|
||||
|
||||
const EnrollmentAlert = ({ payload }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
canEnroll,
|
||||
courseId,
|
||||
extraText,
|
||||
isStaff,
|
||||
} = payload;
|
||||
|
||||
const {
|
||||
org,
|
||||
} = useModel('courseHomeMeta', courseId);
|
||||
|
||||
const { enrollClickHandler, loading } = useEnrollClickHandler(
|
||||
courseId,
|
||||
org,
|
||||
intl.formatMessage(messages.success),
|
||||
);
|
||||
|
||||
let text = intl.formatMessage(messages.alert);
|
||||
let type = 'warning';
|
||||
let icon = WarningFilled;
|
||||
if (isStaff) {
|
||||
text = intl.formatMessage(messages.staffAlert);
|
||||
type = 'info';
|
||||
icon = Info;
|
||||
} else if (extraText) {
|
||||
text = `${text} ${extraText}`;
|
||||
}
|
||||
|
||||
const button = canEnroll && (
|
||||
<Button disabled={loading} variant="link" className="p-0 border-0 align-top mx-1" size="sm" style={{ textDecoration: 'underline' }} onClick={enrollClickHandler}>
|
||||
{intl.formatMessage(messages.enrollNowSentence)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert variant={type} icon={icon}>
|
||||
<div className="d-flex">
|
||||
{text}
|
||||
{button}
|
||||
{loading && <FontAwesomeIcon icon={faSpinner} spin />}
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
EnrollmentAlert.propTypes = {
|
||||
payload: PropTypes.shape({
|
||||
canEnroll: PropTypes.bool,
|
||||
courseId: PropTypes.string,
|
||||
extraText: PropTypes.string,
|
||||
isStaff: PropTypes.bool,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default EnrollmentAlert;
|
||||
@@ -1,35 +0,0 @@
|
||||
import { useContext, useState, useCallback } from 'react';
|
||||
import { sendTrackEvent } from '@edx/frontend-platform/analytics';
|
||||
|
||||
import { UserMessagesContext, ALERT_TYPES } from '../../generic/user-messages';
|
||||
|
||||
import { postCourseEnrollment } from './data/api';
|
||||
|
||||
// Separated into its own file to avoid a circular dependency inside this directory
|
||||
|
||||
function useEnrollClickHandler(courseId, orgId, successText) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { addFlash } = useContext(UserMessagesContext);
|
||||
const enrollClickHandler = useCallback(() => {
|
||||
setLoading(true);
|
||||
postCourseEnrollment(courseId).then(() => {
|
||||
addFlash({
|
||||
dismissible: true,
|
||||
flash: true,
|
||||
text: successText,
|
||||
type: ALERT_TYPES.SUCCESS,
|
||||
topic: 'course',
|
||||
});
|
||||
setLoading(false);
|
||||
sendTrackEvent('edx.bi.user.course-home.enrollment', {
|
||||
org_key: orgId,
|
||||
courserun_key: courseId,
|
||||
});
|
||||
global.location.reload();
|
||||
});
|
||||
}, [addFlash, courseId, orgId, successText]);
|
||||
|
||||
return { enrollClickHandler, loading };
|
||||
}
|
||||
|
||||
export default useEnrollClickHandler;
|
||||
@@ -1,9 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
|
||||
export async function postCourseEnrollment(courseId) {
|
||||
const url = `${getConfig().LMS_BASE_URL}/api/enrollment/v1/enrollment`;
|
||||
const { data } = await getAuthenticatedHttpClient().post(url, { course_details: { course_id: courseId } });
|
||||
return data;
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React, {
|
||||
useContext, useMemo,
|
||||
} from 'react';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import { useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const EnrollmentAlert = React.lazy(() => import('./EnrollmentAlert'));
|
||||
|
||||
export function useEnrollmentAlert(courseId) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const course = useModel('courseHomeMeta', courseId);
|
||||
const outline = useModel('outline', courseId);
|
||||
const enrolledUser = course && course.isEnrolled !== undefined && course.isEnrolled;
|
||||
const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses;
|
||||
/**
|
||||
* This alert should render if
|
||||
* 1. the user is not enrolled,
|
||||
* 2. the user is authenticated, AND
|
||||
* 3. the course is private.
|
||||
*/
|
||||
const isVisible = !enrolledUser && authenticatedUser !== null && privateOutline;
|
||||
const payload = useMemo(() => ({
|
||||
canEnroll: outline && outline.enrollAlert ? outline.enrollAlert.canEnroll : false,
|
||||
courseId,
|
||||
extraText: outline && outline.enrollAlert ? outline.enrollAlert.extraText : '',
|
||||
isStaff: course && course.isStaff,
|
||||
}), [course, courseId, outline]);
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientEnrollmentAlert',
|
||||
payload,
|
||||
topic: 'outline',
|
||||
});
|
||||
|
||||
return { clientEnrollmentAlert: EnrollmentAlert };
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { useEnrollmentAlert as default } from './hooks';
|
||||
@@ -1,32 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
alert: {
|
||||
id: 'learning.enrollment.alert',
|
||||
defaultMessage: 'You must be enrolled in the course to see course content.',
|
||||
description: 'Message shown to indicate that a user needs to enroll in a course prior to viewing the course content. Shown as part of an alert, along with a link to enroll.',
|
||||
},
|
||||
staffAlert: {
|
||||
id: 'learning.staff.enrollment.alert',
|
||||
defaultMessage: 'You are viewing this course as staff, and are not enrolled.',
|
||||
description: 'Message shown to indicate that a user is not enrolled, but is able to view a course anyway because they are staff. Shown as part of an alert, along with a link to enroll.',
|
||||
},
|
||||
enrollNowInline: {
|
||||
id: 'learning.enrollment.enrollNow.Inline',
|
||||
defaultMessage: 'Enroll now',
|
||||
description: 'A link prompting the user to click on it to enroll in the currently viewed course.'
|
||||
+ 'This text is meant to be used at the beginning of a sentence (example: Enroll now to view course content.)',
|
||||
},
|
||||
enrollNowSentence: {
|
||||
id: 'learning.enrollment.enrollNow.Sentence',
|
||||
defaultMessage: 'Enroll now.',
|
||||
description: 'A link prompting the user to click on it to enroll in the currently viewed course.',
|
||||
},
|
||||
success: {
|
||||
id: 'learning.enrollment.success',
|
||||
defaultMessage: "You've successfully enrolled in this course!",
|
||||
description: 'A message telling the user that their course enrollment was successful.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,127 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import Cookies from 'js-cookie';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
AlertModal,
|
||||
Button,
|
||||
Spinner,
|
||||
Icon,
|
||||
} from '@openedx/paragon';
|
||||
import { Check, ArrowForward } from '@openedx/paragon/icons';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { sendActivationEmail } from '../../courseware/data';
|
||||
import messages from './messages';
|
||||
|
||||
const AccountActivationAlert = () => {
|
||||
const intl = useIntl();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showSpinner, setShowSpinner] = useState(false);
|
||||
const [showCheck, setShowCheck] = useState(false);
|
||||
const handleOnClick = () => {
|
||||
setShowSpinner(true);
|
||||
setShowCheck(false);
|
||||
sendActivationEmail().then(() => {
|
||||
setShowSpinner(false);
|
||||
setShowCheck(true);
|
||||
});
|
||||
};
|
||||
|
||||
const showAccountActivationAlert = Cookies.get('show-account-activation-popup');
|
||||
if (showAccountActivationAlert !== undefined) {
|
||||
Cookies.remove('show-account-activation-popup', { path: '/', domain: process.env.SESSION_COOKIE_DOMAIN });
|
||||
// extra check to make sure cookie was removed before updating the state. Updating the state without removal
|
||||
// of cookie would make it infinite rendering
|
||||
if (Cookies.get('show-account-activation-popup') === undefined) {
|
||||
setShowModal(true);
|
||||
}
|
||||
}
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
variant="primary"
|
||||
className=""
|
||||
onClick={() => setShowModal(false)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id="account-activation.alert.button"
|
||||
defaultMessage="Continue to {siteName}"
|
||||
description="account activation alert continue button"
|
||||
values={{
|
||||
siteName: getConfig().SITE_NAME,
|
||||
}}
|
||||
/>
|
||||
<Icon src={ArrowForward} className="ml-1 d-inline-block align-bottom" />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const children = () => {
|
||||
let bodyContent;
|
||||
const message = (
|
||||
<FormattedMessage
|
||||
id="account-activation.alert.message"
|
||||
defaultMessage="We sent an email to {boldEmail} with a link to activate your account. Can’t find it? Check your spam folder or
|
||||
{sendEmailTag}."
|
||||
description="Message for account activation alert which is shown after the registration"
|
||||
values={{
|
||||
boldEmail: <b>{getAuthenticatedUser() && getAuthenticatedUser().email}</b>,
|
||||
sendEmailTag: (
|
||||
// eslint-disable-next-line jsx-a11y/anchor-is-valid
|
||||
<a href="#" role="button" onClick={handleOnClick}>
|
||||
<FormattedMessage
|
||||
id="account-activation.resend.link"
|
||||
defaultMessage="resend the email"
|
||||
description="Message for resend link in account activation alert which is shown after the registration"
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
bodyContent = (
|
||||
<div>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!showCheck && showSpinner) {
|
||||
bodyContent = (
|
||||
<div>
|
||||
{message}
|
||||
<Spinner
|
||||
animation="border"
|
||||
variant="secondary"
|
||||
style={{ height: '1.5rem', width: '1.5rem' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (showCheck && !showSpinner) {
|
||||
bodyContent = (
|
||||
<div>
|
||||
{message}
|
||||
<Icon
|
||||
src={Check}
|
||||
style={{ height: '1.7rem', width: '1.25rem' }}
|
||||
className="text-success-500 d-inline-block position-fixed"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return bodyContent;
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertModal
|
||||
isOpen={showModal}
|
||||
title={intl.formatMessage(messages.accountActivationAlertTitle)}
|
||||
footerNode={button}
|
||||
onClose={() => ({})}
|
||||
>
|
||||
{children()}
|
||||
</AlertModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountActivationAlert;
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
|
||||
import { Alert, Hyperlink } from '@openedx/paragon';
|
||||
import { WarningFilled } from '@openedx/paragon/icons';
|
||||
|
||||
import genericMessages from '../../generic/messages';
|
||||
|
||||
const LogistrationAlert = () => {
|
||||
const intl = useIntl();
|
||||
const signIn = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getLoginRedirectUrl(global.location.href)}`}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.signInLowercase)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
// TODO: Pull this registration URL building out into a function, like the login one above.
|
||||
// This is complicated by the fact that we don't have a REGISTER_URL env variable available.
|
||||
const register = (
|
||||
<Hyperlink
|
||||
style={{ textDecoration: 'underline' }}
|
||||
destination={`${getConfig().LMS_BASE_URL}/register?next=${encodeURIComponent(global.location.href)}`}
|
||||
>
|
||||
{intl.formatMessage(genericMessages.registerLowercase)}
|
||||
</Hyperlink>
|
||||
);
|
||||
|
||||
return (
|
||||
<Alert variant="warning" icon={WarningFilled}>
|
||||
<FormattedMessage
|
||||
id="learning.logistration.alert"
|
||||
description="Prompts the user to sign in or register to see course content."
|
||||
defaultMessage="To see course content, {signIn} or {register}."
|
||||
values={{
|
||||
signIn,
|
||||
register,
|
||||
}}
|
||||
/>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
|
||||
export default LogistrationAlert;
|
||||
@@ -1,28 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React, { useContext } from 'react';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const LogistrationAlert = React.lazy(() => import('./LogistrationAlert'));
|
||||
|
||||
export function useLogistrationAlert(courseId) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
const outline = useModel('outline', courseId);
|
||||
const privateOutline = outline && outline.courseBlocks && !outline.courseBlocks.courses;
|
||||
/**
|
||||
* This alert should render if
|
||||
* 1. the user is not authenticated, AND
|
||||
* 2. the course is private.
|
||||
*/
|
||||
const isVisible = authenticatedUser === null && privateOutline;
|
||||
|
||||
useAlert(isVisible, {
|
||||
code: 'clientLogistrationAlert',
|
||||
topic: 'outline',
|
||||
dismissible: false,
|
||||
type: ALERT_TYPES.ERROR,
|
||||
});
|
||||
|
||||
return { clientLogistrationAlert: LogistrationAlert };
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { useLogistrationAlert as default } from './hooks';
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
accountActivationAlertTitle: {
|
||||
id: 'account-activation.alert.title',
|
||||
defaultMessage: 'Activate your account so you can log back in',
|
||||
description: 'Title for account activation alert which is shown after the registration',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import { ALERT_TYPES, useAlert } from '../../generic/user-messages';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
function useSequenceBannerTextAlert(sequenceId) {
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
|
||||
// Show Alert that comes along with the sequence
|
||||
useAlert(sequenceStatus === 'loaded' && sequence.bannerText, {
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: sequence.bannerText,
|
||||
type: ALERT_TYPES.INFO,
|
||||
topic: 'sequence',
|
||||
});
|
||||
}
|
||||
|
||||
function useSequenceEntranceExamAlert(courseId, sequenceId, intl) {
|
||||
const course = useModel('coursewareMeta', courseId);
|
||||
const sequence = useModel('sequences', sequenceId);
|
||||
const sequenceStatus = useSelector(state => state.courseware.sequenceStatus);
|
||||
const {
|
||||
entranceExamCurrentScore,
|
||||
entranceExamEnabled,
|
||||
entranceExamId,
|
||||
entranceExamMinimumScorePct,
|
||||
entranceExamPassed,
|
||||
} = course.entranceExamData || {};
|
||||
const entranceExamAlertVisible = sequenceStatus === 'loaded' && entranceExamEnabled && entranceExamId === sequence.sectionId;
|
||||
let entranceExamText;
|
||||
|
||||
if (entranceExamPassed) {
|
||||
entranceExamText = intl.formatMessage(
|
||||
messages.entranceExamTextPassed,
|
||||
{ entranceExamCurrentScore: entranceExamCurrentScore * 100 },
|
||||
);
|
||||
} else {
|
||||
entranceExamText = intl.formatMessage(messages.entranceExamTextNotPassing, {
|
||||
entranceExamCurrentScore: entranceExamCurrentScore * 100,
|
||||
entranceExamMinimumScorePct: entranceExamMinimumScorePct * 100,
|
||||
});
|
||||
}
|
||||
|
||||
useAlert(entranceExamAlertVisible, {
|
||||
code: null,
|
||||
dismissible: false,
|
||||
text: entranceExamText,
|
||||
type: ALERT_TYPES.INFO,
|
||||
topic: 'sequence',
|
||||
});
|
||||
}
|
||||
|
||||
export { useSequenceBannerTextAlert, useSequenceEntranceExamAlert };
|
||||
@@ -1,14 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
entranceExamTextNotPassing: {
|
||||
id: 'learn.sequence.entranceExamTextNotPassing',
|
||||
defaultMessage: 'To access course materials, you must score {entranceExamMinimumScorePct}% or higher on this exam. Your current score is {entranceExamCurrentScore}%.',
|
||||
},
|
||||
entranceExamTextPassed: {
|
||||
id: 'learn.sequence.entranceExamTextPassed',
|
||||
defaultMessage: 'Your score is {entranceExamCurrentScore}%. You have passed the entrance exam.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
BIN
src/assets/favicon.ico
Normal file
BIN
src/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
@@ -1,74 +0,0 @@
|
||||
export const DECODE_ROUTES = {
|
||||
ACCESS_DENIED: '/course/:courseId/access-denied',
|
||||
HOME: '/course/:courseId/home',
|
||||
LIVE: '/course/:courseId/live',
|
||||
DATES: '/course/:courseId/dates',
|
||||
DISCUSSION: '/course/:courseId/discussion/:path/*',
|
||||
PROGRESS: [
|
||||
'/course/:courseId/progress/:targetUserId/',
|
||||
'/course/:courseId/progress',
|
||||
],
|
||||
COURSE_END: '/course/:courseId/course-end',
|
||||
COURSEWARE: [
|
||||
'/course/:courseId/:sequenceId/:unitId',
|
||||
'/course/:courseId/:sequenceId',
|
||||
'/course/:courseId',
|
||||
'/preview/course/:courseId/:sequenceId/:unitId',
|
||||
'/preview/course/:courseId/:sequenceId',
|
||||
],
|
||||
REDIRECT_HOME: 'home/:courseId',
|
||||
REDIRECT_SURVEY: 'survey/:courseId',
|
||||
} as const satisfies Readonly<{ [k: string]: string | readonly string[] }>;
|
||||
|
||||
export const ROUTES = {
|
||||
UNSUBSCRIBE: '/goal-unsubscribe/:token',
|
||||
PREFERENCES_UNSUBSCRIBE: '/preferences-unsubscribe/:userToken/:updatePatch?',
|
||||
REDIRECT: '/redirect/*',
|
||||
DASHBOARD: 'dashboard',
|
||||
ENTERPRISE_LEARNER_DASHBOARD: 'enterprise-learner-dashboard',
|
||||
CONSENT: 'consent',
|
||||
} as const satisfies Readonly<{ [k: string]: string }>;
|
||||
|
||||
export const REDIRECT_MODES = {
|
||||
DASHBOARD_REDIRECT: 'dashboard-redirect',
|
||||
ENTERPRISE_LEARNER_DASHBOARD_REDIRECT: 'enterprise-learner-dashboard-redirect',
|
||||
CONSENT_REDIRECT: 'consent-redirect',
|
||||
HOME_REDIRECT: 'home-redirect',
|
||||
SURVEY_REDIRECT: 'survey-redirect',
|
||||
} as const satisfies Readonly<{ [k: string]: string }>;
|
||||
|
||||
export const VERIFIED_MODES = [
|
||||
'professional',
|
||||
'verified',
|
||||
'no-id-professional',
|
||||
'credit',
|
||||
'masters',
|
||||
'executive-education',
|
||||
'paid-executive-education',
|
||||
'paid-bootcamp',
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
export const AUDIT_MODES = [
|
||||
'audit',
|
||||
'honor',
|
||||
'unpaid-executive-education',
|
||||
'unpaid-bootcamp',
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
// In sync with CourseMode.UPSELL_TO_VERIFIED_MODES
|
||||
// https://github.com/openedx/edx-platform/blob/master/common/djangoapps/course_modes/models.py#L231
|
||||
export const ALLOW_UPSELL_MODES = [
|
||||
'audit',
|
||||
'honor',
|
||||
] as const satisfies readonly string[];
|
||||
|
||||
export const WIDGETS = {
|
||||
DISCUSSIONS: 'DISCUSSIONS',
|
||||
NOTIFICATIONS: 'NOTIFICATIONS',
|
||||
} as const satisfies Readonly<{ [k: string]: string }>;
|
||||
|
||||
export const LOADING = 'loading';
|
||||
export const LOADED = 'loaded';
|
||||
export const FAILED = 'failed';
|
||||
export const DENIED = 'denied';
|
||||
export type StatusValue = typeof LOADING | typeof LOADED | typeof FAILED | typeof DENIED;
|
||||
50
src/course-header/CourseTabsNavigation.jsx
Normal file
50
src/course-header/CourseTabsNavigation.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import messages from './messages';
|
||||
import Tabs from '../tabs/Tabs';
|
||||
|
||||
function CourseTabsNavigation({
|
||||
activeTabSlug, tabs, intl,
|
||||
}) {
|
||||
return (
|
||||
<div className="course-tabs-navigation">
|
||||
<div className="container-fluid">
|
||||
<Tabs
|
||||
className="nav-underline-tabs"
|
||||
aria-label={intl.formatMessage(messages['learn.navigation.course.tabs.label'])}
|
||||
>
|
||||
{tabs.map(({ url, title, slug }) => (
|
||||
<a
|
||||
key={slug}
|
||||
className={classNames('nav-item flex-shrink-0 nav-link', { active: slug === activeTabSlug })}
|
||||
href={`${getConfig().LMS_BASE_URL}${url}`}
|
||||
>
|
||||
{title}
|
||||
</a>
|
||||
))}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CourseTabsNavigation.propTypes = {
|
||||
activeTabSlug: PropTypes.string,
|
||||
tabs: PropTypes.arrayOf(PropTypes.shape({
|
||||
title: PropTypes.string.isRequired,
|
||||
priority: PropTypes.number.isRequired,
|
||||
slug: PropTypes.string.isRequired,
|
||||
url: PropTypes.string.isRequired,
|
||||
})).isRequired,
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
CourseTabsNavigation.defaultProps = {
|
||||
activeTabSlug: undefined,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseTabsNavigation);
|
||||
74
src/course-header/Header.jsx
Normal file
74
src/course-header/Header.jsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useContext } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Dropdown } from '@edx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { AppContext } from '@edx/frontend-platform/react';
|
||||
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUserCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
import logo from './assets/logo.svg';
|
||||
|
||||
function LinkedLogo({
|
||||
href,
|
||||
src,
|
||||
alt,
|
||||
...attributes
|
||||
}) {
|
||||
return (
|
||||
<a href={href} {...attributes}>
|
||||
<img className="d-block" src={src} alt={alt} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
LinkedLogo.propTypes = {
|
||||
href: PropTypes.string.isRequired,
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
export default function Header({
|
||||
courseOrg, courseNumber, courseTitle,
|
||||
}) {
|
||||
const { authenticatedUser } = useContext(AppContext);
|
||||
|
||||
return (
|
||||
<header className="course-header">
|
||||
<div className="container-fluid py-2 d-flex align-items-center ">
|
||||
<LinkedLogo
|
||||
className="logo"
|
||||
href={`${getConfig().LMS_BASE_URL}/dashboard`}
|
||||
src={logo}
|
||||
alt={getConfig().SITE_NAME}
|
||||
/>
|
||||
<div className="flex-grow-1 course-title-lockup" style={{ lineHeight: 1 }}>
|
||||
<span className="d-block small m-0">{courseOrg} {courseNumber}</span>
|
||||
<span className="d-block m-0 font-weight-bold course-title">{courseTitle}</span>
|
||||
</div>
|
||||
|
||||
<Dropdown className="user-dropdown">
|
||||
<Dropdown.Button>
|
||||
<FontAwesomeIcon icon={faUserCircle} className="d-md-none" size="lg" />
|
||||
<span className="d-none d-md-inline">
|
||||
{authenticatedUser.username}
|
||||
</span>
|
||||
</Dropdown.Button>
|
||||
<Dropdown.Menu className="dropdown-menu-right">
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/dashboard`}>Dashboard</Dropdown.Item>
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/u/${authenticatedUser.username}`}>Profile</Dropdown.Item>
|
||||
<Dropdown.Item href={`${getConfig().LMS_BASE_URL}/account/settings`}>Account</Dropdown.Item>
|
||||
<Dropdown.Item href={getConfig().ORDER_HISTORY_URL}>Order History</Dropdown.Item>
|
||||
<Dropdown.Item href={getConfig().LOGOUT_URL}>Sign Out</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
Header.propTypes = {
|
||||
courseOrg: PropTypes.string.isRequired,
|
||||
courseNumber: PropTypes.string.isRequired,
|
||||
courseTitle: PropTypes.string.isRequired,
|
||||
};
|
||||
15
src/course-header/assets/logo.svg
Normal file
15
src/course-header/assets/logo.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1168px" height="540px" viewBox="0 0 1168 540" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 53.2 (72643) - https://sketchapp.com -->
|
||||
<title>logo</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<g id="logo" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<polygon id="Path" fill="#209FDA" fill-rule="nonzero" points="1166.81993 85.5 1166.81993 2.84217094e-14 953.759925 2.84217094e-14 953.759925 85.5 1002.17993 85.5 915.859925 191.98 829.459925 85.5 878.099925 85.5 878.099925 2.84217094e-14 718.919925 2.84217094e-14 718.919925 95.72 856.479925 265.26 718.919925 434.96 718.919925 452.02 784.499925 452.02 784.499925 539.64 878.099925 539.64 878.099925 452.02 823.919925 452.02 915.919925 338.52 915.939925 338.52 1008.03993 452.02 953.759925 452.02 953.759925 539.64 1166.81993 539.64 1166.81993 452.02 1126.85993 452.02 975.319925 265.26 1121.01993 85.5"></polygon>
|
||||
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="664.019925 7.10542736e-15 664.019925 85.5 710.619925 85.5 718.919925 95.72 718.919925 7.10542736e-15"></polygon>
|
||||
<polygon id="Path" fill="#026BA4" fill-rule="nonzero" points="718.919925 452.02 718.919925 434.96 705.079925 452.02 664.019925 452.02 664.019925 539.64 784.499925 539.64 784.499925 452.02"></polygon>
|
||||
<path d="M321.999925,411.86 L397.659925,411.86 C388.805702,433.829527 376.258024,454.122269 360.559925,471.86 C344.364089,454.216816 331.320914,433.921419 321.999925,411.86" id="Path" fill="#78212E" fill-rule="nonzero"></path>
|
||||
<path d="M360.559925,189.28 C338.58337,213.190393 322.501981,241.908137 313.599925,273.14 C317.134915,280.039338 320.007771,287.25831 322.179925,294.7 L397.059925,294.7 C399.306706,287.354671 402.25356,280.242036 405.859925,273.46 C397.464721,242.277678 381.959326,213.464341 360.559925,189.28 Z M322.179925,294.7 C328.784599,317.438017 328.978396,341.558795 322.739925,364.4 L396.399925,364.4 C389.855554,341.597488 390.06397,317.386469 396.999925,294.7 L322.179925,294.7 Z M322.179925,294.7 L308.679925,294.7 C304.690779,317.752715 304.575868,341.309464 308.339925,364.4 L322.739925,364.4 C328.978396,341.558795 328.784599,317.438017 322.179925,294.7 L322.179925,294.7 Z" id="Shape" fill="#78212E" fill-rule="nonzero"></path>
|
||||
<path d="M710.619925,85.5 L664.019925,85.5 L664.019925,0.02 L576.019925,0.02 L576.019925,85.5 L632.859925,85.5 L632.859925,159.2 C598.417874,134.487772 557.04992,121.286425 514.659925,121.48 C456.044663,121.405246 400.107354,146.01621 360.559925,189.28 C381.937732,213.470272 397.422343,242.283149 405.799925,273.46 C426.944121,233.500977 468.451514,208.51034 513.659925,208.52 C581.059925,208.52 632.879925,263.16 632.879925,330.52 L632.879925,331.2 C632.539925,398.28 580.879925,452.56 513.659925,452.56 C468.477451,452.593197 426.976426,427.652566 405.799925,387.74 L405.799925,387.74 C401.869213,380.340239 398.718926,372.551658 396.399925,364.5 L308.399925,364.5 C309.686934,372.450225 311.443338,380.317312 313.659925,388.06 C315.970162,396.190434 318.775397,404.171995 322.059925,411.96 L397.659925,411.96 C388.805702,433.929527 376.258024,454.222269 360.559925,471.96 C400.107354,515.22379 456.044663,539.834754 514.659925,539.76 C571.465111,540.091874 625.745998,516.316729 664.019925,474.34 L664.019925,452.04 L705.059925,452.04 L718.899925,434.96 L718.899925,95.74 L710.619925,85.5 Z M632.879925,501.9 L632.879925,539.74 L664.019925,539.74 L664.019925,474.18 C654.623775,484.469293 644.18821,493.758755 632.879925,501.9 L632.879925,501.9 Z M313.599925,273.14 C311.569597,280.231983 309.927163,287.429316 308.679925,294.7 L322.179925,294.7 C320.007771,287.25831 317.134915,280.039338 313.599925,273.14 L313.599925,273.14 Z" id="Shape" fill="#8A8C8F" fill-rule="nonzero"></path>
|
||||
<path d="M410.399925,294.7 C409.199925,287.5 407.659925,280.4 405.799925,273.46 C402.19356,280.242036 399.246706,287.354671 396.999925,294.7 C390.06397,317.386469 389.855554,341.597488 396.399925,364.4 L410.719925,364.4 C414.264276,341.293291 414.156293,317.77319 410.399925,294.7 L410.399925,294.7 Z M209.059925,121.48 C107.422724,121.487508 20.5081632,194.571683 3.05992537,294.7 L91.3999254,294.7 C107.135726,243.467257 154.465065,208.503753 208.059925,208.52 C252.638644,208.335148 293.496156,233.351373 313.599925,273.14 C322.501981,241.908137 338.58337,213.190393 360.559925,189.28 C322.206855,145.880863 266.976617,121.163964 209.059925,121.48 L209.059925,121.48 Z M297.479925,411.86 C275.077969,437.877726 242.392659,452.761934 208.059925,452.58 C153.691226,452.598435 105.87164,416.63791 90.7999254,364.4 L308.339925,364.4 C304.575868,341.309464 304.690779,317.752715 308.679925,294.7 L3.05992537,294.7 C-0.902504563,317.755068 -1.01739385,341.307372 2.71992537,364.4 L2.71992537,364.4 C19.3292424,465.441984 106.661918,539.594765 209.059925,539.6 C266.986094,539.900862 322.217868,515.161403 360.559925,471.74 C344.364089,454.096816 331.320914,433.801419 321.999925,411.74 L297.479925,411.86 Z" id="Shape" fill="#B72768" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
@@ -1,2 +1,2 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as Header } from './Header';
|
||||
export { default as CourseTabsNavigation } from './CourseTabsNavigation';
|
||||
@@ -1,7 +1,7 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
courseMaterial: {
|
||||
'learn.navigation.course.tabs.label': {
|
||||
id: 'learn.navigation.course.tabs.label',
|
||||
defaultMessage: 'Course Material',
|
||||
description: 'The accessible label for course tabs navigation',
|
||||
41
src/course-home/CourseDates.jsx
Normal file
41
src/course-home/CourseDates.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
export default function CourseDates({
|
||||
start,
|
||||
end,
|
||||
enrollmentStart,
|
||||
enrollmentEnd,
|
||||
enrollmentMode,
|
||||
isEnrolled,
|
||||
}) {
|
||||
return (
|
||||
<section>
|
||||
<h4>Upcoming Dates</h4>
|
||||
<div><strong>Course Start:</strong><br />{start}</div>
|
||||
<div><strong>Course End:</strong><br />{end}</div>
|
||||
<div><strong>Enrollment Start:</strong><br />{enrollmentStart}</div>
|
||||
<div><strong>Enrollment End:</strong><br />{enrollmentEnd}</div>
|
||||
<div><strong>Mode:</strong><br />{enrollmentMode}</div>
|
||||
<div>{isEnrolled ? 'Active Enrollment' : 'Inactive Enrollment'}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
CourseDates.propTypes = {
|
||||
start: PropTypes.string,
|
||||
end: PropTypes.string,
|
||||
enrollmentStart: PropTypes.string,
|
||||
enrollmentEnd: PropTypes.string,
|
||||
enrollmentMode: PropTypes.string,
|
||||
isEnrolled: PropTypes.bool,
|
||||
};
|
||||
|
||||
CourseDates.defaultProps = {
|
||||
start: null,
|
||||
end: null,
|
||||
enrollmentStart: null,
|
||||
enrollmentEnd: null,
|
||||
enrollmentMode: null,
|
||||
isEnrolled: false,
|
||||
};
|
||||
92
src/course-home/CourseHome.jsx
Normal file
92
src/course-home/CourseHome.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button } from '@edx/paragon';
|
||||
|
||||
import AlertList from '../user-messages/AlertList';
|
||||
import { Header, CourseTabsNavigation } from '../course-header';
|
||||
import { useLogistrationAlert } from '../logistration-alert';
|
||||
import { useEnrollmentAlert } from '../enrollment-alert';
|
||||
|
||||
import CourseDates from './CourseDates';
|
||||
import Section from './Section';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
const EnrollmentAlert = React.lazy(() => import('../enrollment-alert'));
|
||||
const LogistrationAlert = React.lazy(() => import('../logistration-alert'));
|
||||
|
||||
export default function CourseHome({
|
||||
courseId,
|
||||
}) {
|
||||
useLogistrationAlert();
|
||||
useEnrollmentAlert(courseId);
|
||||
|
||||
const {
|
||||
org,
|
||||
number,
|
||||
title,
|
||||
start,
|
||||
end,
|
||||
enrollmentStart,
|
||||
enrollmentEnd,
|
||||
enrollmentMode,
|
||||
isEnrolled,
|
||||
tabs,
|
||||
sectionIds,
|
||||
} = useModel('courses', courseId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
courseOrg={org}
|
||||
courseNumber={number}
|
||||
courseTitle={title}
|
||||
/>
|
||||
<main className="d-flex flex-column flex-grow-1">
|
||||
<div className="container-fluid">
|
||||
<CourseTabsNavigation tabs={tabs} className="mb-3" activeTabSlug="courseware" />
|
||||
<AlertList
|
||||
topic="outline"
|
||||
className="mb-3"
|
||||
customAlerts={{
|
||||
clientEnrollmentAlert: EnrollmentAlert,
|
||||
clientLogistrationAlert: LogistrationAlert,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-grow-1">
|
||||
<div className="container-fluid">
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<h2>{title}</h2>
|
||||
<Button className="btn-primary" type="button">Resume Course</Button>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col col-8">
|
||||
{sectionIds.map((sectionId) => (
|
||||
<Section
|
||||
key={sectionId}
|
||||
id={sectionId}
|
||||
courseId={courseId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="col col-4">
|
||||
<CourseDates
|
||||
start={start}
|
||||
end={end}
|
||||
enrollmentStart={enrollmentStart}
|
||||
enrollmentEnd={enrollmentEnd}
|
||||
enrollmentMode={enrollmentMode}
|
||||
isEnrolled={isEnrolled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CourseHome.propTypes = {
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
54
src/course-home/CourseHomeContainer.jsx
Normal file
54
src/course-home/CourseHomeContainer.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import PageLoading from '../PageLoading';
|
||||
import CourseHome from './CourseHome';
|
||||
import { fetchCourse } from '../data';
|
||||
|
||||
function CourseHomeContainer(props) {
|
||||
const {
|
||||
intl,
|
||||
match,
|
||||
} = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
// The courseId from the URL is the course we WANT to load.
|
||||
dispatch(fetchCourse(match.params.courseId));
|
||||
}, [match.params.courseId]);
|
||||
|
||||
// The courseId from the store is the course we HAVE loaded. If the URL changes,
|
||||
// we don't want the application to adjust to it until it has actually loaded the new data.
|
||||
const {
|
||||
courseId,
|
||||
courseStatus,
|
||||
} = useSelector(state => state.courseware);
|
||||
|
||||
return (
|
||||
<>
|
||||
{courseStatus === 'loaded' ? (
|
||||
<CourseHome
|
||||
courseId={courseId}
|
||||
/>
|
||||
) : (
|
||||
<PageLoading
|
||||
srMessage={intl.formatMessage(messages['learn.loading.outline'])}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
CourseHomeContainer.propTypes = {
|
||||
intl: intlShape.isRequired,
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
courseId: PropTypes.string.isRequired,
|
||||
}).isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(CourseHomeContainer);
|
||||
44
src/course-home/Section.jsx
Normal file
44
src/course-home/Section.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Collapsible } from '@edx/paragon';
|
||||
import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import SequenceLink from './SequenceLink';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
export default function Section({ id, courseId }) {
|
||||
const section = useModel('sections', id);
|
||||
const { title, sequenceIds } = section;
|
||||
return (
|
||||
<Collapsible.Advanced className="collapsible-card mb-2">
|
||||
<Collapsible.Trigger className="collapsible-trigger d-flex align-items-start">
|
||||
<Collapsible.Visible whenClosed>
|
||||
<div style={{ minWidth: '1rem' }}>
|
||||
<FontAwesomeIcon icon={faChevronRight} />
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<div style={{ minWidth: '1rem' }}>
|
||||
<FontAwesomeIcon icon={faChevronDown} />
|
||||
</div>
|
||||
</Collapsible.Visible>
|
||||
<div className="ml-2 flex-grow-1">{title}</div>
|
||||
</Collapsible.Trigger>
|
||||
|
||||
<Collapsible.Body className="collapsible-body">
|
||||
{sequenceIds.map((sequenceId) => (
|
||||
<SequenceLink
|
||||
key={sequenceId}
|
||||
id={sequenceId}
|
||||
courseId={courseId}
|
||||
/>
|
||||
))}
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
);
|
||||
}
|
||||
|
||||
Section.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
18
src/course-home/SequenceLink.jsx
Normal file
18
src/course-home/SequenceLink.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useModel } from '../model-store';
|
||||
|
||||
export default function SequenceLink({ id, courseId }) {
|
||||
const sequence = useModel('sequences', id);
|
||||
return (
|
||||
<div className="ml-4">
|
||||
<Link to={`/course/${courseId}/${id}`}>{sequence.title}</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
SequenceLink.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
courseId: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Tabs, Tab } from '@openedx/paragon';
|
||||
|
||||
import { useParams } from 'react-router';
|
||||
import CoursewareSearchResults from './CoursewareSearchResults';
|
||||
import messages from './messages';
|
||||
import { useCoursewareSearchParams } from './hooks';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
|
||||
const filterAll = 'all';
|
||||
const filterTypes = ['text', 'video', 'sequence'];
|
||||
const filterOther = 'other';
|
||||
const validFilters = [filterAll, ...filterTypes, filterOther];
|
||||
|
||||
export const CoursewareSearchResultsFilter = () => {
|
||||
const intl = useIntl();
|
||||
const { courseId } = useParams();
|
||||
const lastSearch = useModel('contentSearchResults', courseId);
|
||||
const { filter: filterKeyword, setFilter } = useCoursewareSearchParams();
|
||||
|
||||
if (!lastSearch) { return null; }
|
||||
|
||||
const { results: data = [] } = lastSearch;
|
||||
|
||||
// If there's no data, we show an empty result.
|
||||
if (!data.length) { return <CoursewareSearchResults />; }
|
||||
|
||||
const results = useMemo(() => {
|
||||
// This reducer distributes the data into different groups to make it easy to
|
||||
// use on the filters.
|
||||
// All results are added to the "all" key and then to its proper group key as well.
|
||||
const grouped = data.reduce((acc, { type, ...rest }) => {
|
||||
const resultType = filterTypes.includes(type) ? type : filterOther;
|
||||
acc[filterAll].push({ type: resultType, ...rest });
|
||||
acc[resultType] = [...(acc[resultType] || []), { type: resultType, ...rest }];
|
||||
return acc;
|
||||
}, { [filterAll]: [] });
|
||||
|
||||
// This is just to format the output object with the expected tab order.
|
||||
const output = {};
|
||||
validFilters.forEach(key => { if (grouped[key]) { output[key] = grouped[key]; } });
|
||||
|
||||
return output;
|
||||
}, [lastSearch]);
|
||||
|
||||
const tabKeys = Object.keys(results);
|
||||
// Filter has no use if it has only 2 tabs (The "all" tab and another one with the same items).
|
||||
if (tabKeys.length < 3) { return <CoursewareSearchResults results={results[filterAll]} />; }
|
||||
|
||||
const filters = useMemo(() => tabKeys.map((key) => ({
|
||||
key,
|
||||
label: intl.formatMessage(messages[`filter:${key}`]),
|
||||
count: results[key].length,
|
||||
})), [results]);
|
||||
|
||||
const activeKey = validFilters.includes(filterKeyword) ? filterKeyword : filterAll;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
id="courseware-search-results-tabs"
|
||||
className="courseware-search-results-tabs"
|
||||
data-testid="courseware-search-results-tabs"
|
||||
variant="tabs"
|
||||
activeKey={activeKey}
|
||||
onSelect={setFilter}
|
||||
>
|
||||
{filters.filter(({ count }) => (count > 0)).map(({ key, label }) => (
|
||||
<Tab key={key} eventKey={key} title={label} data-testid={`courseware-search-results-tabs-${key}`}>
|
||||
<CoursewareSearchResults results={results[key]} />
|
||||
</Tab>
|
||||
))}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursewareSearchResultsFilter;
|
||||
@@ -1,132 +0,0 @@
|
||||
import React from 'react';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import {
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '../../setupTest';
|
||||
import { CoursewareSearchResultsFilter } from './CoursewareResultsFilter';
|
||||
import { useCoursewareSearchParams } from './hooks';
|
||||
import initializeStore from '../../store';
|
||||
import { useModel } from '../../generic/model-store';
|
||||
import searchResultsFactory from './test-data/search-results-factory';
|
||||
|
||||
jest.mock('./hooks');
|
||||
jest.mock('../../generic/model-store', () => ({
|
||||
useModel: jest.fn(),
|
||||
}));
|
||||
|
||||
global.ResizeObserver = jest.fn().mockImplementation(() => ({
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
}));
|
||||
|
||||
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const decodedSequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
|
||||
const decodedUnitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
|
||||
const pathname = `/course/${decodedCourseId}/${decodedSequenceId}/${decodedUnitId}`;
|
||||
|
||||
const intl = {
|
||||
formatMessage: (message) => message?.defaultMessage || '',
|
||||
};
|
||||
|
||||
const coursewareSearch = {
|
||||
query: '',
|
||||
filter: '',
|
||||
setQuery: jest.fn(),
|
||||
setFilter: jest.fn(),
|
||||
clearSearchParams: jest.fn(),
|
||||
};
|
||||
|
||||
function renderComponent(props = {}) {
|
||||
const store = initializeStore();
|
||||
history.push(pathname);
|
||||
const { container } = render(
|
||||
<AppProvider store={store}>
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/:sequenceId/:unitId" element={<CoursewareSearchResultsFilter intl={intl} {...props} />} />
|
||||
</Routes>
|
||||
</AppProvider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchResultsFilter', () => {
|
||||
beforeAll(initializeMockApp);
|
||||
|
||||
beforeEach(() => {
|
||||
useCoursewareSearchParams.mockReturnValue(coursewareSearch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when returning full results', () => {
|
||||
beforeEach(() => {
|
||||
useModel.mockReturnValue(searchResultsFactory());
|
||||
renderComponent();
|
||||
});
|
||||
|
||||
it('should render without errors', async () => {
|
||||
await waitFor(() => {
|
||||
expect(useCoursewareSearchParams).toBeCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs-all')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs-text')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs-video')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs-sequence')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs-other')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when returning only one result type', () => {
|
||||
beforeEach(async () => {
|
||||
// Get results for only videos
|
||||
const data = searchResultsFactory();
|
||||
const onlyVideos = data.results.filter(({ type }) => type === 'video');
|
||||
const filteredResults = {
|
||||
...data,
|
||||
results: onlyVideos,
|
||||
};
|
||||
|
||||
useModel.mockReturnValue(filteredResults);
|
||||
await renderComponent();
|
||||
});
|
||||
|
||||
it('should not render', async () => {
|
||||
await waitFor(() => {
|
||||
expect(useCoursewareSearchParams).toBeCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('when there are not results', () => {
|
||||
beforeEach(async () => {
|
||||
useModel.mockReturnValue(searchResultsFactory('blah', {
|
||||
results: [],
|
||||
filters: [],
|
||||
total: 0,
|
||||
maxScore: null,
|
||||
ms: 5,
|
||||
}));
|
||||
await renderComponent();
|
||||
});
|
||||
|
||||
it('should not render', async () => {
|
||||
await waitFor(() => {
|
||||
expect(useCoursewareSearchParams).toBeCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('courseware-search-results-tabs')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,176 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Alert, Button, Icon, Spinner,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
Close,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import { useCoursewareSearchParams, useElementBoundingBox, useLockScroll } from './hooks';
|
||||
import messages from './messages';
|
||||
|
||||
import CoursewareSearchForm from './CoursewareSearchForm';
|
||||
import CoursewareSearchResultsFilterContainer from './CoursewareResultsFilter';
|
||||
import { updateModel, useModel } from '../../generic/model-store';
|
||||
import { searchCourseContent } from '../data/thunks';
|
||||
|
||||
const CoursewareSearch = ({ ...sectionProps }) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const { courseId } = useParams();
|
||||
const { query: searchKeyword, setQuery, clearSearchParams } = useCoursewareSearchParams();
|
||||
const dispatch = useDispatch();
|
||||
const { org } = useModel('courseHomeMeta', courseId);
|
||||
const {
|
||||
loading,
|
||||
searchKeyword: lastSearchKeyword,
|
||||
errors,
|
||||
total,
|
||||
} = useModel('contentSearchResults', courseId);
|
||||
const dialogRef = useRef();
|
||||
|
||||
useLockScroll();
|
||||
|
||||
const info = useElementBoundingBox('courseTabsNavigation');
|
||||
const top = info ? `${Math.floor(info.top)}px` : 0;
|
||||
|
||||
const clearSearch = () => {
|
||||
clearSearchParams();
|
||||
dispatch(updateModel({
|
||||
modelType: 'contentSearchResults',
|
||||
model: {
|
||||
id: courseId,
|
||||
searchKeyword: '',
|
||||
results: [],
|
||||
errors: undefined,
|
||||
loading:
|
||||
false,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = (value) => {
|
||||
if (!value) {
|
||||
clearSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
sendTrackingLogEvent('edx.course.home.courseware_search.submit', {
|
||||
org_key: org,
|
||||
courserun_key: courseId,
|
||||
event_type: 'searchKeyword',
|
||||
keyword: value,
|
||||
});
|
||||
|
||||
dispatch(searchCourseContent(courseId, value));
|
||||
setQuery(value);
|
||||
};
|
||||
|
||||
const handleOnChange = (value) => {
|
||||
if (value === searchKeyword) { return; }
|
||||
if (!value) { clearSearch(); }
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
clearSearch();
|
||||
dispatch(setShowSearch(false));
|
||||
};
|
||||
|
||||
const handlePopState = () => close();
|
||||
|
||||
const handleBackdropClick = function (event) {
|
||||
if (event.target === dialogRef.current) {
|
||||
dialogRef.current.close();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// We need this to keep the dialog reference when unmounting.
|
||||
const dialog = dialogRef.current;
|
||||
|
||||
// Open the dialog as a modal on render to confine focus within it.
|
||||
dialogRef.current.showModal();
|
||||
|
||||
if (searchKeyword) {
|
||||
handleSubmit(searchKeyword); // In case it's opened with a search link, we run the search.
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
window.addEventListener('popstate', handlePopState, { signal });
|
||||
dialog.addEventListener('click', handleBackdropClick, { signal });
|
||||
|
||||
return () => controller.abort(); // Removes event listeners.
|
||||
}, []);
|
||||
|
||||
const handleSearchClose = () => close();
|
||||
|
||||
let status = 'idle';
|
||||
if (loading) {
|
||||
status = 'loading';
|
||||
} else if (errors) {
|
||||
status = 'error';
|
||||
} else if (lastSearchKeyword) {
|
||||
status = 'results';
|
||||
}
|
||||
|
||||
return (
|
||||
<dialog ref={dialogRef} className="courseware-search" style={{ '--modal-top-position': top }} data-testid="courseware-search-dialog" onClose={handleSearchClose} {...sectionProps}>
|
||||
<div className="courseware-search__outer-content">
|
||||
<div className="courseware-search__content" data-testid="courseware-search-content">
|
||||
<div className="courseware-search__form">
|
||||
<h1 className="h2">{formatMessage(messages.searchModuleTitle)}</h1>
|
||||
<CoursewareSearchForm
|
||||
searchTerm={searchKeyword}
|
||||
onSubmit={handleSubmit}
|
||||
onChange={handleOnChange}
|
||||
placeholder={formatMessage(messages.searchBarPlaceholderText)}
|
||||
/>
|
||||
<div className="courseware-search__close">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="p-1"
|
||||
aria-label={formatMessage(messages.searchCloseAction)}
|
||||
onClick={() => dialogRef.current.close()}
|
||||
data-testid="courseware-search-close-button"
|
||||
><Icon src={Close} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="courseware-search__results" aria-live="polite" data-testid="courseware-search-results">
|
||||
{status === 'loading' ? (
|
||||
<div className="courseware-search__spinner" data-testid="courseware-search-spinner">
|
||||
<Spinner animation="border" variant="light" screenReaderText={formatMessage(messages.loading)} />
|
||||
</div>
|
||||
) : null}
|
||||
{status === 'error' && (
|
||||
<Alert className="mt-4" variant="danger" data-testid="courseware-search-error">
|
||||
{formatMessage(messages.searchResultsError)}
|
||||
</Alert>
|
||||
)}
|
||||
{status === 'results' ? (
|
||||
<>
|
||||
{total > 0 ? (
|
||||
<div
|
||||
className="courseware-search__results-summary"
|
||||
aria-relevant="all"
|
||||
aria-atomic="true"
|
||||
data-testid="courseware-search-summary"
|
||||
>{formatMessage(messages.searchResultsLabel, { total, keyword: lastSearchKeyword })}
|
||||
</div>
|
||||
) : null}
|
||||
<CoursewareSearchResultsFilterContainer />
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursewareSearch;
|
||||
@@ -1,287 +0,0 @@
|
||||
import React from 'react';
|
||||
import { history } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
|
||||
import {
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
within,
|
||||
} from '../../setupTest';
|
||||
import { CoursewareSearch } from './index';
|
||||
import { useElementBoundingBox, useLockScroll, useCoursewareSearchParams } from './hooks';
|
||||
import initializeStore from '../../store';
|
||||
import { searchCourseContent } from '../data/thunks';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import { updateModel, useModel } from '../../generic/model-store';
|
||||
|
||||
jest.mock('./hooks');
|
||||
jest.mock('../../generic/model-store', () => ({
|
||||
...jest.requireActual('../../generic/model-store'),
|
||||
updateModel: jest.fn(),
|
||||
useModel: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@edx/frontend-platform/analytics', () => ({
|
||||
sendTrackingLogEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../data/thunks', () => ({
|
||||
searchCourseContent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../data/slice', () => ({
|
||||
setShowSearch: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
const decodedCourseId = 'course-v1:edX+DemoX+Demo_Course';
|
||||
const decodedSequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction';
|
||||
const decodedUnitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
|
||||
const pathname = `/course/${decodedCourseId}/${decodedSequenceId}/${decodedUnitId}`;
|
||||
|
||||
const tabsTopPosition = 128;
|
||||
|
||||
const defaultProps = {
|
||||
org: 'edX',
|
||||
loading: false,
|
||||
searchKeyword: '',
|
||||
errors: undefined,
|
||||
total: 0,
|
||||
};
|
||||
|
||||
const defaultSearchParams = {
|
||||
query: '',
|
||||
filter: '',
|
||||
setQuery: jest.fn(),
|
||||
setFilter: jest.fn(),
|
||||
clearSearchParams: jest.fn(),
|
||||
};
|
||||
|
||||
const intl = {
|
||||
formatMessage: (message) => message?.defaultMessage || '',
|
||||
};
|
||||
|
||||
function renderComponent(props = {}) {
|
||||
const store = initializeStore();
|
||||
history.push(pathname);
|
||||
const { container } = render(
|
||||
<AppProvider store={store}>
|
||||
<Routes>
|
||||
<Route path="/course/:courseId/:sequenceId/:unitId" element={<CoursewareSearch intl={intl} {...props} />} />
|
||||
</Routes>
|
||||
</AppProvider>,
|
||||
);
|
||||
return container;
|
||||
}
|
||||
|
||||
const mockModels = ((props = defaultProps) => {
|
||||
useModel.mockReturnValue({
|
||||
...defaultProps,
|
||||
...props,
|
||||
});
|
||||
|
||||
updateModel.mockReturnValue({
|
||||
type: 'MOCK_ACTION',
|
||||
payload: {
|
||||
modelType: 'contentSearchResults',
|
||||
model: defaultProps,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const mockSearchParams = ((params) => {
|
||||
const props = { ...defaultSearchParams, ...params };
|
||||
useCoursewareSearchParams.mockReturnValue(props);
|
||||
});
|
||||
|
||||
describe('CoursewareSearch', () => {
|
||||
beforeAll(() => initializeMockApp());
|
||||
|
||||
beforeEach(() => {
|
||||
mockModels();
|
||||
mockSearchParams();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('when rendering normally', () => {
|
||||
beforeAll(() => {
|
||||
useElementBoundingBox.mockImplementation(() => ({ top: tabsTopPosition }));
|
||||
});
|
||||
|
||||
it('should use useElementBoundingBox() and useLockScroll() hooks', () => {
|
||||
renderComponent();
|
||||
|
||||
expect(useElementBoundingBox).toHaveBeenCalledTimes(1);
|
||||
expect(useLockScroll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should have a "--modal-top-position" CSS variable matching the CourseTabsNavigation top position', () => {
|
||||
renderComponent();
|
||||
|
||||
const section = screen.getByTestId('courseware-search-dialog');
|
||||
expect(section.style.getPropertyValue('--modal-top-position')).toBe(`${tabsTopPosition}px`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when clicking on the "Close" button', () => {
|
||||
it('should close the dialog', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
const close = screen.queryByTestId('courseware-search-close-button');
|
||||
fireEvent.click(close);
|
||||
});
|
||||
|
||||
expect(HTMLDialogElement.prototype.close).toHaveBeenCalled();
|
||||
expect(setShowSearch).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when CourseTabsNavigation is not present', () => {
|
||||
it('should use "--modal-top-position: 0" if nce element is not present', () => {
|
||||
useElementBoundingBox.mockImplementation(() => undefined);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const section = screen.getByTestId('courseware-search-dialog');
|
||||
expect(section.style.getPropertyValue('--modal-top-position')).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when passing extra props', () => {
|
||||
it('should pass on extra props to section element', () => {
|
||||
renderComponent({ foo: 'bar' });
|
||||
|
||||
const section = screen.getByTestId('courseware-search-dialog');
|
||||
expect(section).toHaveAttribute('foo', 'bar');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when submitting an empty search', () => {
|
||||
it('should clear the search by dispatch updateModel', async () => {
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
const submit = screen.queryByTestId('courseware-search-form-submit');
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(updateModel).toHaveBeenCalledWith({
|
||||
modelType: 'contentSearchResults',
|
||||
model: {
|
||||
id: decodedCourseId,
|
||||
searchKeyword: '',
|
||||
results: [],
|
||||
errors: undefined,
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when submitting a search', () => {
|
||||
it('should show a loading state', () => {
|
||||
mockModels({
|
||||
loading: true,
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByTestId('courseware-search-spinner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call searchCourseContent', async () => {
|
||||
renderComponent();
|
||||
|
||||
const searchKeyword = 'course';
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.queryByTestId('courseware-search-form').querySelector('input');
|
||||
fireEvent.change(input, { target: { value: searchKeyword } });
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const submit = screen.queryByTestId('courseware-search-form-submit');
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(sendTrackingLogEvent).toHaveBeenCalledWith('edx.course.home.courseware_search.submit', {
|
||||
org_key: defaultProps.org,
|
||||
courserun_key: decodedCourseId,
|
||||
event_type: 'searchKeyword',
|
||||
keyword: searchKeyword,
|
||||
});
|
||||
expect(searchCourseContent).toHaveBeenCalledWith(decodedCourseId, searchKeyword);
|
||||
});
|
||||
|
||||
it('should show an error state if any', () => {
|
||||
mockModels({
|
||||
errors: ['foo'],
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByTestId('courseware-search-error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show a summary if there are no results', () => {
|
||||
mockModels({
|
||||
searchKeyword: 'test',
|
||||
total: 0,
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
expect(screen.queryByTestId('courseware-search-summary')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show a summary for the results within a container with aria-live="polite"', () => {
|
||||
mockModels({
|
||||
searchKeyword: 'fubar',
|
||||
total: 1,
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
const results = screen.queryByTestId('courseware-search-results');
|
||||
|
||||
expect(results).toHaveAttribute('aria-live', 'polite');
|
||||
expect(within(results).queryByTestId('courseware-search-summary').textContent).toBe('Results for "fubar":');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when clearing the search input', () => {
|
||||
it('should clear the search by dispatch updateModel', async () => {
|
||||
mockSearchParams({ query: 'fubar' });
|
||||
mockModels({
|
||||
searchKeyword: 'fubar',
|
||||
total: 2,
|
||||
});
|
||||
renderComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
const input = screen.queryByTestId('courseware-search-form').querySelector('input');
|
||||
fireEvent.change(input, { target: { value: '' } });
|
||||
});
|
||||
|
||||
expect(updateModel).toHaveBeenCalledWith({
|
||||
modelType: 'contentSearchResults',
|
||||
model: {
|
||||
id: decodedCourseId,
|
||||
searchKeyword: '',
|
||||
results: [],
|
||||
errors: undefined,
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
const CoursewareSearchEmpty = () => {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<div className="courseware-search-results">
|
||||
<p className="courseware-search-results__empty" data-testid="no-results">{intl.formatMessage(messages.searchResultsNone)}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursewareSearchEmpty;
|
||||
@@ -1,28 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
} from '../../setupTest';
|
||||
import CoursewareSearchEmpty from './CoursewareSearchEmpty';
|
||||
import messages from './messages';
|
||||
|
||||
function renderComponent() {
|
||||
const { container } = render(<CoursewareSearchEmpty />);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchEmpty', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
it('render empty results text and corresponding classes', () => {
|
||||
renderComponent();
|
||||
const emptyText = screen.getByText(messages.searchResultsNone.defaultMessage);
|
||||
expect(emptyText).toBeInTheDocument();
|
||||
expect(emptyText).toHaveClass('courseware-search-results__empty');
|
||||
expect(emptyText).toHaveAttribute('data-testid', 'no-results');
|
||||
expect(emptyText.parentElement).toHaveClass('courseware-search-results');
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import { SearchField } from '@openedx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
const CoursewareSearchForm = ({
|
||||
searchTerm,
|
||||
onSubmit,
|
||||
onChange,
|
||||
placeholder,
|
||||
}) => {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<SearchField.Advanced
|
||||
value={searchTerm}
|
||||
onSubmit={onSubmit}
|
||||
onChange={onChange}
|
||||
submitButtonLocation="external"
|
||||
className="courseware-search-form"
|
||||
screenReaderText={{
|
||||
label: formatMessage(messages.searchSubmitLabel),
|
||||
clearButton: formatMessage(messages.searchClearAction),
|
||||
submitButton: null, // Remove the sr-only label in the button.
|
||||
}}
|
||||
>
|
||||
<div className="pgn__searchfield_wrapper" data-testid="courseware-search-form">
|
||||
<SearchField.Label />
|
||||
<SearchField.Input placeholder={placeholder} autoFocus />
|
||||
<SearchField.ClearButton />
|
||||
</div>
|
||||
<SearchField.SubmitButton
|
||||
buttonText={formatMessage(messages.searchSubmitLabel)}
|
||||
submitButtonLocation="external"
|
||||
data-testid="courseware-search-form-submit"
|
||||
/>
|
||||
</SearchField.Advanced>
|
||||
);
|
||||
};
|
||||
|
||||
CoursewareSearchForm.propTypes = {
|
||||
searchTerm: PropTypes.string,
|
||||
onSubmit: PropTypes.func,
|
||||
onChange: PropTypes.func,
|
||||
placeholder: PropTypes.string,
|
||||
};
|
||||
|
||||
CoursewareSearchForm.defaultProps = {
|
||||
searchTerm: undefined,
|
||||
onSubmit: undefined,
|
||||
onChange: undefined,
|
||||
placeholder: undefined,
|
||||
};
|
||||
|
||||
export default CoursewareSearchForm;
|
||||
@@ -1,60 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
act,
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from '../../setupTest';
|
||||
import CoursewareSearchForm from './CoursewareSearchForm';
|
||||
|
||||
function renderComponent(placeholder, onSubmit, onChange) {
|
||||
const { container } = render(<CoursewareSearchForm
|
||||
placeholder={placeholder}
|
||||
onSubmit={onSubmit}
|
||||
onChange={onChange}
|
||||
/>);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchToggle', () => {
|
||||
const placeholderText = 'Search for courseware';
|
||||
let onSubmitHandlerMock;
|
||||
let onChangeHandlerMock;
|
||||
|
||||
beforeAll(async () => {
|
||||
onChangeHandlerMock = jest.fn();
|
||||
onSubmitHandlerMock = jest.fn();
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
it('should render', async () => {
|
||||
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('courseware-search-form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onChange handler when input changes', async () => {
|
||||
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
|
||||
await waitFor(() => {
|
||||
const element = screen.queryByPlaceholderText(placeholderText);
|
||||
fireEvent.change(element, { target: { value: 'test' } });
|
||||
expect(onChangeHandlerMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onSubmit handler when submit is clicked', async () => {
|
||||
await act(async () => renderComponent(placeholderText, onSubmitHandlerMock, onChangeHandlerMock));
|
||||
await waitFor(async () => {
|
||||
const element = await screen.findByTestId('courseware-search-form-submit');
|
||||
fireEvent.click(element);
|
||||
expect(onSubmitHandlerMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Folder, TextFields, VideoCamera, Article,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { Icon } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import CoursewareSearchEmpty from './CoursewareSearchEmpty';
|
||||
|
||||
const iconTypeMapping = {
|
||||
text: TextFields,
|
||||
video: VideoCamera,
|
||||
sequence: Folder,
|
||||
other: Article,
|
||||
};
|
||||
|
||||
const defaultIcon = Article;
|
||||
|
||||
const CoursewareSearchResults = ({ results = [] }) => {
|
||||
if (!results?.length) {
|
||||
return <CoursewareSearchEmpty />;
|
||||
}
|
||||
|
||||
const baseUrl = `${getConfig().LMS_BASE_URL}`;
|
||||
|
||||
return (
|
||||
<div className="courseware-search-results" data-testid="search-results">
|
||||
{results.map(({
|
||||
id,
|
||||
title,
|
||||
type,
|
||||
location,
|
||||
url,
|
||||
contentHits,
|
||||
}) => {
|
||||
const key = type.toLowerCase();
|
||||
const icon = iconTypeMapping[key] || defaultIcon;
|
||||
const isExternal = !url.startsWith('/');
|
||||
const linkProps = isExternal ? {
|
||||
href: url,
|
||||
target: '_blank',
|
||||
rel: 'nofollow',
|
||||
} : { href: `${baseUrl}${url}` };
|
||||
|
||||
return (
|
||||
<a key={id} className="courseware-search-results__item" {...linkProps}>
|
||||
<div className="courseware-search-results__icon"><Icon src={icon} /></div>
|
||||
<div className="courseware-search-results__info">
|
||||
<div className="courseware-search-results__title">
|
||||
<span>{title}</span>
|
||||
{contentHits ? (<em>{contentHits}</em>) : null }
|
||||
</div>
|
||||
{location?.length ? (
|
||||
<ul className="courseware-search-results__breadcrumbs">
|
||||
{
|
||||
// This ignore is necessary because the breadcrumb texts might have duplicates.
|
||||
// The breadcrumbs are not expected to change.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
location.map((breadcrumb, i) => (<li key={`${i}:${breadcrumb}`}><div>{breadcrumb}</div></li>))
|
||||
}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CoursewareSearchResults.propTypes = {
|
||||
results: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
type: PropTypes.string,
|
||||
location: PropTypes.arrayOf(PropTypes.string),
|
||||
url: PropTypes.string,
|
||||
contentHits: PropTypes.number,
|
||||
})),
|
||||
};
|
||||
|
||||
CoursewareSearchResults.defaultProps = {
|
||||
results: [],
|
||||
};
|
||||
|
||||
export default CoursewareSearchResults;
|
||||
@@ -1,87 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
} from '../../setupTest';
|
||||
import CoursewareSearchResults from './CoursewareSearchResults';
|
||||
import messages from './messages';
|
||||
import searchResultsFactory from './test-data/search-results-factory';
|
||||
import * as mock from './test-data/mocked-response.json';
|
||||
|
||||
jest.mock('react-redux');
|
||||
|
||||
function renderComponent({ results }) {
|
||||
const { container } = render(<CoursewareSearchResults results={results} />);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchResults', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
describe('when an empty array is provided', () => {
|
||||
beforeEach(() => { renderComponent({ results: [] }); });
|
||||
|
||||
it('should render a "no results found" message.', () => {
|
||||
expect(screen.getByTestId('no-results').textContent).toBe(messages.searchResultsNone.defaultMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when list of results is provided', () => {
|
||||
beforeEach(() => {
|
||||
const { results } = searchResultsFactory('course');
|
||||
renderComponent({ results });
|
||||
});
|
||||
|
||||
it('should render complete list', () => {
|
||||
const courses = screen.getAllByRole('link');
|
||||
expect(courses.length).toBe(mock.results.length);
|
||||
});
|
||||
|
||||
it('should render correct link for internal course', () => {
|
||||
const courses = screen.getAllByRole('link');
|
||||
const firstCourse = courses[0];
|
||||
const firstCourseTitle = firstCourse.querySelector('.courseware-search-results__title span');
|
||||
expect(firstCourseTitle.innerHTML).toEqual(mock.results[0].data.content.display_name);
|
||||
expect(firstCourse.href).toContain(mock.results[0].data.url);
|
||||
expect(firstCourse).not.toHaveAttribute('target', '_blank');
|
||||
expect(firstCourse).not.toHaveAttribute('rel', 'nofollow');
|
||||
});
|
||||
|
||||
it('should render correct link if is External url course', () => {
|
||||
const courses = screen.getAllByRole('link');
|
||||
const externalCourse = courses[courses.length - 1];
|
||||
const externalCourseTitle = externalCourse.querySelector('.courseware-search-results__title span');
|
||||
expect(externalCourseTitle.innerHTML).toEqual(mock.results[mock.results.length - 1].data.content.display_name);
|
||||
expect(externalCourse.href).toContain(mock.results[mock.results.length - 1].data.url);
|
||||
expect(externalCourse).toHaveAttribute('target', '_blank');
|
||||
expect(externalCourse).toHaveAttribute('rel', 'nofollow');
|
||||
const icon = externalCourse.querySelector('svg');
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render location breadcrumbs', () => {
|
||||
const breadcrumbs = screen.getAllByText(mock.results[0].data.location[0]);
|
||||
expect(breadcrumbs.length).toBeGreaterThan(0);
|
||||
const firstBreadcrumb = breadcrumbs[0].closest('li');
|
||||
expect(firstBreadcrumb).toBeInTheDocument();
|
||||
expect(firstBreadcrumb.querySelector('div').textContent).toBe(mock.results[0].data.location[0]);
|
||||
expect(firstBreadcrumb.nextSibling.querySelector('div').textContent).toBe(mock.results[0].data.location[1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when results are provided with content hits', () => {
|
||||
beforeEach(() => {
|
||||
const { results } = searchResultsFactory('Passing');
|
||||
renderComponent({ results });
|
||||
});
|
||||
|
||||
it('should render content hits', () => {
|
||||
const contentHits = screen.getByText('1');
|
||||
expect(contentHits).toBeInTheDocument();
|
||||
expect(contentHits.tagName).toBe('EM');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import { ManageSearch } from '@openedx/paragon/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import messages from './messages';
|
||||
import { useCoursewareSearchFeatureFlag, useCoursewareSearchParams } from './hooks';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
|
||||
const CoursewareSearchToggle = () => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
const enabled = useCoursewareSearchFeatureFlag();
|
||||
const { query } = useCoursewareSearchParams();
|
||||
|
||||
const handleSearchOpenClick = () => {
|
||||
dispatch(setShowSearch(true));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && !!query) { handleSearchOpenClick(); }
|
||||
}, [enabled]);
|
||||
|
||||
if (!enabled) { return null; }
|
||||
|
||||
return (
|
||||
<div className="courseware-search-toggle">
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
size="sm"
|
||||
className="p-1 mt-2 mr-2"
|
||||
aria-label={intl.formatMessage(messages.searchOpenAction)}
|
||||
onClick={handleSearchOpenClick}
|
||||
data-testid="courseware-search-open-button"
|
||||
iconAfter={ManageSearch}
|
||||
>
|
||||
{intl.formatMessage(messages.contentSearchButton)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CoursewareSearchToggle;
|
||||
@@ -1,91 +0,0 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
initializeMockApp,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '../../setupTest';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
import { setShowSearch } from '../data/slice';
|
||||
import { CoursewareSearchToggle } from './index';
|
||||
|
||||
const mockDispatch = jest.fn();
|
||||
const mockCoursewareSearchParams = jest.fn();
|
||||
|
||||
jest.mock('../data/thunks');
|
||||
jest.mock('../data/slice');
|
||||
|
||||
jest.mock('react-redux', () => ({
|
||||
...jest.requireActual('react-redux'),
|
||||
useDispatch: () => mockDispatch,
|
||||
}));
|
||||
|
||||
jest.mock('./hooks', () => ({
|
||||
...jest.requireActual('./hooks'),
|
||||
useCoursewareSearchParams: () => mockCoursewareSearchParams,
|
||||
}));
|
||||
|
||||
const coursewareSearch = {
|
||||
query: '',
|
||||
filter: '',
|
||||
setQuery: jest.fn(),
|
||||
setFilter: jest.fn(),
|
||||
clearSearchParams: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSearchParams = ((props = coursewareSearch) => {
|
||||
mockCoursewareSearchParams.mockReturnValue(props);
|
||||
});
|
||||
|
||||
function renderComponent() {
|
||||
const { container } = render(<CoursewareSearchToggle />);
|
||||
return container;
|
||||
}
|
||||
|
||||
describe('CoursewareSearchToggle', () => {
|
||||
beforeAll(async () => {
|
||||
initializeMockApp();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Should not render when the waffle flag is disabled', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: false }));
|
||||
mockSearchParams();
|
||||
|
||||
await act(async () => renderComponent());
|
||||
await waitFor(() => {
|
||||
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('courseware-search-open-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should render when the waffle flag is enabled', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
|
||||
mockSearchParams();
|
||||
|
||||
await act(async () => renderComponent());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchCoursewareSearchSettings).toHaveBeenCalledTimes(1);
|
||||
expect(screen.queryByTestId('courseware-search-open-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Should dispatch setShowSearch(true) when clicking the search button', async () => {
|
||||
fetchCoursewareSearchSettings.mockImplementation(() => Promise.resolve({ enabled: true }));
|
||||
mockSearchParams();
|
||||
|
||||
await act(async () => renderComponent());
|
||||
const button = await screen.findByTestId('courseware-search-open-button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
||||
expect(setShowSearch).toHaveBeenCalledTimes(1);
|
||||
expect(setShowSearch).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -1,181 +0,0 @@
|
||||
.courseware-search {
|
||||
background: white;
|
||||
position: fixed;
|
||||
top: var(--modal-top-position, 0);
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--pgn-color-light-300);
|
||||
z-index: var(--pgn-elevation-modal-zindex); // Bootstrap's z-index layer for Modals.
|
||||
|
||||
&__form {
|
||||
position: relative;
|
||||
|
||||
.h2 {
|
||||
margin-right: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__close {
|
||||
position: absolute !important; // For some reason it gets overridden
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__outer-content {
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-top: 2rem;
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
max-width: 42rem;
|
||||
margin: auto;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__results-summary {
|
||||
font-size: .9rem;
|
||||
color: var(--pgn-color-gray-500);
|
||||
padding: 1rem 0 .5rem;
|
||||
}
|
||||
|
||||
&__spinner {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 20vh;
|
||||
}
|
||||
}
|
||||
|
||||
.courseware-search-results {
|
||||
margin-top: 1.5rem;
|
||||
|
||||
&__empty {
|
||||
color: var(--pgn-color-gray-500);
|
||||
padding: 6rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: block;
|
||||
padding: .75rem 1rem;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
gap: 0.625rem;
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
background: var(--pgn-color-light-300);
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
border-top: 1px solid var(--pgn-color-light-300);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
padding: 0.375rem 0 0 0.375rem;
|
||||
color: var(--pgn-color-gray-300);
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 2.5;
|
||||
font-size: 0.875rem;
|
||||
color: var(--pgn-color-black);
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
em {
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-variant-numeric: lining-nums tabular-nums;
|
||||
min-width: 1.25rem;
|
||||
line-height: 1rem;
|
||||
background: var(--pgn-color-light-300);
|
||||
border-radius: 99rem;
|
||||
font-style: normal;
|
||||
margin-left: 0.375rem;
|
||||
font-size: 0.6875rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
&__breadcrumbs {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
color: var(--pgn-color-gray-500);
|
||||
overflow: hidden;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
> li {
|
||||
position: relative;
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
|
||||
&:not(:first-child)::before {
|
||||
content: '›';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -55%);
|
||||
left: -0.625rem;
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.courseware-search-results-tabs {
|
||||
border-bottom-color: var(--pgn-color-gray-400) !important;
|
||||
|
||||
&.nav-tabs .nav-link.active {
|
||||
border-bottom-width: 4px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (--pgn-size-breakpoint-min-width-md) {
|
||||
.courseware-search {
|
||||
&__close {
|
||||
right: -2.5rem;
|
||||
}
|
||||
|
||||
&__content {
|
||||
padding-top: 8rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
body._search-no-scroll {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useState, useEffect, useLayoutEffect } from 'react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { debounce } from 'lodash';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
|
||||
const DEBOUNCE_WAIT = 100; // ms
|
||||
|
||||
export function useCoursewareSearchFeatureFlag() {
|
||||
const { courseId } = useParams();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCoursewareSearchSettings(courseId).then(response => setEnabled(response.enabled));
|
||||
}, [courseId]);
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function useCoursewareSearchState() {
|
||||
const enabled = useCoursewareSearchFeatureFlag();
|
||||
const show = useSelector(state => state.courseHome.showSearch);
|
||||
|
||||
return { show: enabled && show };
|
||||
}
|
||||
|
||||
export function useElementBoundingBox(elementId) {
|
||||
const [info, setInfo] = useState(undefined);
|
||||
|
||||
const element = document.getElementById(elementId);
|
||||
|
||||
if (!element) {
|
||||
console.warn(`useElementBoundingBox(): Unable to find element with id='${elementId}' in the document.`); // eslint-disable-line no-console
|
||||
return undefined;
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Handler to call on window resize and scroll
|
||||
function recalculate() {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
setInfo(bounds);
|
||||
}
|
||||
const debouncedRecalculate = debounce(recalculate, DEBOUNCE_WAIT, { leading: true });
|
||||
|
||||
// Add event listener
|
||||
global.addEventListener('resize', debouncedRecalculate);
|
||||
global.addEventListener('scroll', debouncedRecalculate);
|
||||
|
||||
// Call handler right away so state gets updated with initial window size
|
||||
debouncedRecalculate();
|
||||
|
||||
// Remove event listener on cleanup
|
||||
return () => {
|
||||
global.removeEventListener('resize', debouncedRecalculate);
|
||||
global.removeEventListener('scroll', debouncedRecalculate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
export function useLockScroll() {
|
||||
useLayoutEffect(() => {
|
||||
window.scrollTo(0, 0);
|
||||
document.body.classList.add('_search-no-scroll');
|
||||
|
||||
return () => {
|
||||
document.body.classList.remove('_search-no-scroll');
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
const initSearchParams = { q: '', f: '' };
|
||||
export function useCoursewareSearchParams() {
|
||||
const [searchParams, setSearchParams] = useSearchParams(initSearchParams);
|
||||
const clearSearchParams = () => setSearchParams(initSearchParams);
|
||||
|
||||
const query = searchParams.get('q');
|
||||
const filter = searchParams.get('f')?.toLowerCase();
|
||||
|
||||
const setQuery = (q) => setSearchParams((params) => ({ q, f: params.get('f') }));
|
||||
const setFilter = (f) => setSearchParams((params) => ({ q: params.get('q'), f }));
|
||||
|
||||
return {
|
||||
query, filter, setQuery, setFilter, clearSearchParams,
|
||||
};
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { fetchCoursewareSearchSettings } from '../data/thunks';
|
||||
import {
|
||||
useCoursewareSearchFeatureFlag,
|
||||
useCoursewareSearchParams,
|
||||
useCoursewareSearchState,
|
||||
useElementBoundingBox,
|
||||
useLockScroll,
|
||||
} from './hooks';
|
||||
|
||||
jest.mock('react-redux');
|
||||
jest.mock('react-router-dom');
|
||||
jest.mock('../data/thunks');
|
||||
|
||||
describe('CoursewareSearch Hooks', () => {
|
||||
const courses = {
|
||||
123: { enabled: true },
|
||||
456: { enabled: false },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchCoursewareSearchSettings.mockImplementation((courseId) => Promise.resolve(courses[courseId]));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('useCoursewareSearchFeatureFlag', () => {
|
||||
const renderTestHook = async (enabled = true) => {
|
||||
useParams.mockImplementation(() => ({ courseId: enabled ? 123 : 456 }));
|
||||
let hook;
|
||||
await act(async () => { (hook = renderHook(() => useCoursewareSearchFeatureFlag())); });
|
||||
return hook;
|
||||
};
|
||||
|
||||
it('should return true if feature is enabled', async () => {
|
||||
const hook = await renderTestHook();
|
||||
await waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
|
||||
expect(hook.result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false if feature is disabled', async () => {
|
||||
const hook = await renderTestHook(false);
|
||||
await waitFor(() => expect(fetchCoursewareSearchSettings).toBeCalledTimes(1));
|
||||
expect(hook.result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCoursewareSearchState', () => {
|
||||
const renderTestHook = async ({ enabled, showSearch }) => {
|
||||
useParams.mockImplementation(() => ({ courseId: enabled ? 123 : 456 }));
|
||||
const mockedStoreState = { courseHome: { showSearch } };
|
||||
useSelector.mockImplementation(selector => selector(mockedStoreState));
|
||||
|
||||
let hook;
|
||||
await act(async () => { (hook = renderHook(() => useCoursewareSearchState())); });
|
||||
return hook;
|
||||
};
|
||||
|
||||
it('should return show: true if feature is enabled and showSearch is true', async () => {
|
||||
const hook = await renderTestHook({ enabled: true, showSearch: true });
|
||||
|
||||
expect(hook.result.current).toEqual({ show: true });
|
||||
});
|
||||
|
||||
it('should return show: false in any other case', async () => {
|
||||
let hook;
|
||||
|
||||
hook = await renderTestHook({ enabled: true, showSearch: false });
|
||||
expect(hook.result.current).toEqual({ show: false });
|
||||
|
||||
hook = await renderTestHook({ enabled: false, showSearch: true });
|
||||
expect(hook.result.current).toEqual({ show: false });
|
||||
|
||||
hook = await renderTestHook({ enabled: false, showSearch: false });
|
||||
expect(hook.result.current).toEqual({ show: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('useElementBoundingBox', () => {
|
||||
let getBoundingClientRectSpy;
|
||||
const renderTestHook = async ({ elementId, mockedInfo }) => {
|
||||
getBoundingClientRectSpy = jest.spyOn(document, 'getElementById').mockImplementation(() => (
|
||||
mockedInfo
|
||||
? { getBoundingClientRect: () => ({ ...mockedInfo }) }
|
||||
: undefined
|
||||
));
|
||||
|
||||
let hook;
|
||||
await act(async () => {
|
||||
hook = renderHook(() => useElementBoundingBox(elementId));
|
||||
});
|
||||
|
||||
return hook;
|
||||
};
|
||||
|
||||
let addEventListenerSpy;
|
||||
let removeEventListenerSpy;
|
||||
beforeEach(() => {
|
||||
addEventListenerSpy = jest.spyOn(global, 'addEventListener');
|
||||
removeEventListenerSpy = jest.spyOn(global, 'removeEventListener');
|
||||
});
|
||||
|
||||
describe('when element is present', () => {
|
||||
const mockedInfo = { top: 128 };
|
||||
|
||||
it('should bind resize and scroll events on mount', async () => {
|
||||
await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything());
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything());
|
||||
});
|
||||
|
||||
it('should unbindbind resize and scroll events when unmounted', async () => {
|
||||
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
hook.unmount();
|
||||
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.anything());
|
||||
expect(removeEventListenerSpy).toHaveBeenCalledWith('scroll', expect.anything());
|
||||
});
|
||||
|
||||
it('should return the element bounding box', async () => {
|
||||
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
|
||||
await waitFor(() => expect(getBoundingClientRectSpy).toHaveBeenCalled());
|
||||
|
||||
expect(hook.result.current).toEqual(mockedInfo);
|
||||
});
|
||||
|
||||
it('should call getBoundingClientRect on window resize', async () => {
|
||||
const hook = await renderTestHook({ elementId: 'test', mockedInfo });
|
||||
|
||||
act(() => {
|
||||
// Trigger the window resize event.
|
||||
global.innerWidth = 500;
|
||||
global.dispatchEvent(new Event('resize'));
|
||||
});
|
||||
|
||||
expect(hook.result.current).toEqual(mockedInfo);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when element is NOT present', () => {
|
||||
let consoleWarnSpy;
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
it('should log a warning and return undefined', async () => {
|
||||
await renderTestHook({ elementId: 'happiness' });
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith("useElementBoundingBox(): Unable to find element with id='happiness' in the document.");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useLockScroll', () => {
|
||||
const renderTestHook = () => (
|
||||
renderHook(() => useLockScroll())
|
||||
);
|
||||
|
||||
let windowScrollSpy;
|
||||
let addBodyClassSpy;
|
||||
let removeBodyClassSpy;
|
||||
let hook;
|
||||
|
||||
beforeEach(() => {
|
||||
windowScrollSpy = jest.spyOn(window, 'scrollTo');
|
||||
addBodyClassSpy = jest.spyOn(document.body.classList, 'add');
|
||||
removeBodyClassSpy = jest.spyOn(document.body.classList, 'remove');
|
||||
hook = renderTestHook();
|
||||
});
|
||||
|
||||
it('should perform a scrollTo(0, 0) on mount', () => {
|
||||
expect(windowScrollSpy).toHaveBeenCalledWith(0, 0);
|
||||
});
|
||||
|
||||
it('should append a _search-no-scroll on mount to the document body', () => {
|
||||
expect(addBodyClassSpy).toHaveBeenCalledWith('_search-no-scroll');
|
||||
});
|
||||
|
||||
it('should remove the _search-no-scroll on unmount', () => {
|
||||
hook.unmount();
|
||||
|
||||
expect(removeBodyClassSpy).toHaveBeenCalledWith('_search-no-scroll');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSearchParams', () => {
|
||||
const initSearch = { q: '', f: '' };
|
||||
const q = { value: '' };
|
||||
const f = { value: '' };
|
||||
const mockedQuery = { q, f };
|
||||
const searchParams = { get: (prop) => mockedQuery[prop].value };
|
||||
const setSearchParams = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
useSearchParams.mockImplementation(() => [searchParams, setSearchParams]);
|
||||
});
|
||||
|
||||
it('should init the search params properly', () => {
|
||||
const {
|
||||
query, filter, setQuery, setFilter, clearSearchParams,
|
||||
} = useCoursewareSearchParams();
|
||||
|
||||
expect(useSearchParams).toBeCalledWith(initSearch);
|
||||
expect(query).toBe('');
|
||||
expect(filter).toBe('');
|
||||
|
||||
setQuery('setQuery');
|
||||
expect(setSearchParams).toBeCalledWith(expect.any(Function));
|
||||
|
||||
setFilter('setFilter');
|
||||
expect(setSearchParams).toBeCalledWith(expect.any(Function));
|
||||
|
||||
clearSearchParams();
|
||||
expect(setSearchParams).toBeCalledWith(initSearch);
|
||||
});
|
||||
|
||||
it('should return the query and lowercase filter if any', () => {
|
||||
q.value = '42';
|
||||
f.value = 'LOWERCASE';
|
||||
const { query, filter } = useCoursewareSearchParams();
|
||||
|
||||
expect(query).toBe('42');
|
||||
expect(filter).toBe('lowercase');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
export { default as CoursewareSearchToggle } from './CoursewareSearchToggle';
|
||||
export { default as CoursewareSearch } from './CoursewareSearch';
|
||||
@@ -1,117 +0,0 @@
|
||||
const Joi = require('joi');
|
||||
|
||||
const endpointSchema = Joi.object({
|
||||
took: Joi.number().required(),
|
||||
total: Joi.number().required(),
|
||||
maxScore: Joi.number().allow(null),
|
||||
results: Joi.array().items(Joi.object({
|
||||
id: Joi.string(),
|
||||
contentType: Joi.string(),
|
||||
location: Joi.array().items(Joi.string()),
|
||||
url: Joi.string(),
|
||||
content: Joi.object({
|
||||
displayName: Joi.string(),
|
||||
htmlContent: Joi.string(),
|
||||
transcriptEn: Joi.string(),
|
||||
}),
|
||||
}).unknown(true)).strict(),
|
||||
}).unknown(true).strict();
|
||||
|
||||
const defaultType = 'text';
|
||||
|
||||
// Parses the search results in a convenient way.
|
||||
export default function mapSearchResponse(response, searchKeywords = '') {
|
||||
const { error, value: data } = endpointSchema.validate(response);
|
||||
|
||||
if (error) {
|
||||
throw new Error('Error in server response:', error);
|
||||
}
|
||||
|
||||
const keywords = searchKeywords ? searchKeywords.toLowerCase().split(' ') : [];
|
||||
|
||||
const {
|
||||
took: ms,
|
||||
total,
|
||||
maxScore,
|
||||
results: rawResults,
|
||||
} = data;
|
||||
|
||||
const results = rawResults.map(result => {
|
||||
const {
|
||||
score,
|
||||
data: {
|
||||
id,
|
||||
content: {
|
||||
displayName,
|
||||
htmlContent,
|
||||
transcriptEn,
|
||||
},
|
||||
contentType,
|
||||
location,
|
||||
url,
|
||||
},
|
||||
} = result;
|
||||
|
||||
const type = contentType?.toLowerCase() || defaultType;
|
||||
|
||||
const content = htmlContent || transcriptEn || '';
|
||||
const searchContent = content.toLowerCase();
|
||||
let contentHits = 0;
|
||||
if (keywords.length) {
|
||||
keywords.forEach(word => {
|
||||
contentHits += searchContent ? searchContent.toLowerCase().split(word).length - 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
const title = displayName || contentType;
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
type,
|
||||
location,
|
||||
url,
|
||||
contentHits,
|
||||
score,
|
||||
};
|
||||
});
|
||||
|
||||
const filters = rawResults.reduce((list, result) => {
|
||||
const label = result?.data?.contentType;
|
||||
|
||||
if (!label) { return list; }
|
||||
|
||||
const key = label.toLowerCase();
|
||||
|
||||
const index = list.findIndex(i => i.key === key);
|
||||
|
||||
if (index === -1) {
|
||||
return [
|
||||
...list,
|
||||
{
|
||||
key,
|
||||
label,
|
||||
count: 1,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const newItem = { ...list[index] };
|
||||
newItem.count++;
|
||||
|
||||
const newList = list.slice(0);
|
||||
newList[index] = newItem;
|
||||
|
||||
return newList;
|
||||
}, []);
|
||||
|
||||
filters.sort((a, b) => (a.key > b.key ? 1 : -1));
|
||||
|
||||
return {
|
||||
results,
|
||||
filters,
|
||||
total,
|
||||
maxScore,
|
||||
ms,
|
||||
};
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import mapSearchResponse from './map-search-response';
|
||||
import mockedResponse from './test-data/mocked-response.json';
|
||||
|
||||
describe('mapSearchResponse', () => {
|
||||
describe('when the response is correct', () => {
|
||||
let response;
|
||||
|
||||
beforeEach(() => {
|
||||
response = mapSearchResponse(camelCaseObject(mockedResponse));
|
||||
});
|
||||
|
||||
it('should match number of results', () => {
|
||||
expect(response.results.length).toBe(mockedResponse.results.length);
|
||||
});
|
||||
|
||||
it('should match expected filters', () => {
|
||||
const expectedFilters = [
|
||||
{ key: 'capa', label: 'CAPA', count: 7 },
|
||||
{ key: 'sequence', label: 'Sequence', count: 2 },
|
||||
{ key: 'text', label: 'Text', count: 9 },
|
||||
{ key: 'unknown', label: 'Unknown', count: 1 },
|
||||
{ key: 'video', label: 'Video', count: 2 },
|
||||
];
|
||||
expect(response.filters).toEqual(expectedFilters);
|
||||
});
|
||||
|
||||
it('should match expected results', () => {
|
||||
const mockFirstResult = mockedResponse.results[0];
|
||||
const expectedFirstResult = {
|
||||
id: mockFirstResult.data.id,
|
||||
title: mockFirstResult.data.content.display_name,
|
||||
type: mockFirstResult.data.content_type.toLowerCase(),
|
||||
location: mockFirstResult.data.location,
|
||||
url: mockFirstResult.data.url,
|
||||
contentHits: 0,
|
||||
score: mockFirstResult.score,
|
||||
};
|
||||
expect(response.results[0]).toEqual(expectedFirstResult);
|
||||
});
|
||||
|
||||
it('should match expected ms and max score', () => {
|
||||
expect(response.maxScore).toBe(mockedResponse.max_score);
|
||||
expect(response.ms).toBe(mockedResponse.took);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the a keyword is provided', () => {
|
||||
const searchText = 'Course';
|
||||
|
||||
it('should not count matches title', () => {
|
||||
const response = mapSearchResponse(camelCaseObject(mockedResponse), searchText);
|
||||
expect(response.results[0].contentHits).toBe(0);
|
||||
});
|
||||
|
||||
it('should count matches on content', () => {
|
||||
const response = mapSearchResponse(camelCaseObject(mockedResponse), searchText);
|
||||
expect(response.results[1].contentHits).toBe(1);
|
||||
});
|
||||
|
||||
it('should ignore capitalization', () => {
|
||||
const response = mapSearchResponse(camelCaseObject(mockedResponse), searchText.toUpperCase());
|
||||
expect(response.results[1].contentHits).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when the response has a wrong format', () => {
|
||||
it('should throw an error', () => {
|
||||
expect(() => mapSearchResponse({ foo: 'bar' })).toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,88 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
searchOpenAction: {
|
||||
id: 'learn.coursewareSearch.openAction',
|
||||
defaultMessage: 'Search within this course',
|
||||
description: 'Aria-label for a button that will pop up Courseware Search.',
|
||||
},
|
||||
contentSearchButton: {
|
||||
id: 'learn.coursewareSearch.contentSearchButton',
|
||||
defaultMessage: 'Content search',
|
||||
description: 'Text for a button that will pop up Courseware Search.',
|
||||
},
|
||||
searchSubmitLabel: {
|
||||
id: 'learn.coursewareSearch.submitLabel',
|
||||
defaultMessage: 'Search',
|
||||
description: 'Button label that will submit Courseware Search.',
|
||||
},
|
||||
searchClearAction: {
|
||||
id: 'learn.coursewareSearch.clearAction',
|
||||
defaultMessage: 'Clear search',
|
||||
description: 'Button label that will the current Courseware Search input.',
|
||||
},
|
||||
searchCloseAction: {
|
||||
id: 'learn.coursewareSearch.closeAction',
|
||||
defaultMessage: 'Close the search form',
|
||||
description: 'Aria-label for a button that will close Courseware Search.',
|
||||
},
|
||||
searchModuleTitle: {
|
||||
id: 'learn.coursewareSearch.searchModuleTitle',
|
||||
defaultMessage: 'Search this course',
|
||||
description: 'Title for the Courseware Search module.',
|
||||
},
|
||||
searchBarPlaceholderText: {
|
||||
id: 'learn.coursewareSearch.searchBarPlaceholderText',
|
||||
defaultMessage: 'Search',
|
||||
description: 'Placeholder text for the Courseware Search input control',
|
||||
},
|
||||
loading: {
|
||||
id: 'learn.coursewareSearch.loading',
|
||||
defaultMessage: 'Searching...',
|
||||
description: 'Screen reader text to use on the spinner while the search is performing.',
|
||||
},
|
||||
searchResultsNone: {
|
||||
id: 'learn.coursewareSearch.searchResultsNone',
|
||||
defaultMessage: 'No results found.',
|
||||
description: 'Text to show when the Courseware Search found no results matching the criteria.',
|
||||
},
|
||||
searchResultsLabel: {
|
||||
id: 'learn.coursewareSearch.searchResultsLabel',
|
||||
defaultMessage: 'Results for "{keyword}":',
|
||||
description: 'Text to show above the search results response list.',
|
||||
},
|
||||
searchResultsError: {
|
||||
id: 'learn.coursewareSearch.searchResultsError',
|
||||
defaultMessage: 'There was an error on the search process. Please try again in a few minutes. If the problem persists, please contact the support team.',
|
||||
description: 'Error message to show to the users when there\'s an error with the endpoint or the returned payload format.',
|
||||
},
|
||||
|
||||
// These are translations for labeling the filters
|
||||
'filter:all': {
|
||||
id: 'learn.coursewareSearch.filter:all',
|
||||
defaultMessage: 'All content',
|
||||
description: 'Label for the search results filter that shows all content (no filter).',
|
||||
},
|
||||
'filter:text': {
|
||||
id: 'learn.coursewareSearch.filter:text',
|
||||
defaultMessage: 'Text',
|
||||
description: 'Label for the search results filter that shows results with text content.',
|
||||
},
|
||||
'filter:video': {
|
||||
id: 'learn.coursewareSearch.filter:video',
|
||||
defaultMessage: 'Video',
|
||||
description: 'Label for the search results filter that shows results with video content.',
|
||||
},
|
||||
'filter:sequence': {
|
||||
id: 'learn.coursewareSearch.filter:sequence',
|
||||
defaultMessage: 'Section',
|
||||
description: 'Label for the search results filter that shows results with section content.',
|
||||
},
|
||||
'filter:other': {
|
||||
id: 'learn.coursewareSearch.filter:other',
|
||||
defaultMessage: 'Other',
|
||||
description: 'Label for the search results filter that shows results with other content.',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -1,570 +0,0 @@
|
||||
{
|
||||
"took": 5,
|
||||
"total": 29,
|
||||
"max_score": 3.4545178,
|
||||
"results": [
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Demo Course Overview"
|
||||
},
|
||||
"content_type": "Sequence",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
|
||||
"start_date": "1970-01-01T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview"
|
||||
],
|
||||
"excerpt": "Demo <b>Course</b> Overview",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction"
|
||||
},
|
||||
"score": 3.4545178
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Passing a Course",
|
||||
"html_content": "Passing a COurse After the last assignment in a class has been due, you will see the entry in your student profile change to show progress toward generating your certificate. After the certificate generation process has completed, you will be able to download it from your profile page. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course"
|
||||
],
|
||||
"excerpt": "Passing a <b>Course</b><span class=\"search-results-ellipsis\"></span>Passing a <b>COurse</b> After the last assignment in a class has been due,",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@5e009378f0b64585baa0a14b155974b9"
|
||||
},
|
||||
"score": 3.4545178
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Passing a Course"
|
||||
},
|
||||
"content_type": "Sequence",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Passing a Course"
|
||||
],
|
||||
"excerpt": "Passing a <b>Course</b>",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff"
|
||||
},
|
||||
"score": 3.4545178
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Text Input",
|
||||
"capa_content": " Here's a very simple example of a text input question. Depending on the course you may have to observe special text requirements for dates, case sensitivity, etc. Which country contains Paris as its capital? "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"stringresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Text input"
|
||||
],
|
||||
"excerpt": "the <b>course</b> you may have to observe special text requirements for",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@0d759dee4f9d459c8956136dbde55f02"
|
||||
},
|
||||
"score": 1.5874016
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Pointing on a Picture",
|
||||
"capa_content": " Some course questions may show you an image and ask that you click on it to answer a question. Try this example. (If you are correct you will see our famous green check mark.) Which animal is a kitten? "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"imageresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Pointing on a Picture"
|
||||
],
|
||||
"excerpt": " Some <b>course</b> questions may show you an image and ask that you click on",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@c554538a57664fac80783b99d9d6da7c"
|
||||
},
|
||||
"score": 1.5499392
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Getting Answers",
|
||||
"capa_content": " In some courses a \"show answer\" button might appear below a question. When you click on this button, you can see the correct answer (with an explanation) that would receive full credit. How much does it cost to take an edX course? Enter the number of dollars. "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"numericalresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Getting Answers"
|
||||
],
|
||||
"excerpt": " In some <b>course</b>s a \"show answer\" button might appear below a question.",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@45d46192272c4f6db6b63586520bbdf4"
|
||||
},
|
||||
"score": 1.5003732
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Welcome!",
|
||||
"transcript_en": " ERIC: Hi, and welcome to the edX demonstration course. I'm Eric, and I'm here to help you get a better understanding of how fun and easy it is to take an edX course. So, let's get started. Let me show you how all the parts work together. If at any time you want to skip this video and get a firsthand experience of the demonstration course, all you have to do is click week one to the left. Don't worry, I won't be offended. Let's first look along the top of the page. This area's called the navigation bar. Click on Courseware to interact with your course. Course Info contains course announcements and updates from the course staff. If your course has digital textbooks, this is where you'll find them. Discussion is where you can communicate with the fellow students on topics and projects, and even occasionally with the course staff. When available, the course Wiki acts as a knowledge base for your course. It's a helpful resource. Clicking on Progress will reveal how well you're doing in your studies and exams. When you take the demo course, we'll provide you with a simple progress report matching your results. Let's look at the left column now. The left side of the Courseware screen contains a course navigation bar starting from the top down. Many courses start with an overview of edX and an introduction to the course. Below the overview are segments of the course, which are released as the course progresses. Typically, an edX course is delivered in week by week segments, and have lessons and homeworks you need to complete. Many courses are 10 to 12 weeks long. We made this demonstration course three weeks for simplicity. Let's now look at the learning sequence. Each item in the left column reveals a corresponding learning sequence. Work your way from left to right. Learning sequences can contain lectures, exercises, and interactive lessons that you can complete on your own schedule. Next, let's discover what makes edX fun and unique, its interactivity. edX prides itself on its interactive lessons, which can include demonstrations, visualizations, and virtual environments. You can try out some in the demo course. Interactive lessons are often graded and contribute to your final grade. While the edX platform also supports more traditional question formats like multiple choice, our classes also test your understanding by allowing you to use labs and simulators, and even asking you to write an essay. Example of these graded interactions are in the demo course. You can see how the questions the course uses for gauging your learning process can even be auto graded, or detailed feedback given in real time. So while an edX course might be rigorous, the tools and visualizations are really fun and truly interactive. Finally, there are many ways successful students like to you interact to get the most out of a course. Beyond the discussion forums, you can meet and engage with fellow classmates through a local meet up-- which we highly recommend-- a Google Hangout, or even invite students to join you via Twitter, Facebook, or other social networks. It's a proven fact that if you engage with others while taking a course, you're more likely to succeed. Now that you've seen how easy it is to take an edX course, experience this demonstration course. Firsthand all you have to do is click on week one to the left and you can get started. "
|
||||
},
|
||||
"content_type": "Video",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd",
|
||||
"start_date": "1970-01-01T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences"
|
||||
],
|
||||
"excerpt": " ERIC: Hi, and welcome to the edX demonstration <b>course</b>. I'm Eric, and",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@0b9e39477cf34507a7a48f74be381fdd"
|
||||
},
|
||||
"score": 1.4792063
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Multiple Choice Questions",
|
||||
"capa_content": " Many edX courses have homework or exercises you need to complete. Notice the clock image to the left? That means this homework or exercise needs to be completed for you to pass the course. (This can be a bit confusing; the exercise may or may not have a due date prior to the end of the course.) We\u2019ve provided eight (8) examples of how a professor might ask you questions. While the multiple choice question types below are somewhat standard, explore the other question types in the sequence above, like the formula builder- try them all out. As you go through the question types, notice how edX gives you immediate feedback on your responses - it really helps in the learning process. What color is the open ocean on a sunny day? 'yellow','blue','green' Which piece of furniture is built for sitting? a table a desk a chair a bookshelf Which of the following are musical instruments? a piano a tree a guitar a window "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"multiplechoiceresponse",
|
||||
"choiceresponse",
|
||||
"optionresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Multiple Choice Questions"
|
||||
],
|
||||
"excerpt": " Many edX <b>course</b>s have homework or exercises you need to complete.",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@a0effb954cca4759994f1ac9e9434bf4"
|
||||
},
|
||||
"score": 1.4341705
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Numerical Input",
|
||||
"capa_content": " Some course questions ask that you insert numbers into web-text fields, and your answers can be judged exactly - or approximately - according to the question. Note that the edX system uses a period to indicate decimals, so fifteen and three quarters is written \"15.75\", not \"15,75\". Enter the numerical value of Pi: Enter the approximate value of 502*9: Enter the number of fingernails on a healthy human hand. For the purposes of this question, please consider the thumb as a finger: "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"numericalresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Homework - Question Styles",
|
||||
"Numerical Input"
|
||||
],
|
||||
"excerpt": " Some <b>course</b> questions ask that you insert numbers into web-text",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@75f9562c77bc4858b61f907bb810d974"
|
||||
},
|
||||
"score": 1.2987298
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Connecting a Circuit and a Circuit Diagram",
|
||||
"transcript_en": "SPEAKER 1: What we see here-- a mess. OK? What we have is a voltmeter and an amp meter. The amp meter measures current, the voltmeter measures voltage of course. And we're measuring the voltage across the light bulb-- the lamp. And we're measuring the current into the lamp. PETER: So we've got a voltmeter measuring across the-- volts meter-- across there. And then we've got an amp reader measuring into. SPEAKER 1: Right. Exactly. Now, we're going to connect that to a battery. The three cell battery that you've seen before. And we're going to see, of course, that the light bulb lit up. And the current we measure is 122 milliamperes going into the light bulb. 122 milliamperes. And the voltage across is from the plus to minus, 4.31 volts. OK? Now, we can do another experiment. Notice how the light bulb is lit up and how much it's lit, approximately. Now, I'm going to reverse the battery so that we connect the battery in the opposite polarity. OK. Go ahead, Peter. PETER: You connect it, I will draw. SPEAKER 1: OK, I'm just doing it. PETER: So I'll swap these. SPEAKER 1: Yes, sir. OK. And what we observe is basically the amp meter is measuring 122 milliamperes in the negative direction. So that means it's measuring the current into the light bulb-- because I've not changed the orientation with respect to the light bulb-- of minus 122 milliamperes. And the voltage across the light bulb, from here to here, is minus 4.29 volts. PETER: Sorry, that's a minus? SPEAKER 1: Yes. PETER: So if we look at the power in the first case, 122 milliamps times 4.31 volts, we get 526 milliwatts. SPEAKER 1: Yep. PETER: If we measure the power in this case over here, minus 122 milliamps times minus 4.29 volts, we get approximately the same thing. So I'm going to round it off to, let's say best guess, 524, maybe 23 or something. No less. SPEAKER 1: OK. PETER: So this is equal to that within measurement error. SPEAKER 1: And of course, you see the power is the power going into the light bulb and coming out as light and heat. OK? We have arranged our measurements by having these associated reference directions, so that this is plus and that's minus, and that the current always goes into the terminal that we label with plus. That always means that the power we measure by multiplying these two numbers is the power going into this device. PETER: So this light bulb is dissipating 524 milliwatts. If we were to do the same calculation for the battery, so current would be going to the positive terminal, we would-- SPEAKER 1: Well, you have to measure it then from there to there. PETER: Yeah. Plus, minus. That's what we're doing. So this would be 4.29 volts. The current would still be minus 122 milliamps. The current's moving in a loop, so here is the same as here, but the signs are swapped. That same calculation would give us minus 524 milliwatts. And that's because the battery is outputting power, whereas the light bulb is dissipating power. SPEAKER 1: Think about it as, if we're measuring the power entering the battery, it's minus 524 milliwatts. OK? That's the way to think about it. This always gives you the power entering that element."
|
||||
},
|
||||
"content_type": "Video",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Video Presentation Styles"
|
||||
],
|
||||
"excerpt": "measures voltage of <b>course</b>. And we're measuring the voltage across the",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@video+block@636541acbae448d98ab484b028c9a7f6"
|
||||
},
|
||||
"score": 1.1870136
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "",
|
||||
"capa_content": " We are searching for the smallest monthly payment such that we can pay off the entire balance of a loan within a year. The following values might be useful when writing your solution Monthly interest rate = (Annual interest rate) / 12 Monthly payment lower bound = Balance / 12 Monthly payment upper bound = (Balance x (1 + Monthly interest rate)12) / 12 The following variables contain values as described below: balance - the outstanding balance on the credit card annualInterestRate - annual interest rate as a decimal Write a program that uses these bounds and bisection search (for more info check out the Wikipedia page on bisection search) to find the smallest monthly payment to the cent such that we can pay off the debt within a year. Note that if you do not use bisection search, your code will not run - your code only has 30 seconds to run on our servers. If you get a message that states \"Your submission could not be graded. Please recheck your submission and try again. If the problem persists, please notify the course staff.\", check to be sure your code doesn't take too long to run. The code you paste into the following box should not specify the values for the variables balance or annualInterestRate - our test code will define those values before testing your submission. monthlyInterestRate = annualInterestRate/12 lowerBound = balance/12 upperBound = (balance * (1+annualInterestRate/12)**12)/12 originalBalance = balance # Keep testing new payment values # until the balance is +/- $0.02 while abs(balance) > .02: # Reset the value of balance to its original value balance = originalBalance # Calculate a new monthly payment value from the bounds payment = (upperBound - lowerBound)/2 + lowerBound # Test if this payment value is sufficient to pay off the # entire balance in 12 months for month in range(12): balance -= payment balance *= 1+monthlyInterestRate # Reset bounds based on the final value of balance if balance > 0: # If the balance is too big, need higher payment # so we increase the lower bound lowerBound = payment else: # If the balance is too small, we need a lower # payment, so we decrease the upper bound upperBound = payment # When the while loop terminates, we know we have # our answer! print(\"Lowest Payment:\", round(payment, 2)) {\"grader\": \"ps02/bisect/grade_bisect.py\"} Note: Depending on where, and how frequently, you round during this function, your answers may be off a few cents in either direction. Try rounding as few times as possible in order to increase the accuracy of your result. Hints Test Cases to test your code with. Be sure to test these on your own machine - and that you get the same output! - before running your code on this webpage! Note: The automated tests are lenient - if your answers are off by a few cents in either direction, your code is OK. Test Cases: Test Case 1: balance = 320000 annualInterestRate = 0.2 Result Your Code Should Generate: ------------------- Lowest Payment: 29157.09 Test Case 2: balance = 999999 annualInterestRate = 0.18 Result Your Code Should Generate: ------------------- Lowest Payment: 90325.07 The autograder says, \"Your submission could not be graded.\" Help! If the autograder gives you the following message: Your submission could not be graded. Please recheck your submission and try again. If the problem persists, please notify the course staff. Don't panic! There are a few things that might be wrong with your code that you should check out. The number one reason this message appears is because your code timed out. You only get 30 seconds of computation time on our servers. If your code times out, you probably have an infinite loop. What to do? The number 1 thing to do is that you need to run this code in your own local environment. Your code should print one line at the end of the loop. If your code never prints anything out - you have an infinite loop! To debug your infinite loop - check your loop conditional. When will it stop? Try inserting print statements inside your loop that prints out information (like variables) - are you incrementing or decrementing your loop counter correctly? Search the forum for people with similar issues. If your search turns up nothing, make a new post and paste in your loop conditional for others to help you out with. Please don't email the course staff unless your code legitimately works and prints out the correct answers in your local environment. In that case, please email your code file, a screenshot of the code printing out the correct answers in your local environment, and a screenshot of the exact same code not working on the tutor. The course staff is otherwise unable to help debug your problem set via email - we can only address platform issues. "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"coderesponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 2: Get Interactive",
|
||||
"Homework - Labs and Demos",
|
||||
"Code Grader"
|
||||
],
|
||||
"excerpt": "notify the <b>course</b> staff.\", check to be sure your code doesn't take too",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@python_grader"
|
||||
},
|
||||
"score": 1.0107487
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Interactive Questions",
|
||||
"capa_content": " Most courses have interactive questions that test your knowledge like the one below. They can be part of a learning sequence or an exam. Notice the visual feedback. Go ahead, try it out! Questions which are part of assignments or exams may have due dates - the last possible time you can submit an assignment for grading. Once this time has passed, you will not be able to get credit for any incomplete problems in the assignment. If an assignment has a due date, you can see the due date in the sidebar. (This demo course does not have any assignments with due dates.) If no due date is displayed, the assignment can be turned in at any time. All assignment due dates are displayed in the time zone that you select in your account settings. If you do not specify a time zone, assignment due dates display in your browser's time zone. What kinds of late policies does edX allow? late penalties instructor forgiveness late time budget none of the above "
|
||||
},
|
||||
"content_type": "CAPA",
|
||||
"problem_types": [
|
||||
"choiceresponse"
|
||||
],
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"Interactive Questions"
|
||||
],
|
||||
"excerpt": " Most <b>course</b>s have interactive questions that test your knowledge like",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@problem+block@9cee77a606ea4c1aa5440e0ea5d0f618"
|
||||
},
|
||||
"score": 0.96387196
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Blank HTML Page",
|
||||
"html_content": "Welcome to the Open edX Demo Course Introduction. This is where you can explore how to take an edX course (like this one). Most courses have an \"intro\" video that shows you how it all works. You can watch the introduction video (below) or scroll though the course studies and assignments using the toolbar (above). Just for fun, we'll keep track of your work in this demo course, and show you your progress in the toolbar just like in a real course. Watch the overview video (below), then click on \"Example Week One\" in the left hand navigation to get started. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4",
|
||||
"start_date": "1970-01-01T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Introduction",
|
||||
"Demo Course Overview",
|
||||
"Introduction: Video and Sequences"
|
||||
],
|
||||
"excerpt": "Welcome to the Open edX Demo <b>Course</b> Introduction. This is where you",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4"
|
||||
},
|
||||
"score": 0.8844358
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Discussion Forums",
|
||||
"html_content": "Discussion FORUMS The discussion forum for each course is found at the top of the course page. You might come across a subset of the discussion forum inside the course (see below), where you can talk with fellow students about the course in context. Go ahead and be social! Make your first post in this demo course. Keep an eye out for posts with a green check mark. The green check means the post has been recognized by a staff member or forum moderator as a great post. You can also actively upvote a post. Others can search on user \u201cupvoted\u201d posts. They tend to be very helpful. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7",
|
||||
"start_date": "1978-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Discussion Forums"
|
||||
],
|
||||
"excerpt": "Discussion FORUMS The discussion forum for each <b>course</b> is found at the",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@html_49b4494da2f7"
|
||||
},
|
||||
"score": 0.8803684
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Overall Grade",
|
||||
"html_content": " OVERALL GRADE PERFORMANCE The progress tab (selectable near the top of each page in your course) shows your performance. Click on it now, and you will see how you're doing in this demo course. The bar chart shows the overall percentage that you have earned on each assignment in the course, and how each of those assignments combine into your overall grade. Further down the page is a detailed breakdown of your score on every graded question in the class. You might notice that some of your assignments on the bar chart show an 'x'. The 'x's indicate the assignments that the edX system will NOT be counting toward your final grade, according to the course grading. The 'x's go to the assignments that you scored the lowest on. Each course has its own percentage cutoff for a Certificate of Mastery. You can see where those cutoffs are by looking at the vertical description. In this demo, a \"pass\" is considered 60%. When you \"pass\" a live edX course, you will receive a certificate after the class has closed. Sorry - the demo course does not grant certificates! "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"Overall Grade Performance"
|
||||
],
|
||||
"excerpt": "of each page in your <b>course</b>) shows your performance. Click on it now,",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f4a39219742149f781a1dda6f43a623c"
|
||||
},
|
||||
"score": 0.87981963
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Blank HTML Page",
|
||||
"html_content": "Find Your Study Buddy Working with other students offline can help you get the most out of an online course and even increase the likelihood you will successfully complete the course. So, your homework is to find a study buddy. The course specific discussion forums are a great place to find neighbors or even new friends to invite to a Meetup you are looking to organize or even a virtual Google Hangout. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339",
|
||||
"start_date": "1978-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Homework - Find Your Study Buddy"
|
||||
],
|
||||
"excerpt": "get the most out of an online <b>course</b> and even increase the likelihood",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@87fa6792d79f4862be098e5169e93339"
|
||||
},
|
||||
"score": 0.84284115
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Find Your Study Buddy",
|
||||
"html_content": "Find Your Study Buddy Working with other students offline can help you get the most out of an online course and even increase the likelihood you will successfully complete the course. So, your homework is to find a study buddy. The course specific discussion forums are a great place to find neighbors or even new friends to invite to a Meetup you are looking to organize or even a virtual Google Hangout. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Homework - Find Your Study Buddy",
|
||||
"Homework - Find Your Study Buddy"
|
||||
],
|
||||
"excerpt": "get the most out of an online <b>course</b> and even increase the likelihood",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@6018785795994726950614ce7d0f38c5"
|
||||
},
|
||||
"score": 0.84284115
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "Be Social",
|
||||
"html_content": "Be SOCIAL A big part of learning online includes \u201cbeing social.\u201d We encourage all students to communicate within the course discussion forums \u2013 a great place to connect with other students and to get support from the course staff. Some students and professors also engage through other social mediums like Meetup or Facebook. Recent research has found that if you take a class with a friend, or engage socially with other learners while taking a course, there is a higher likelihood that you will complete a course. If you haven\u2019t already, consider finding a study buddy! Check out more information about the discussion forum by navigating to the next item in this learning sequence. In the discussion forums, remember to be polite and respectful. Simply put, treat others the way you want to be treated. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0",
|
||||
"start_date": "1978-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 3: Be Social",
|
||||
"Lesson 3 - Be Social",
|
||||
"Be Social"
|
||||
],
|
||||
"excerpt": "encourage all students to communicate within the <b>course</b> discussion",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@f9f3a25e7bab46e583fd1fbbd7a2f6a0"
|
||||
},
|
||||
"score": 0.84210813
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "EdX Exams",
|
||||
"html_content": " EDX EXAMS Not all edX courses have exams; many do, but not all. When choosing a course, it's a good idea to check the exam and study requirements, as well as any prerequisites. Of course - you can \"audit\" any edX course, which means you can study alongside other students using the same content, tools and materials, but you're not focused on grades and might skip the exams and assignments. Follow this learning sequence via the links above to understand more about how we grade your work and track your progress. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530",
|
||||
"start_date": "2013-02-05T00:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"About Exams and Certificates",
|
||||
"edX Exams",
|
||||
"EdX Exams"
|
||||
],
|
||||
"excerpt": " EDX EXAMS Not all edX <b>course</b>s have exams; many do, but not all. When",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@8293139743f34377817d537b69911530"
|
||||
},
|
||||
"score": 0.8306555
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
|
||||
"data": {
|
||||
"course": "course-v1:edX+DemoX+Demo_Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "When Are Your Exams? ",
|
||||
"html_content": "WHEN ARE YOUR Exams? Every course treats the timing on its exams differently, and you should be really careful to pay attention to any announcements about exam timing that your course makes. "
|
||||
},
|
||||
"content_type": "Text",
|
||||
"id": "block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": [
|
||||
"Example Week 1: Getting Started",
|
||||
"Lesson 1 - Getting Started",
|
||||
"When Are Your Exams? "
|
||||
],
|
||||
"excerpt": "WHEN ARE YOUR Exams? Every <b>course</b> treats the timing on its exams",
|
||||
"url": "/courses/course-v1:edX+DemoX+Demo_Course/jump_to/block-v1:edX+DemoX+Demo_Course+type@html+block@9d5104b502f24ee89c3d2f4ce9d347cf"
|
||||
},
|
||||
"score": 0.82610154
|
||||
},
|
||||
{
|
||||
"_index": "courseware_content",
|
||||
"_type": "_doc",
|
||||
"_id": "some-external-reference",
|
||||
"data": {
|
||||
"course": "External Course",
|
||||
"org": "edX",
|
||||
"content": {
|
||||
"display_name": "External Course Link Test",
|
||||
"html_content": "This should open a new tab when following the link."
|
||||
},
|
||||
"content_type": "Unknown",
|
||||
"id": "random-element-id",
|
||||
"start_date": "2013-02-05T05:00:00+00:00",
|
||||
"content_groups": null,
|
||||
"course_name": "Demonstration Course",
|
||||
"location": null,
|
||||
"excerpt": "Just testing external links",
|
||||
"url": "https://www.edx.org"
|
||||
},
|
||||
"score": 0.82610154
|
||||
}
|
||||
],
|
||||
"access_denied_count": 0
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import mockedData from './mocked-response.json';
|
||||
import mapSearchResponse from '../map-search-response';
|
||||
|
||||
function searchResultsFactory(searchKeywords = '', moreInfo = {}) {
|
||||
const data = camelCaseObject(mockedData);
|
||||
const info = mapSearchResponse(data, searchKeywords);
|
||||
|
||||
const result = {
|
||||
...info,
|
||||
...moreInfo,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export default searchResultsFactory;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user