Compare commits

..

2 Commits

Author SHA1 Message Date
Régis Behmo
85f943b1e4 Merge pull request #516 from Dicey-Tech/bbrsofiane/maple-upgrade-deps
chore: upgrade versions of frontend-build and frontend-component-header
2021-12-20 17:52:58 +01:00
Sofiane Bebert
c81802c8e8 chore: upgrade versions of frontend-build and frontend-component-header 2021-12-20 16:45:47 +00:00
94 changed files with 20794 additions and 40020 deletions

11
.env
View File

@@ -10,8 +10,6 @@ LOGIN_URL=''
LOGOUT_URL=''
MARKETING_SITE_BASE_URL=''
ORDER_HISTORY_URL=''
ACCOUNT_SETTINGS_URL=''
ACCOUNT_PROFILE_URL=''
REFRESH_ACCESS_TOKEN_ENDPOINT=''
SEGMENT_KEY=''
SITE_NAME=''
@@ -24,10 +22,5 @@ LOGO_URL=''
LOGO_TRADEMARK_URL=''
LOGO_WHITE_URL=''
FAVICON_URL=''
COLLECT_YEAR_OF_BIRTH=true
APP_ID=''
MFE_CONFIG_API_URL=''
SEARCH_CATALOG_URL=''
ENABLE_SKILLS_BUILDER_PROFILE=''
# Fallback in local style files
PARAGON_THEME_URLS={}
ENABLE_LEARNER_RECORD_MFE=''
LEARNER_RECORD_MFE_BASE_URL=''

View File

@@ -3,9 +3,7 @@ PORT=1995
ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload'
BASE_URL='localhost:1995'
CREDENTIALS_BASE_URL='http://localhost:18150'
ACCOUNT_SETTINGS_URL=http://localhost:1997
CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ACCOUNT_PROFILE_URL=http://localhost:1995
ECOMMERCE_BASE_URL='http://localhost:18130'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
@@ -25,10 +23,5 @@ LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
COLLECT_YEAR_OF_BIRTH=true
APP_ID=''
MFE_CONFIG_API_URL=''
SEARCH_CATALOG_URL='http://localhost:18000/courses'
ENABLE_SKILLS_BUILDER_PROFILE=''
# Fallback in local style files
PARAGON_THEME_URLS={}
ENABLE_LEARNER_RECORD_MFE=''
LEARNER_RECORD_MFE_BASE_URL='http://localhost:1990'

View File

@@ -5,8 +5,6 @@ CSRF_TOKEN_API_PATH='/csrf/api/v1/token'
ECOMMERCE_BASE_URL='http://localhost:18130'
LANGUAGE_PREFERENCE_COOKIE_NAME='openedx-language-preference'
LMS_BASE_URL='http://localhost:18000'
ACCOUNT_SETTINGS_URL='http://localhost:1997'
ACCOUNT_PROFILE_URL='http://localhost:1995'
LOGIN_URL='http://localhost:18000/login'
LOGOUT_URL='http://localhost:18000/logout'
MARKETING_SITE_BASE_URL='http://localhost:18000'
@@ -20,9 +18,4 @@ LOGO_TRADEMARK_URL=https://edx-cdn.org/v3/default/logo-trademark.svg
LOGO_WHITE_URL=https://edx-cdn.org/v3/default/logo-white.svg
FAVICON_URL=https://edx-cdn.org/v3/default/favicon.ico
ENABLE_LEARNER_RECORD_MFE=''
ENABLE_SKILLS_BUILDER_PROFILE=''
LEARNER_RECORD_MFE_BASE_URL='http://localhost:1990'
COLLECT_YEAR_OF_BIRTH=true
APP_ID=''
MFE_CONFIG_API_URL=''
PARAGON_THEME_URLS={}

View File

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

2
.github/CODEOWNERS vendored
View File

@@ -1 +1 @@
* @openedx/2U-infinity
* @edx/community-engineering

View File

@@ -1,7 +0,0 @@
version: 2
updates:
# Adding new check for github-actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

View File

@@ -1,24 +0,0 @@
### Description
Include a description of your changes here, along with a link to any relevant Jira tickets and/or GitHub issues.
#### How Has This Been Tested?
Please describe in detail how you tested your changes.
#### Screenshots/sandbox (optional):
Include a link to the sandbox for design changes or screenshot for before and after. **Remove this section if it's not applicable.**
|Before|After|
|-------|-----|
| | |
#### Merge Checklist
* [ ] If your update includes visual changes, have they been reviewed by a designer? Send them a link to the Sandbox, if applicable.
* [ ] Is there adequate test coverage for your changes?
#### Post-merge Checklist
* [ ] Deploy the changes to prod after verifying on stage or ask **@openedx/2u-infinity** to do it.
* [ ] 🎉 🙌 Celebrate! Thanks for your contribution.

View File

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

View File

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

View File

@@ -1,3 +1,4 @@
---
name: ci
on:
push:
@@ -5,25 +6,27 @@ on:
- master
pull_request:
jobs:
tests:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version:
- 12
npm-test:
- i18n_extract
- is-es5
- lint
- test
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version-file: '.nvmrc'
node-version: ${{ matrix.node-version }}
- run: npm install -g npm@6
- run: make requirements
- run: make test NPM_TESTS=build
- run: make test NPM_TESTS=${{ matrix.npm-test }}
- name: Coverage
if: ${{ matrix.npm-test == 'test' }}
uses: codecov/codecov-action@v4
- name: upload coverage
uses: codecov/codecov-action@v2
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
fail_ci_if_error: false

View File

@@ -7,4 +7,4 @@ on:
jobs:
commitlint:
uses: openedx/.github/.github/workflows/commitlint.yml@master
uses: edx/.github/.github/workflows/commitlint.yml@master

View File

@@ -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

View File

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

View File

@@ -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 }}

1
.gitignore vendored
View File

@@ -17,4 +17,3 @@ temp/babel-plugin-react-intl
/temp
/.vscode
/module.config.js
src/i18n/messages

1
.nvmrc
View File

@@ -1 +0,0 @@
20

8
.tx/config Normal file
View File

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

37
Makefile Normal file → Executable file
View File

@@ -1,11 +1,16 @@
intl_imports = ./node_modules/.bin/intl-imports.js
transifex_resource = frontend-app-profile
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
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-formatjs
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/
NPM_TESTS=build i18n_extract lint test
# This directory must match .babelrc .
transifex_temp = ./temp/babel-plugin-react-intl
NPM_TESTS=build i18n_extract lint test is-es5
.PHONY: test
test: $(addprefix test.npm.,$(NPM_TESTS)) ## validate ci suite
@@ -35,18 +40,20 @@ detect_changed_source_translations:
# Checking for changed translations...
git diff --exit-code $(i18n)
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-app-profile/src/i18n/messages:frontend-app-profile
# 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)
$(intl_imports) frontend-platform paragon frontend-component-header frontend-component-footer frontend-app-profile
# Pulls translations from Transifex.
pull_translations:
tx pull -f --mode reviewed --language=$(transifex_langs)
# This target is used by Travis.
validate-no-uncommitted-package-lock-changes:

View File

@@ -1,153 +1,57 @@
#####################
|Build Status| |Codecov| |license|
frontend-app-profile
#####################
====================
|license-badge| |status-badge| |ci-badge| |codecov-badge|
.. |license-badge| image:: https://img.shields.io/github/license/openedx/frontend-app-profile.svg
:target: https://github.com/openedx/frontend-app-profile/blob/main/LICENSE
:alt: License
.. |status-badge| image:: https://img.shields.io/badge/Status-Maintained-brightgreen
.. |ci-badge| image:: https://github.com/openedx/frontend-app-profile/actions/workflows/ci.yml/badge.svg
:target: https://github.com/openedx/frontend-app-profile/actions/workflows/ci.yml
:alt: Continuous Integration
.. |codecov-badge| image:: https://codecov.io/github/openedx/frontend-app-profile/coverage.svg?branch=main
:target: https://codecov.io/github/openedx/frontend-app-profile?branch=main
:alt: Codecov
********
Purpose
********
This is a micro-frontend application responsible for the display and updating of user profiles.
This is a micro-frontend application responsible for the display and updating of user profiles. Please tag **@edx/arch-fed** on any PRs or issues.
When a user views their own profile, they're given fields to edit their full name, location, primary spoken language, education, social links, and bio. Each field also has a dropdown to select the visibility of that field - i.e., whether it can be viewed by other learners.
When a user views someone else's profile, they see all those fields that that user set as public.
***************
Getting Started
***************
----------
Installation
============
Development
-----------
Follow these steps to provision, run, and enable an instance of the
Profile MFE for local development via the `devstack`_.
Start Devstack
^^^^^^^^^^^^^^
.. _devstack: https://github.com/openedx/devstack#getting-started
To use this application `devstack <https://github.com/edx/devstack>`__ must be running and you must be logged into it.
#. To use this application, `devstack <https://github.com/openedx/devstack>`__ must be running and you must be logged into it.
- Start devstack
- Log in (http://localhost:18000/login)
* Start devstack
* Log in (http://localhost:18000/login)
Start the development server
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
#. To run Profile, install requirements and start the development server by running:
In this project, install requirements and start the development server by running:
.. code-block::
.. code:: bash
1. Clone your new repo:
npm install
npm start # The server will run on port 1995
``git clone https://github.com/openedx/frontend-app-profile.git``
Once the dev server is up visit http://localhost:1995/u/staff.
2. Use node v18.x.
----------
The current version of the micro-frontend build scripts support node 18.
Using other major versions of node *may* work, but this is unsupported. For
convenience, this repository includes an .nvmrc file to help in setting the
correct node version via `nvm <https://github.com/nvm-sh/nvm>`_.
3. Install npm dependencies:
``cd frontend-app-profile && npm ci``
4. Start the dev server:
``npm start``
The server will run on port 1995
Once the dev server is up, visit http://localhost:1995/u/staff.
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>`_.
Configuration
=============
Configuration and Deployment
----------------------------
This MFE is configured via node environment variables supplied at build time. See the .env file for the list of required environment variables. Example build syntax with a single environment variable:
.. code-block::
.. code:: bash
NODE_ENV=production ACCESS_TOKEN_COOKIE_NAME='edx-jwt-cookie-header-payload' npm run build
Getting Help
============
If you're having trouble, we have discussion forums at
https://discuss.openedx.org where you can connect with others in the community.
For more information see the document: `Micro-frontend applications in Open
edX <https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/micro-frontends-in-open-edx.html>`__.
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. Please tag **@openedx/2u-infinity** on any PRs or issues.
https://github.com/openedx/frontend-app-profile/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/getting-help
License
=======
The code in this repository is licensed under the AGPLv3 unless otherwise
noted.
Please see `LICENSE <LICENSE>`_ for details.
Contributing
============
Contributions are very welcome. Please read `How To Contribute`_ for details.
.. _How To Contribute: https://openedx.org/r/how-to-contribute
This project is currently accepting all types of contributions, bug fixes,
security fixes, maintenance work, or new features. However, please make sure
to have a discussion about your new feature idea with the maintainers prior to
beginning development to maximize the chances of your change being accepted.
You can start a conversation by creating a new issue on this repo summarizing
your idea.
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/
People
======
The assigned maintainers for this component and other project details may be
found in `Backstage`_. Backstage pulls this data from the ``catalog-info.yaml``
file in this repo.
.. _Backstage: https://backstage.herokuapp.com/catalog/default/component/frontend-app-profile
Reporting Security Issues
=========================
Please do not report security issues in public. Email security@openedx.org instead.
.. |Build Status| image:: https://api.travis-ci.org/edx/frontend-app-profile.svg?branch=master
:target: https://travis-ci.org/edx/frontend-app-profile
.. |Codecov| image:: https://img.shields.io/codecov/c/github/edx/frontend-app-profile
:target: https://codecov.io/gh/edx/frontend-app-profile
.. |license| image:: https://img.shields.io/npm/l/@edx/frontend-app-profile.svg
:target: @edx/frontend-app-profile

View File

@@ -1,25 +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-profile'
description: 'This is a micro-frontend application responsible for displaying and updating the user profiles.'
links:
- url: 'https://github.com/openedx/frontend-app-profile/blob/master/README.rst'
title: 'Documentation'
icon: 'Article'
annotations:
# (Optional) Annotation keys and values can be whatever you want.
# We use it in Open edX repos to have a comma-separated list of GitHub user
# names that might be interested in changes to the architecture of this
# component.
openedx.org/arch-interest-groups: ""
# This can be multiple comma-separated projects.
openedx.org/add-to-projects: "openedx:23"
openedx.org/release: "master"
spec:
owner: group:2u-infinity
type: 'service'
lifecycle: 'production'
# (Optional) An array of different components or resources.

View File

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

6
openedx.yaml Normal file
View File

@@ -0,0 +1,6 @@
# 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
nick: prof
oeps: {}
openedx-release: {ref: master}

45192
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,74 +6,71 @@
"license": "AGPL-3.0",
"repository": {
"type": "git",
"url": "git+https://github.com/openedx/frontend-app-profile.git"
"url": "git+https://github.com/edx/frontend-app-profile.git"
},
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"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",
"dev": "PUBLIC_PATH=/profile/ MFE_CONFIG_API_URL='http://localhost:8000/api/mfe_config/v1' fedx-scripts webpack-dev-server --progress --host apps.local.openedx.io",
"test": "TZ=UTC fedx-scripts jest --coverage --passWithNoTests",
"stubs": "pact-stub-service ./src/pacts/frontend-app-profile-edx-platform.json --port 18000"
"test": "fedx-scripts jest --coverage --passWithNoTests"
},
"bugs": {
"url": "https://github.com/openedx/frontend-app-profile/issues"
"url": "https://github.com/edx/frontend-app-profile/issues"
},
"homepage": "https://github.com/openedx/frontend-app-profile#readme",
"homepage": "https://github.com/edx/frontend-app-profile#readme",
"publishConfig": {
"access": "public"
},
"browserslist": [
"extends @edx/browserslist-config"
"last 2 versions",
"ie 11"
],
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@edx/frontend-component-footer": "^14.6.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-platform": "^8.3.1",
"@edx/openedx-atlas": "^0.7.0",
"@fortawesome/fontawesome-svg-core": "6.7.2",
"@fortawesome/free-brands-svg-icons": "6.7.2",
"@fortawesome/free-regular-svg-icons": "6.7.2",
"@fortawesome/free-solid-svg-icons": "6.7.2",
"@fortawesome/react-fontawesome": "0.2.2",
"@openedx/paragon": "^23.4.5",
"@pact-foundation/pact": "^11.0.2",
"@redux-devtools/extension": "3.3.0",
"classnames": "2.5.1",
"core-js": "3.43.0",
"history": "5.3.0",
"@edx/brand": "npm:@edx/brand-openedx@1.1.0",
"@edx/frontend-component-footer": "10.1.6",
"@edx/frontend-component-header": "2.4.3",
"@edx/frontend-platform": "1.12.7",
"@edx/paragon": "16.6.1",
"@fortawesome/fontawesome-svg-core": "1.2.36",
"@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.15",
"classnames": "2.3.1",
"core-js": "3.18.2",
"lodash.camelcase": "4.3.0",
"lodash.get": "4.4.2",
"lodash.pick": "4.4.0",
"lodash.snakecase": "4.1.1",
"prop-types": "15.8.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-helmet": "6.1.0",
"react-redux": "7.2.9",
"react-router": "6.30.1",
"react-router-dom": "6.30.1",
"redux": "4.2.1",
"prop-types": "15.7.2",
"react": "16.14.0",
"react-dom": "16.14.0",
"react-redux": "7.2.5",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"redux": "4.1.1",
"redux-devtools-extension": "2.13.9",
"redux-logger": "3.0.6",
"redux-saga": "1.3.0",
"redux-thunk": "2.4.2",
"regenerator-runtime": "0.14.1",
"reselect": "5.1.1",
"universal-cookie": "4.0.4"
"redux-saga": "1.1.3",
"redux-thunk": "2.3.0",
"regenerator-runtime": "0.13.9",
"reselect": "4.0.0",
"universal-cookie": "3.1.0"
},
"devDependencies": {
"@commitlint/cli": "19.8.1",
"@commitlint/config-angular": "19.8.1",
"@edx/browserslist-config": "^1.1.1",
"@edx/reactifex": "2.2.0",
"@openedx/frontend-build": "^14.3.3",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "14.3.1",
"glob": "11.0.3",
"@commitlint/cli": "13.2.1",
"@commitlint/config-angular": "13.2.0",
"@edx/frontend-build": "9.0.5",
"codecov": "3.8.3",
"enzyme": "3.11.0",
"enzyme-adapter-react-16": "1.15.6",
"es-check": "5.2.4",
"glob": "7.2.0",
"react-test-renderer": "16.14.0",
"reactifex": "1.1.1",
"redux-mock-store": "1.5.5"
"redux-mock-store": "1.5.4"
}
}

View File

@@ -22,11 +22,6 @@
"pin"
],
"automerge": true
},
{
"matchPackagePatterns": ["@edx", "@openedx"],
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
}
],
"timezone": "America/New_York"

View File

@@ -1,7 +1,7 @@
import { getConfig } from '@edx/frontend-platform';
import { applyMiddleware, createStore, compose } from 'redux';
import thunkMiddleware from 'redux-thunk';
import { composeWithDevTools } from '@redux-devtools/extension';
import { composeWithDevTools } from 'redux-devtools-extension';
import { createLogger } from 'redux-logger';
import createSagaMiddleware from 'redux-saga';

View File

@@ -1,21 +0,0 @@
import React from 'react';
import { Helmet } from 'react-helmet';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
const Head = ({ intl }) => (
<Helmet>
<title>
{intl.formatMessage(messages['profile.page.title'], { siteName: getConfig().SITE_NAME })}
</title>
<link rel="shortcut icon" href={getConfig().FAVICON_URL} type="image/x-icon" />
</Helmet>
);
Head.propTypes = {
intl: intlShape.isRequired,
};
export default injectIntl(Head);

View File

@@ -1,17 +0,0 @@
import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { Helmet } from 'react-helmet';
import { render } from '@testing-library/react';
import { getConfig } from '@edx/frontend-platform';
import Head from './Head';
describe('Head', () => {
const props = {};
it('should match render title tag and favicon with the site configuration values', () => {
render(<IntlProvider locale="en"><Head {...props} /></IntlProvider>);
const helmet = Helmet.peek();
expect(helmet.title).toEqual(`Profile | ${getConfig().SITE_NAME}`);
expect(helmet.linkTags[0].rel).toEqual('shortcut icon');
expect(helmet.linkTags[0].href).toEqual(getConfig().FAVICON_URL);
});
});

View File

@@ -1,11 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.page.title': {
id: 'profile.page.title',
defaultMessage: 'Profile | {siteName}',
description: 'Title tag',
},
});
export default messages;

View File

@@ -1 +0,0 @@
export default [];

32
src/i18n/index.jsx Normal file
View File

@@ -0,0 +1,32 @@
import arMessages from './messages/ar.json';
import caMessages from './messages/ca.json';
// no need to import en messages-- they are in the defaultMessage field
import es419Messages from './messages/es_419.json';
import frMessages from './messages/fr.json';
import zhcnMessages from './messages/zh_CN.json';
import heMessages from './messages/he.json';
import idMessages from './messages/id.json';
import kokrMessages from './messages/ko_kr.json';
import plMessages from './messages/pl.json';
import ptbrMessages from './messages/pt_br.json';
import ruMessages from './messages/ru.json';
import thMessages from './messages/th.json';
import ukMessages from './messages/uk.json';
const messages = {
ar: arMessages,
'es-419': es419Messages,
fr: frMessages,
'zh-cn': zhcnMessages,
ca: caMessages,
he: heMessages,
id: idMessages,
'ko-kr': kokrMessages,
pl: plMessages,
'pt-br': ptbrMessages,
ru: ruMessages,
th: thMessages,
uk: ukMessages,
};
export default messages;

51
src/i18n/messages/ar.json Normal file
View File

@@ -0,0 +1,51 @@
{
"profile.age.headline": "لا يمكن مشاركة ملفك الشخصي",
"profile.age.details": "لمشاركة ملفك الشخصي مع متعلمي edX الآخرين يجب التحقق من أن يكون عمرك أكثر من ١٣ سنة",
"profile.age.set.date": "اضبط تاريخ ميلاك",
"profile.datejoined.member.since": "عضو منذُ {year}",
"profile.bio.empty": "أضف نبذة قصيرة",
"profile.bio.about.me": "نبذة عنّي",
"profile.certificate.organization.label": "من",
"profile.certificate.completion.date.label": "مكتمل في",
"profile.no.certificates": "لم تحصل على أية شهادات حتى الآن.",
"profile.certificates.my.certificates": "شهاداتي",
"profile.certificates.view.certificate": "معاينة الشهادة",
"profile.certificates.types.verified": "شهادة موثقة",
"profile.certificates.types.professional": "شهادة مهنية",
"profile.certificates.types.unknown": "شهادة",
"profile.country.label": "الموقع",
"profile.country.empty": "أضف موقعًا",
"profile.education.empty": "أضف مؤهلًا تعليميًا",
"profile.education.education": "المستوى التعليمي",
"profile.education.levels.p": "شهادة دكتوراه",
"profile.education.levels.m": "ماجستير أو شهادة مهنيّة",
"profile.education.levels.b": "درجة البكالوريوس",
"profile.education.levels.a": "درجة الزمالة",
"profile.education.levels.hs": "شهادة الثانوية العامة",
"profile.education.levels.jhs": "شهادة الثانوية الصغرى/الإعدادية/المرحلة المتوسّطة",
"profile.education.levels.el": "شهادة المدرسة الابتدائية",
"profile.education.levels.none": "لا يوجد تعليم رسمي",
"profile.education.levels.o": "نوع آخر من التعليم",
"profile.editbutton.edit": "تحرير",
"profile.formcontrols.who.can.see": "من يمكنه مشاهدة هذا:",
"profile.formcontrols.button.cancel": "إلغاء",
"profile.formcontrols.button.save": "حفظ",
"profile.formcontrols.button.saving": "جاري الحفظ",
"profile.formcontrols.button.saved": "تم الحفظ",
"profile.visibility.who.just.me": "أنا فقط",
"profile.visibility.who.everyone": "جميع أعضاء edX",
"profile.name.full.name": "الاسم الكامل",
"profile.name.details": "هذا هو الاسم الذي سيظهر في حسابك وفي شهاداتك",
"profile.name.empty": "إضافة اسم",
"profile.preferredlanguage.empty": "إضافة اللغة",
"profile.preferredlanguage.label": "لغة التحدث الأم",
"profile.profileavatar.upload-button": "تحميل صورة",
"profile.profileavatar.remove.button": "حذف",
"profile.image.alt.attribute": "صورة عرض الملف الشخصي",
"profile.profileavatar.change-button": "تغيير",
"profile.sociallinks.add": "إضافة {network}",
"profile.sociallinks.social.links": "روابط قنوات التواصل الاجتماعي",
"profile.notfound.message": "الصفحة التي تبحث عنها غير متوفرة أو هناك خطأ في نص الرابط. الرجاء التحقق من الرابط والمحاولة مجددا.",
"profile.viewMyRecords": "عرض سجلّاتي",
"profile.loading": "جاري تحميل الملف الشخصي ..."
}

View File

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

View File

@@ -0,0 +1,51 @@
{
"profile.age.headline": "Tu perfil no puede ser compartido.",
"profile.age.details": "Para compartir tu perfil con otros estudiantes de edX, debes confirmar que tienes más de 13 años.",
"profile.age.set.date": "Establece tu fecha de nacimiento",
"profile.datejoined.member.since": "Miembro desde {year}",
"profile.bio.empty": "Añade una breve biografía",
"profile.bio.about.me": "Sobre Mí",
"profile.certificate.organization.label": "Desde",
"profile.certificate.completion.date.label": "Completado el {date}",
"profile.no.certificates": "Todavía no ha obtenido ningún certificado.",
"profile.certificates.my.certificates": "Mis Certificados",
"profile.certificates.view.certificate": "Ver Certificado",
"profile.certificates.types.verified": "Certificado verificado",
"profile.certificates.types.professional": "Certificado profesional",
"profile.certificates.types.unknown": "Certificado",
"profile.country.label": "Ubicación",
"profile.country.empty": "Añade ubicación",
"profile.education.empty": "Añade Educación",
"profile.education.education": "Educación",
"profile.education.levels.p": "Doctorado",
"profile.education.levels.m": "Master o magíster",
"profile.education.levels.b": "Pregrado o Licenciatura",
"profile.education.levels.a": "Grado técnico - tecnológico",
"profile.education.levels.hs": "Enseñanza secundaria",
"profile.education.levels.jhs": "Formación media",
"profile.education.levels.el": "Enseñanza primaria",
"profile.education.levels.none": "Ninguna educación formal",
"profile.education.levels.o": "Otra educación",
"profile.editbutton.edit": "Editar",
"profile.formcontrols.who.can.see": "Quién puede ver esto:",
"profile.formcontrols.button.cancel": "Cancelar",
"profile.formcontrols.button.save": "Guardar",
"profile.formcontrols.button.saving": "Guardando",
"profile.formcontrols.button.saved": "Guardado",
"profile.visibility.who.just.me": "Solo yo",
"profile.visibility.who.everyone": "Todos en edX",
"profile.name.full.name": "Nombre completo",
"profile.name.details": "Este es el nombre que aparecerá en tu cuenta y en tus certificados.",
"profile.name.empty": "Añade nombre",
"profile.preferredlanguage.empty": "Añadir idioma",
"profile.preferredlanguage.label": "Idioma principal que hablas",
"profile.profileavatar.upload-button": "Subir foto",
"profile.profileavatar.remove.button": "Eliminar",
"profile.image.alt.attribute": "avatar del perfil",
"profile.profileavatar.change-button": "Cambiar",
"profile.sociallinks.add": "Añade {network}",
"profile.sociallinks.social.links": "Enlaces De Redes Sociales",
"profile.notfound.message": "La página que estas buscando no está disponible o hay un error en la URL. Por favor, comprueba la URL y vuelve a intentarlo.",
"profile.viewMyRecords": "Ver mis registros",
"profile.loading": "Cargando perfil..."
}

51
src/i18n/messages/fr.json Normal file
View File

@@ -0,0 +1,51 @@
{
"profile.age.headline": "Votre profil ne peut pas être partagé.",
"profile.age.details": "Pour partager votre profil avec d'autres apprenants edX, vous devez confirmer que vous avez plus de 13 ans.",
"profile.age.set.date": "Définissez votre date de naissance",
"profile.datejoined.member.since": "Membre depuis {year}",
"profile.bio.empty": "Ajouter une courte biographie",
"profile.bio.about.me": "À propos de moi",
"profile.certificate.organization.label": "De",
"profile.certificate.completion.date.label": "Terminé le {date}",
"profile.no.certificates": "Vous n'avez pas encore de certificats.",
"profile.certificates.my.certificates": "Mes certificats",
"profile.certificates.view.certificate": "Voir le certificat",
"profile.certificates.types.verified": "Certificat vérifié",
"profile.certificates.types.professional": "Certificat professionnel",
"profile.certificates.types.unknown": "Certificat",
"profile.country.label": "Localisation",
"profile.country.empty": "Ajouter localisation",
"profile.education.empty": "Ajouter une éducation",
"profile.education.education": "Education",
"profile.education.levels.p": "Doctorat",
"profile.education.levels.m": "Master ou diplôme professionnel",
"profile.education.levels.b": "Diplôme de licence",
"profile.education.levels.a": "Grade de l'associé",
"profile.education.levels.hs": "Lycée / enseignement secondaire",
"profile.education.levels.jhs": "Collège / enseignement secondaire inférieur",
"profile.education.levels.el": "Enseignement primaire",
"profile.education.levels.none": "Sans diplôme",
"profile.education.levels.o": "Autre niveau d'étude",
"profile.editbutton.edit": "Modifier",
"profile.formcontrols.who.can.see": "Qui peut voir ça :",
"profile.formcontrols.button.cancel": "Annuler",
"profile.formcontrols.button.save": "Enregistrer",
"profile.formcontrols.button.saving": "Enregistrement",
"profile.formcontrols.button.saved": "Enregistré",
"profile.visibility.who.just.me": "Juste moi",
"profile.visibility.who.everyone": "Tout le monde sur edX",
"profile.name.full.name": "Nom complet",
"profile.name.details": "C'est le nom qui apparaît dans votre compte et sur vos certificats.",
"profile.name.empty": "Ajouter un nom",
"profile.preferredlanguage.empty": "Ajouter une langue",
"profile.preferredlanguage.label": "Langue principale parlée",
"profile.profileavatar.upload-button": "Envoyer la photo",
"profile.profileavatar.remove.button": "Supprimer",
"profile.image.alt.attribute": "Profil avatar",
"profile.profileavatar.change-button": "Modifier",
"profile.sociallinks.add": "Ajouter {network}",
"profile.sociallinks.social.links": "Liens vers les réseaux sociaux",
"profile.notfound.message": "La page que vous recherchez n'est pas disponible ou il y a une erreur dans l'URL. Veuillez vérifier l'URL et réessayer.",
"profile.viewMyRecords": "Voir mes succès",
"profile.loading": "Chargement du profil...."
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,51 @@
{
"profile.age.headline": "Your profile cannot be shared.",
"profile.age.details": "To share your profile with other edX learners, you must confirm that you are over the age of 13.",
"profile.age.set.date": "Set your date of birth",
"profile.datejoined.member.since": "Member since {year}",
"profile.bio.empty": "Add a short bio",
"profile.bio.about.me": "About Me",
"profile.certificate.organization.label": "From",
"profile.certificate.completion.date.label": "Completed on {date}",
"profile.no.certificates": "You don't have any certificates yet.",
"profile.certificates.my.certificates": "My Certificates",
"profile.certificates.view.certificate": "View Certificate",
"profile.certificates.types.verified": "Verified Certificate",
"profile.certificates.types.professional": "Professional Certificate",
"profile.certificates.types.unknown": "Certificate",
"profile.country.label": "Location",
"profile.country.empty": "Add location",
"profile.education.empty": "Add education",
"profile.education.education": "Education",
"profile.education.levels.p": "Doctorate",
"profile.education.levels.m": "Master's or professional degree",
"profile.education.levels.b": "Bachelor's Degree",
"profile.education.levels.a": "Associate's degree",
"profile.education.levels.hs": "Secondary/high school",
"profile.education.levels.jhs": "Junior secondary/junior high/middle school",
"profile.education.levels.el": "Elementary/primary school",
"profile.education.levels.none": "No formal education",
"profile.education.levels.o": "Other education",
"profile.editbutton.edit": "Edit",
"profile.formcontrols.who.can.see": "Who can see this:",
"profile.formcontrols.button.cancel": "Cancel",
"profile.formcontrols.button.save": "Save",
"profile.formcontrols.button.saving": "Saving",
"profile.formcontrols.button.saved": "Saved",
"profile.visibility.who.just.me": "Just me",
"profile.visibility.who.everyone": "Everyone on edX",
"profile.name.full.name": "Full Name",
"profile.name.details": "This is the name that appears in your account and on your certificates.",
"profile.name.empty": "Add name",
"profile.preferredlanguage.empty": "Add language",
"profile.preferredlanguage.label": "Primary Language Spoken",
"profile.profileavatar.upload-button": "Upload Photo",
"profile.profileavatar.remove.button": "Remove",
"profile.image.alt.attribute": "profile avatar",
"profile.profileavatar.change-button": "Change",
"profile.sociallinks.add": "Add {network}",
"profile.sociallinks.social.links": "Social Links",
"profile.notfound.message": "The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again.",
"profile.viewMyRecords": "View My Records",
"profile.loading": "Profile loading..."
}

View File

@@ -13,49 +13,53 @@ import {
ErrorPage,
} from '@edx/frontend-platform/react';
import React, { StrictMode } from 'react';
// eslint-disable-next-line import/no-unresolved
import { createRoot } from 'react-dom/client';
import React from 'react';
import ReactDOM from 'react-dom';
import { Route, Switch } from 'react-router-dom';
import Header from '@edx/frontend-component-header';
import { FooterSlot } from '@edx/frontend-component-footer';
import Header, { messages as headerMessages } from '@edx/frontend-component-header';
import Footer, { messages as footerMessages } from '@edx/frontend-component-footer';
import messages from './i18n';
import appMessages from './i18n';
import { ProfilePage, NotFoundPage } from './profile';
import configureStore from './data/configureStore';
import './index.scss';
import Head from './head/Head';
import AppRoutes from './routes/AppRoutes';
const rootNode = createRoot(document.getElementById('root'));
subscribe(APP_READY, () => {
rootNode.render(
<StrictMode>
<AppProvider store={configureStore()}>
<Head />
<Header />
<main id="main">
<AppRoutes />
</main>
<FooterSlot />
</AppProvider>
</StrictMode>,
ReactDOM.render(
<AppProvider store={configureStore()}>
<Header />
<main>
<Switch>
<Route path="/u/:username" component={ProfilePage} />
<Route path="/notfound" component={NotFoundPage} />
<Route path="*" component={NotFoundPage} />
</Switch>
</main>
<Footer />
</AppProvider>,
document.getElementById('root'),
);
});
subscribe(APP_INIT_ERROR, (error) => {
rootNode.render(<ErrorPage message={error.message} />);
ReactDOM.render(<ErrorPage message={error.message} />, document.getElementById('root'));
});
initialize({
messages,
messages: [
appMessages,
headerMessages,
footerMessages,
],
requireAuthenticatedUser: true,
hydrateAuthenticatedUser: true,
handlers: {
config: () => {
mergeConfig({
COLLECT_YEAR_OF_BIRTH: process.env.COLLECT_YEAR_OF_BIRTH,
ENABLE_SKILLS_BUILDER_PROFILE: process.env.ENABLE_SKILLS_BUILDER_PROFILE,
ENABLE_LEARNER_RECORD_MFE: (process.env.ENABLE_LEARNER_RECORD_MFE || false),
LEARNER_RECORD_MFE_BASE_URL: process.env.LEARNER_RECORD_MFE_BASE_URL,
}, 'App loadConfig override handler');
},
},

View File

@@ -1,5 +1,7 @@
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
@import "~@edx/brand/paragon/fonts";
@import "~@edx/brand/paragon/variables";
@import "~@edx/paragon/scss/core/core";
@import "~@edx/brand/paragon/overrides";
@import "~@edx/frontend-component-header/dist/index";
@import "~@edx/frontend-component-footer/dist/footer";

View File

@@ -1,77 +0,0 @@
{
"consumer": {
"name": "frontend-app-profile"
},
"interactions": [
{
"description": "A request for user's basic information",
"providerStates": [
{
"name": "Account and user's information does not exist"
}
],
"request": {
"method": "GET",
"path": "/api/user/v1/accounts/staff_not_found"
},
"response": {
"status": 404
}
},
{
"description": "A request for user's basic information",
"providerStates": [
{
"name": "I have a user's basic information"
}
],
"request": {
"method": "GET",
"path": "/api/user/v1/accounts/staff"
},
"response": {
"body": {
"bio": "This is my bio",
"country": "ME",
"dateJoined": "2017-06-07T00:44:23Z",
"email": "staff@example.com",
"isActive": true,
"name": "Lemon Seltzer",
"username": "staff",
"yearOfBirth": 1901
},
"headers": {
"Content-Type": "application/json"
},
"matchingRules": {
"body": {
"$": {
"combine": "AND",
"matchers": [
{
"match": "type"
}
]
}
}
},
"status": 200
}
}
],
"metadata": {
"pact-js": {
"version": "11.0.2"
},
"pactRust": {
"ffi": "0.4.0",
"models": "1.0.4"
},
"pactSpecification": {
"version": "3.0.0"
}
},
"provider": {
"name": "edx-platform"
}
}

View File

@@ -1,53 +0,0 @@
# Footer Slot
### Slot ID: `org.openedx.frontend.layout.footer.v1`
### Slot ID Aliases
* `footer_slot`
## Description
This slot is used to replace/modify/hide the footer.
The implementation of the `FooterSlot` component lives in [the `frontend-component-footer` repository](https://github.com/openedx/frontend-component-footer/).
## Example
The following `env.config.jsx` will replace the default footer.
![Screenshot of Default Footer](./images/default_footer.png)
with a simple custom footer
![Screenshot of Custom Footer](./images/custom_footer.png)
```jsx
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
const config = {
pluginSlots: {
'org.openedx.frontend.layout.footer.v1': {
plugins: [
{
// Hide the default footer
op: PLUGIN_OPERATIONS.Hide,
widgetId: 'default_contents',
},
{
// Insert a custom footer
op: PLUGIN_OPERATIONS.Insert,
widget: {
id: 'custom_footer',
type: DIRECT_PLUGIN,
RenderWidget: () => (
<h1 style={{textAlign: 'center'}}>🦶</h1>
),
},
},
]
}
},
}
export default config;
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -1,3 +0,0 @@
# `frontend-app-profile` Plugin Slots
* [`org.openedx.frontend.layout.footer.v1`](./FooterSlot/)

View File

@@ -1,40 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@openedx/paragon';
import { StatusAlert } from '@edx/paragon';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
const AgeMessage = ({ accountSettingsUrl }) => (
<Alert
variant="info"
dismissible={false}
show
>
<Alert.Heading id="profile.age.headline">
<FormattedMessage
id="profile.age.cannotShare"
defaultMessage="Your profile cannot be shared."
description="Error message indicating that the user's profile cannot be shared"
/>
</Alert.Heading>
<FormattedMessage
id="profile.age.details"
defaultMessage="To share your profile with other {siteName} learners, you must confirm that you are over the age of 13."
description="Error message"
tagName="p"
values={{
siteName: getConfig().SITE_NAME,
}}
function AgeMessage({ accountSettingsUrl }) {
return (
<StatusAlert
alertType="info"
dialog={(
<>
<FormattedMessage
id="profile.age.headline"
defaultMessage="Your profile cannot be shared."
description="error message"
tagName="h6"
/>
<FormattedMessage
id="profile.age.details"
defaultMessage="To share your profile with other edX learners, you must confirm that you are over the age of 13."
description="error message"
tagName="p"
/>
<a href={accountSettingsUrl}>
<FormattedMessage
id="profile.age.set.date"
defaultMessage="Set your date of birth"
description="label on a link to set birthday"
/>
</a>
</>
)}
dismissible={false}
open
/>
<Alert.Link href={accountSettingsUrl}>
<FormattedMessage
id="profile.age.set.date"
defaultMessage="Set your date of birth"
description="Label on a link to set birthday"
/>
</Alert.Link>
</Alert>
);
);
}
AgeMessage.propTypes = {
accountSettingsUrl: PropTypes.string.isRequired,

View File

@@ -1,5 +1,7 @@
import React from 'react';
const Banner = () => <div className="profile-page-bg-banner bg-primary d-md-block p-relative" />;
function Banner() {
return <div className="profile-page-bg-banner bg-primary d-none d-md-block p-relative" />;
}
export default Banner;

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage, FormattedDate } from '@edx/frontend-platform/i18n';
const DateJoined = ({ date }) => {
function DateJoined({ date }) {
if (date == null) {
return null;
}
@@ -19,7 +19,7 @@ const DateJoined = ({ date }) => {
/>
</p>
);
};
}
DateJoined.propTypes = {
date: PropTypes.string,

View File

@@ -1,16 +1,16 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
const NotFoundPage = () => (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
<p className="my-0 py-5 text-muted" style={{ maxWidth: '32em' }}>
<FormattedMessage
id="profile.notfound.message"
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
description="error message when a page does not exist"
/>
</p>
</div>
);
export default NotFoundPage;
export default function NotFoundPage() {
return (
<div className="container-fluid d-flex py-5 justify-content-center align-items-start text-center">
<p className="my-0 py-5 text-muted" style={{ maxWidth: '32em' }}>
<FormattedMessage
id="profile.notfound.message"
defaultMessage="The page you're looking for is unavailable or there's an error in the URL. Please check the URL and try again."
description="error message when a page does not exist"
/>
</p>
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics';
import { ensureConfig, getConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Alert, Hyperlink } from '@openedx/paragon';
import { StatusAlert, Hyperlink } from '@edx/paragon';
// Actions
import {
@@ -30,10 +30,8 @@ import Bio from './forms/Bio';
import Certificates from './forms/Certificates';
import AgeMessage from './AgeMessage';
import DateJoined from './DateJoined';
import UsernameDescription from './UsernameDescription';
import PageLoading from './PageLoading';
import Banner from './Banner';
import LearningGoal from './forms/LearningGoal';
// Selectors
import { profilePageSelector } from './data/selectors';
@@ -41,18 +39,17 @@ import { profilePageSelector } from './data/selectors';
// i18n
import messages from './ProfilePage.messages';
import withParams from '../utils/hoc';
ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL'], 'ProfilePage');
class ProfilePage extends React.Component {
constructor(props, context) {
super(props, context);
const credentialsBaseUrl = context.config.CREDENTIALS_BASE_URL;
const recordsUrl = this.getRecordsUrl(context);
this.state = {
viewMyRecordsUrl: credentialsBaseUrl ? `${credentialsBaseUrl}/records` : null,
accountSettingsUrl: context.config.ACCOUNT_SETTINGS_URL,
viewMyRecordsUrl: recordsUrl,
accountSettingsUrl: `${context.config.LMS_BASE_URL}/account/settings`,
};
this.handleSaveProfilePhoto = this.handleSaveProfilePhoto.bind(this);
@@ -64,12 +61,29 @@ class ProfilePage extends React.Component {
}
componentDidMount() {
this.props.fetchProfile(this.props.params.username);
this.props.fetchProfile(this.props.match.params.username);
sendTrackingLogEvent('edx.profile.viewed', {
username: this.props.params.username,
username: this.props.match.params.username,
});
}
getRecordsUrl(context) {
let recordsUrl = null;
if (getConfig().ENABLE_LEARNER_RECORD_MFE) {
recordsUrl = getConfig().LEARNER_RECORD_MFE_BASE_URL;
} else {
const credentialsBaseUrl = context.config.CREDENTIALS_BASE_URL;
recordsUrl = credentialsBaseUrl ? `${credentialsBaseUrl}/records` : null;
}
return recordsUrl;
}
isAuthenticatedUserProfile() {
return this.props.match.params.username === this.context.authenticatedUser.username;
}
handleSaveProfilePhoto(formData) {
this.props.saveProfilePhoto(this.context.authenticatedUser.username, formData);
}
@@ -94,18 +108,6 @@ class ProfilePage extends React.Component {
this.props.updateDraft(name, value);
}
isYOBDisabled() {
const { yearOfBirth } = this.props;
const currentYear = new Date().getFullYear();
const isAgeOrNotCompliant = !yearOfBirth || ((currentYear - yearOfBirth) < 13);
return isAgeOrNotCompliant && getConfig().COLLECT_YEAR_OF_BIRTH !== 'true';
}
isAuthenticatedUserProfile() {
return this.props.params.username === this.context.authenticatedUser.username;
}
// Inserted into the DOM in two places (for responsive layout)
renderViewMyRecordsButton() {
if (!(this.state.viewMyRecordsUrl && this.isAuthenticatedUserProfile())) {
@@ -124,12 +126,13 @@ class ProfilePage extends React.Component {
const { dateJoined } = this.props;
return (
<span data-hj-suppress>
<h1 className="h2 mb-0 font-weight-bold text-truncate">{this.props.params.username}</h1>
<DateJoined date={dateJoined} />
{this.isYOBDisabled() && <UsernameDescription />}
<hr className="d-none d-md-block" />
</span>
<>
<span data-hj-suppress>
<h1 className="h2 mb-0 font-weight-bold">{this.props.match.params.username}</h1>
<DateJoined date={dateJoined} />
<hr className="d-none d-md-block" />
</span>
</>
);
}
@@ -143,9 +146,7 @@ class ProfilePage extends React.Component {
return (
<div className="row">
<div className="col-md-4 col-lg-3">
<Alert variant="danger" dismissible={false} show>
{photoUploadError.userMessage}
</Alert>
<StatusAlert alertType="danger" dialog={photoUploadError.userMessage} dismissible={false} open />
</div>
</div>
);
@@ -173,29 +174,19 @@ class ProfilePage extends React.Component {
socialLinks,
draftSocialLinksByPlatform,
visibilitySocialLinks,
learningGoal,
visibilityLearningGoal,
languageProficiencies,
visibilityLanguageProficiencies,
courseCertificates,
visibilityCourseCertificates,
bio,
visibilityBio,
requiresParentalConsent,
isLoadingProfile,
username,
saveState,
navigate,
} = this.props;
if (isLoadingProfile) {
return <PageLoading srMessage={this.props.intl.formatMessage(messages['profile.loading'])} />;
}
if (!username && saveState === 'error' && navigate) {
navigate('/notfound');
}
const commonFormProps = {
openHandler: this.handleOpen,
closeHandler: this.handleClose,
@@ -203,17 +194,6 @@ class ProfilePage extends React.Component {
changeHandler: this.handleChange,
};
const isBlockVisible = (blockInfo) => this.isAuthenticatedUserProfile()
|| (!this.isAuthenticatedUserProfile() && Boolean(blockInfo));
const isLanguageBlockVisible = isBlockVisible(languageProficiencies.length);
const isEducationBlockVisible = isBlockVisible(levelOfEducation);
const isSocialLinksBLockVisible = isBlockVisible(socialLinks.some((link) => link.socialLink !== null));
const isBioBlockVisible = isBlockVisible(bio);
const isCertificatesBlockVisible = isBlockVisible(courseCertificates.length);
const isNameBlockVisible = isBlockVisible(name);
const isLocationBlockVisible = isBlockVisible(country);
return (
<div className="container-fluid">
<div className="row align-items-center pt-4 mb-4 pt-md-0 mb-md-0">
@@ -230,7 +210,7 @@ class ProfilePage extends React.Component {
/>
</div>
</div>
<div className="col">
<div className="col pl-0">
<div className="d-md-none">
{this.renderHeadingLockup()}
</div>
@@ -248,73 +228,51 @@ class ProfilePage extends React.Component {
<div className="d-md-none mb-4">
{this.renderViewMyRecordsButton()}
</div>
{isNameBlockVisible && (
<Name
name={name}
visibilityName={visibilityName}
formId="name"
{...commonFormProps}
/>
)}
{isLocationBlockVisible && (
<Country
country={country}
visibilityCountry={visibilityCountry}
formId="country"
{...commonFormProps}
/>
)}
{isLanguageBlockVisible && (
<PreferredLanguage
languageProficiencies={languageProficiencies}
visibilityLanguageProficiencies={visibilityLanguageProficiencies}
formId="languageProficiencies"
{...commonFormProps}
/>
)}
{isEducationBlockVisible && (
<Education
levelOfEducation={levelOfEducation}
visibilityLevelOfEducation={visibilityLevelOfEducation}
formId="levelOfEducation"
{...commonFormProps}
/>
)}
{isSocialLinksBLockVisible && (
<SocialLinks
socialLinks={socialLinks}
draftSocialLinksByPlatform={draftSocialLinksByPlatform}
visibilitySocialLinks={visibilitySocialLinks}
formId="socialLinks"
{...commonFormProps}
/>
)}
<Name
name={name}
visibilityName={visibilityName}
formId="name"
{...commonFormProps}
/>
<Country
country={country}
visibilityCountry={visibilityCountry}
formId="country"
{...commonFormProps}
/>
<PreferredLanguage
languageProficiencies={languageProficiencies}
visibilityLanguageProficiencies={visibilityLanguageProficiencies}
formId="languageProficiencies"
{...commonFormProps}
/>
<Education
levelOfEducation={levelOfEducation}
visibilityLevelOfEducation={visibilityLevelOfEducation}
formId="levelOfEducation"
{...commonFormProps}
/>
<SocialLinks
socialLinks={socialLinks}
draftSocialLinksByPlatform={draftSocialLinksByPlatform}
visibilitySocialLinks={visibilitySocialLinks}
formId="socialLinks"
{...commonFormProps}
/>
</div>
<div className="pt-md-3 col-md-8 col-lg-7 offset-lg-1">
{!this.isYOBDisabled() && this.renderAgeMessage()}
{isBioBlockVisible && (
<Bio
bio={bio}
visibilityBio={visibilityBio}
formId="bio"
{...commonFormProps}
/>
)}
{getConfig().ENABLE_SKILLS_BUILDER_PROFILE && (
<LearningGoal
learningGoal={learningGoal}
visibilityLearningGoal={visibilityLearningGoal}
formId="learningGoal"
{...commonFormProps}
/>
)}
{isCertificatesBlockVisible && (
<Certificates
visibilityCourseCertificates={visibilityCourseCertificates}
formId="certificates"
{...commonFormProps}
/>
)}
{this.renderAgeMessage()}
<Bio
bio={bio}
visibilityBio={visibilityBio}
formId="bio"
{...commonFormProps}
/>
<Certificates
visibilityCourseCertificates={visibilityCourseCertificates}
formId="certificates"
{...commonFormProps}
/>
</div>
</div>
</div>
@@ -337,11 +295,9 @@ ProfilePage.propTypes = {
// Account data
requiresParentalConsent: PropTypes.bool,
dateJoined: PropTypes.string,
username: PropTypes.string,
// Bio form data
bio: PropTypes.string,
yearOfBirth: PropTypes.number,
visibilityBio: PropTypes.string.isRequired,
// Certificates form data
@@ -379,10 +335,6 @@ ProfilePage.propTypes = {
})),
visibilitySocialLinks: PropTypes.string.isRequired,
// Learning Goal form data
learningGoal: PropTypes.string,
visibilityLearningGoal: PropTypes.string.isRequired,
// Other data we need
profileImage: PropTypes.shape({
src: PropTypes.string,
@@ -403,11 +355,12 @@ ProfilePage.propTypes = {
openForm: PropTypes.func.isRequired,
closeForm: PropTypes.func.isRequired,
updateDraft: PropTypes.func.isRequired,
navigate: PropTypes.func.isRequired,
// Router
params: PropTypes.shape({
username: PropTypes.string.isRequired,
match: PropTypes.shape({
params: PropTypes.shape({
username: PropTypes.string.isRequired,
}).isRequired,
}).isRequired,
// i18n
@@ -416,18 +369,15 @@ ProfilePage.propTypes = {
ProfilePage.defaultProps = {
saveState: null,
username: '',
savePhotoState: null,
photoUploadError: {},
profileImage: {},
name: null,
yearOfBirth: null,
levelOfEducation: null,
country: null,
socialLinks: [],
draftSocialLinksByPlatform: {},
bio: null,
learningGoal: null,
languageProficiencies: [],
courseCertificates: null,
requiresParentalConsent: null,
@@ -445,4 +395,4 @@ export default connect(
closeForm,
updateDraft,
},
)(injectIntl(withParams(ProfilePage)));
)(injectIntl(ProfilePage));

View File

@@ -3,24 +3,22 @@ import { getConfig } from '@edx/frontend-platform';
import * as analytics from '@edx/frontend-platform/analytics';
import { AppContext } from '@edx/frontend-platform/react';
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
import { render } from '@testing-library/react';
import { mount } from 'enzyme';
import React from 'react';
import PropTypes from 'prop-types';
import { Provider } from 'react-redux';
import renderer from 'react-test-renderer';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { BrowserRouter, useNavigate } from 'react-router-dom';
import messages from '../i18n';
import ProfilePage from './ProfilePage';
const mockStore = configureMockStore([thunk]);
const storeMocks = {
loadingApp: require('./__mocks__/loadingApp.mockStore'),
invalidUser: require('./__mocks__/invalidUser.mockStore'),
viewOwnProfile: require('./__mocks__/viewOwnProfile.mockStore'),
viewOtherProfile: require('./__mocks__/viewOtherProfile.mockStore'),
savingEditedBio: require('./__mocks__/savingEditedBio.mockStore'),
loadingApp: require('./__mocks__/loadingApp.mockStore.js'),
viewOwnProfile: require('./__mocks__/viewOwnProfile.mockStore.js'),
viewOtherProfile: require('./__mocks__/viewOtherProfile.mockStore.js'),
savingEditedBio: require('./__mocks__/savingEditedBio.mockStore.js'),
};
const requiredProfilePageProps = {
fetchUserAccount: () => {},
@@ -30,7 +28,7 @@ const requiredProfilePageProps = {
deleteProfilePhoto: () => {},
openField: () => {},
closeField: () => {},
params: { username: 'staff' },
match: { params: { username: 'staff' } },
};
// Mock language cookie
@@ -67,203 +65,84 @@ beforeEach(() => {
analytics.sendTrackingLogEvent.mockReset();
});
const ProfileWrapper = ({ params, requiresParentalConsent }) => {
const navigate = useNavigate();
return (
<ProfilePage
{...requiredProfilePageProps}
params={params}
requiresParentalConsent={requiresParentalConsent}
navigate={navigate}
/>
);
};
ProfileWrapper.propTypes = {
params: PropTypes.shape({}).isRequired,
requiresParentalConsent: PropTypes.bool.isRequired,
};
const ProfilePageWrapper = ({
contextValue, store, params, requiresParentalConsent,
}) => (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={store}>
<BrowserRouter>
<ProfileWrapper
params={params}
requiresParentalConsent={requiresParentalConsent}
/>
</BrowserRouter>
</Provider>
</IntlProvider>
</AppContext.Provider>
);
ProfilePageWrapper.defaultProps = {
params: { username: 'staff' },
requiresParentalConsent: null,
};
ProfilePageWrapper.propTypes = {
contextValue: PropTypes.shape({}).isRequired,
store: PropTypes.shape({}).isRequired,
params: PropTypes.shape({}),
requiresParentalConsent: PropTypes.bool,
};
describe('<ProfilePage />', () => {
describe('Renders correctly in various states', () => {
it('app loading', () => {
const contextValue = {
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
};
const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeMocks.loadingApp)} />;
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('successfully redirected to not found page.', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeMocks.invalidUser)} />;
const { container: tree } = render(component);
const component = (
<AppContext.Provider
value={{
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
}}
>
<IntlProvider locale="en">
<Provider store={mockStore(storeMocks.loadingApp)}>
<ProfilePage {...requiredProfilePageProps} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
const tree = renderer.create(component).toJSON();
expect(tree).toMatchSnapshot();
});
it('viewing own profile', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = <ProfilePageWrapper contextValue={contextValue} store={mockStore(storeMocks.viewOwnProfile)} />;
const { container: tree } = render(component);
const component = (
<AppContext.Provider
value={{
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
}}
>
<IntlProvider locale="en">
<Provider store={mockStore(storeMocks.viewOwnProfile)}>
<ProfilePage {...requiredProfilePageProps} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
const tree = renderer.create(component).toJSON();
expect(tree).toMatchSnapshot();
});
it('viewing other profile with all fields', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
it('viewing other profile', () => {
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore({
...storeMocks.viewOtherProfile,
profilePage: {
...storeMocks.viewOtherProfile.profilePage,
account: {
...storeMocks.viewOtherProfile.profilePage.account,
name: 'user',
country: 'EN',
bio: 'bio',
courseCertificates: ['course certificates'],
levelOfEducation: 'some level',
languageProficiencies: ['some lang'],
socialLinks: ['twitter'],
timeZone: 'time zone',
accountPrivacy: 'all_users',
},
},
})}
match={{ params: { username: 'verified' } }} // Override default match
/>
<AppContext.Provider
value={{
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
}}
>
<IntlProvider locale="en">
<Provider store={mockStore(storeMocks.viewOtherProfile)}>
<ProfilePage
{...requiredProfilePageProps}
match={{ params: { username: 'verified' } }} // Override default match
/>
</Provider>
</IntlProvider>
</AppContext.Provider>
);
const { container: tree } = render(component);
const tree = renderer.create(component).toJSON();
expect(tree).toMatchSnapshot();
});
it('while saving an edited bio', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.savingEditedBio)}
/>
<AppContext.Provider
value={{
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
}}
>
<IntlProvider locale="en">
<Provider store={mockStore(storeMocks.savingEditedBio)}>
<ProfilePage {...requiredProfilePageProps} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('while saving an edited bio with error', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.bio = { userMessage: 'bio error' };
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('test country edit with error', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.country = { userMessage: 'country error' };
storeData.profilePage.currentlyEditingField = 'country';
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('test education edit with error', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.levelOfEducation = { userMessage: 'education error' };
storeData.profilePage.currentlyEditingField = 'levelOfEducation';
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
/>
);
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
});
it('test preferreded language edit with error', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.savingEditedBio));
storeData.profilePage.errors.languageProficiencies = { userMessage: 'preferred language error' };
storeData.profilePage.currentlyEditingField = 'languageProficiencies';
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
/>
);
const { container: tree } = render(component);
const tree = renderer.create(component).toJSON();
expect(tree).toMatchSnapshot();
});
@@ -271,69 +150,46 @@ describe('<ProfilePage />', () => {
const config = getConfig();
config.CREDENTIALS_BASE_URL = '';
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
const component = (
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.viewOwnProfile)}
/>
<AppContext.Provider
value={{
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config,
}}
>
<IntlProvider locale="en">
<Provider store={mockStore(storeMocks.viewOwnProfile)}>
<ProfilePage {...requiredProfilePageProps} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
const { container: tree } = render(component);
const tree = renderer.create(component).toJSON();
expect(tree).toMatchSnapshot();
});
it('test age message alert', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.viewOwnProfile));
storeData.userAccount.requiresParentalConsent = true;
storeData.profilePage.account.requiresParentalConsent = true;
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: { ...getConfig(), COLLECT_YEAR_OF_BIRTH: true },
};
const { container } = render(
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
requiresParentalConsent
/>,
);
expect(container.querySelector('.alert-info')).toHaveClass('show');
});
it('test photo error alert', () => {
const storeData = JSON.parse(JSON.stringify(storeMocks.viewOwnProfile));
storeData.profilePage.errors.photo = { userMessage: 'error' };
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: { ...getConfig(), COLLECT_YEAR_OF_BIRTH: true },
};
const { container } = render(
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeData)}
requiresParentalConsent
/>,
);
expect(container.querySelector('.alert-danger')).toHaveClass('show');
});
});
describe('handles analytics', () => {
it('calls sendTrackingLogEvent when mounting', () => {
const contextValue = {
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
};
render(
<ProfilePageWrapper
contextValue={contextValue}
store={mockStore(storeMocks.loadingApp)}
params={{ username: 'test-username' }}
/>,
const component = (
<AppContext.Provider
value={{
authenticatedUser: { userId: 123, username: 'staff', administrator: true },
config: getConfig(),
}}
>
<IntlProvider locale="en">
<Provider store={mockStore(storeMocks.loadingApp)}>
<ProfilePage
{...requiredProfilePageProps}
match={{ params: { username: 'test-username' } }}
/>
</Provider>
</IntlProvider>
</AppContext.Provider>
);
const wrapper = mount(component);
wrapper.update();
expect(analytics.sendTrackingLogEvent.mock.calls.length).toBe(1);
expect(analytics.sendTrackingLogEvent.mock.calls[0][0]).toEqual('edx.profile.viewed');

View File

@@ -1,23 +0,0 @@
import React from 'react';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { VisibilityOff } from '@openedx/paragon/icons';
import { Icon } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
const UsernameDescription = () => (
<div className="d-flex align-items-center mt-3 mb-2rem">
<Icon src={VisibilityOff} className="icon-visibility-off" />
<div className="username-description">
<FormattedMessage
id="profile.username.description"
defaultMessage="Your profile information is only visible to you. Only your username is visible to others on {siteName}."
description="A description of the username field"
values={{
siteName: getConfig().SITE_NAME,
}}
/>
</div>
</div>
);
export default UsernameDescription;

View File

@@ -1,41 +0,0 @@
module.exports = {
userAccount: {
loading: false,
error: null,
username: 'staff',
email: null,
bio: null,
name: null,
country: null,
socialLinks: null,
profileImage: {
imageUrlMedium: null,
imageUrlLarge: null
},
levelOfEducation: null,
learningGoal: null
},
profilePage: {
errors: {},
saveState: 'error',
savePhotoState: null,
currentlyEditingField: null,
account: {
username: '',
socialLinks: []
},
preferences: {},
courseCertificates: [],
drafts: {},
isLoadingProfile: false,
isAuthenticatedUserProfile: true,
},
router: {
location: {
pathname: '/u/staffTest',
search: '',
hash: ''
},
action: 'POP'
}
};

View File

@@ -12,8 +12,7 @@ module.exports = {
imageUrlMedium: null,
imageUrlLarge: null
},
levelOfEducation: null,
learningGoal: null
levelOfEducation: null
},
profilePage: {
errors: {},

View File

@@ -42,8 +42,7 @@ module.exports = {
secondaryEmail: null,
timeZone: null,
gender: null,
accountPrivacy: 'custom',
learningGoal: null,
accountPrivacy: 'custom'
},
profilePage: {
errors: {},
@@ -92,8 +91,7 @@ module.exports = {
timeZone: null,
levelOfEducation: 'el',
gender: null,
accountPrivacy: 'custom',
learningGoal: null,
accountPrivacy: 'custom'
},
preferences: {
visibilityUserLocation: 'all_users',
@@ -106,8 +104,7 @@ module.exports = {
visibilityName: 'private',
visibilityLanguageProficiencies: 'all_users',
visibilityCountry: 'all_users',
accountPrivacy: 'custom',
visibilityLearningGoal: 'private',
accountPrivacy: 'custom'
},
courseCertificates: [
{

View File

@@ -42,8 +42,7 @@ module.exports = {
secondaryEmail: null,
timeZone: null,
gender: null,
accountPrivacy: 'custom',
learningGoal: 'advance_career',
accountPrivacy: 'custom'
},
profilePage: {
errors: {},
@@ -84,8 +83,7 @@ module.exports = {
preferences: {},
courseCertificates: [],
drafts: {},
isLoadingProfile: false,
learningGoal: 'advance_career',
isLoadingProfile: false
},
router: {
location: {

View File

@@ -42,8 +42,7 @@ module.exports = {
secondaryEmail: null,
timeZone: null,
gender: null,
accountPrivacy: 'custom',
learningGoal: 'advance_career'
accountPrivacy: 'custom'
},
profilePage: {
errors: {},
@@ -92,8 +91,7 @@ module.exports = {
timeZone: null,
levelOfEducation: 'el',
gender: null,
accountPrivacy: 'custom',
learningGoal: 'advance_career'
accountPrivacy: 'custom'
},
preferences: {
visibilityUserLocation: 'all_users',
@@ -106,8 +104,7 @@ module.exports = {
visibilityName: 'private',
visibilityLanguageProficiencies: 'all_users',
visibilityCountry: 'all_users',
accountPrivacy: 'custom',
visibilityLearningGoal: 'private',
accountPrivacy: 'custom'
},
courseCertificates: [
{

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ const EDUCATION_LEVELS = [
'jhs',
'el',
'none',
'other',
'o',
];
const SOCIAL = {

View File

@@ -1,7 +0,0 @@
const mockData = {
learningGoal: 'advance_career',
editMode: 'static',
visibilityLearningGoal: 'private',
};
export default mockData;

View File

@@ -1,80 +0,0 @@
// This test file simply creates a contract that defines
// expectations and correct responses from the Pact stub server.
import path from 'path';
import { PactV3, MatchersV3 } from '@pact-foundation/pact';
import { initializeMockApp, getConfig, setConfig } from '@edx/frontend-platform';
import { getAccount } from './services';
const expectedUserInfo200 = {
username: 'staff',
email: 'staff@example.com',
bio: 'This is my bio',
name: 'Lemon Seltzer',
country: 'ME',
dateJoined: '2017-06-07T00:44:23Z',
isActive: true,
yearOfBirth: 1901,
};
const provider = new PactV3({
log: path.resolve(process.cwd(), 'src/pact-logs/pact.log'),
dir: path.resolve(process.cwd(), 'src/pacts'),
consumer: 'frontend-app-profile',
provider: 'edx-platform',
});
describe('getAccount for one username', () => {
beforeAll(async () => {
initializeMockApp();
});
it('returns a HTTP 200 and user information', async () => {
const username200 = 'staff';
await provider.addInteraction({
states: [{ description: "I have a user's basic information" }],
uponReceiving: "A request for user's basic information",
withRequest: {
method: 'GET',
path: `/api/user/v1/accounts/${username200}`,
headers: {},
},
willRespondWith: {
status: 200,
headers: {},
body: MatchersV3.like(expectedUserInfo200),
},
});
return provider.executeTest(async (mockserver) => {
setConfig({
...getConfig(),
LMS_BASE_URL: mockserver.url,
});
const response = await getAccount(username200);
expect(response).toEqual(expectedUserInfo200);
});
});
it('Account does not exist', async () => {
const username404 = 'staff_not_found';
await provider.addInteraction({
states: [{ description: "Account and user's information does not exist" }],
uponReceiving: "A request for user's basic information",
withRequest: {
method: 'GET',
path: `/api/user/v1/accounts/${username404}`,
},
willRespondWith: {
status: 404,
},
});
await provider.executeTest(async (mockserver) => {
setConfig({
...getConfig(),
LMS_BASE_URL: mockserver.url,
});
await expect(getAccount(username404).then((response) => response.data)).rejects.toThrow('Request failed with status code 404');
});
});
});

View File

@@ -24,7 +24,7 @@ export const initialState = {
isAuthenticatedUserProfile: false,
};
const profilePage = (state = initialState, action = {}) => {
const profilePage = (state = initialState, action) => {
switch (action.type) {
case FETCH_PROFILE.BEGIN:
return {
@@ -63,14 +63,12 @@ const profilePage = (state = initialState, action = {}) => {
return {
...state,
saveState: 'error',
isLoadingProfile: false,
errors: { ...state.errors, ...action.payload.errors },
};
case SAVE_PROFILE.RESET:
return {
...state,
saveState: null,
isLoadingProfile: false,
errors: {},
};

View File

@@ -1,3 +1,4 @@
import { history } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import pick from 'lodash.pick';
import {
@@ -65,25 +66,6 @@ export function* handleFetchProfile(action) {
} else {
[account, courseCertificates] = result;
}
// Set initial visibility values for account
// Set account_privacy as custom is necessary so that when viewing another user's profile,
// their full name is displayed and change visibility forms are worked correctly
if (isAuthenticatedUserProfile && result[0].accountPrivacy === 'all_users') {
yield call(ProfileApiService.patchPreferences, action.payload.username, {
account_privacy: 'custom',
'visibility.name': 'all_users',
'visibility.bio': 'all_users',
'visibility.course_certificates': 'all_users',
'visibility.country': 'all_users',
'visibility.date_joined': 'all_users',
'visibility.level_of_education': 'all_users',
'visibility.language_proficiencies': 'all_users',
'visibility.social_links': 'all_users',
'visibility.time_zone': 'all_users',
});
}
yield put(fetchProfileSuccess(
account,
preferences,
@@ -94,11 +76,7 @@ export function* handleFetchProfile(action) {
yield put(fetchProfileReset());
} catch (e) {
if (e.response.status === 404) {
if (e.processedData && e.processedData.fieldErrors) {
yield put(saveProfileFailure(e.processedData.fieldErrors));
} else {
yield put(saveProfileFailure(e.customAttributes));
}
history.push('/notfound');
} else {
throw e;
}

View File

@@ -35,12 +35,9 @@ export const editableFormModeSelector = createSelector(
// or is being hidden from us (for other users' profiles)
let propExists = account[formId] != null && account[formId].length > 0;
propExists = formId === 'certificates' ? certificates.length > 0 : propExists; // overwrite for certificates
// If this isn't the current user's profile
if (!isAuthenticatedUserProfile) {
return 'static';
}
// If this isn't the current user's profile or if
// the current user has no age set / under 13 ...
if (account.requiresParentalConsent) {
if (!isAuthenticatedUserProfile || account.requiresParentalConsent) {
// then there are only two options: static or nothing.
// We use 'null' as a return value because the consumers of
// getMode render nothing at all on a mode of null.
@@ -231,13 +228,13 @@ export const visibilitiesSelector = createSelector(
switch (accountPrivacy) {
case 'custom':
return {
visibilityBio: preferences.visibilityBio || 'all_users',
visibilityCourseCertificates: preferences.visibilityCourseCertificates || 'all_users',
visibilityCountry: preferences.visibilityCountry || 'all_users',
visibilityLevelOfEducation: preferences.visibilityLevelOfEducation || 'all_users',
visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'all_users',
visibilityName: preferences.visibilityName || 'all_users',
visibilitySocialLinks: preferences.visibilitySocialLinks || 'all_users',
visibilityBio: preferences.visibilityBio || 'private',
visibilityCourseCertificates: preferences.visibilityCourseCertificates || 'private',
visibilityCountry: preferences.visibilityCountry || 'private',
visibilityLevelOfEducation: preferences.visibilityLevelOfEducation || 'private',
visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'private',
visibilityName: preferences.visibilityName || 'private',
visibilitySocialLinks: preferences.visibilitySocialLinks || 'private',
};
case 'private':
return {
@@ -338,7 +335,6 @@ export const profilePageSelector = createSelector(
profileImage,
requiresParentalConsent: account.requiresParentalConsent,
dateJoined: account.dateJoined,
yearOfBirth: account.yearOfBirth,
// Bio form data
bio: formValues.bio,

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon';
import { ValidationFormGroup } from '@edx/paragon';
import messages from './Bio.messages';
@@ -56,9 +56,10 @@ class Bio extends React.Component {
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={this.handleSubmit}>
<Form.Group
controlId={formId}
isInvalid={error !== null}
<ValidationFormGroup
for={formId}
invalid={error !== null}
invalidMessage={error}
>
<label className="edit-section-header" htmlFor={formId}>
{intl.formatMessage(messages['profile.bio.about.me'])}
@@ -70,12 +71,7 @@ class Bio extends React.Component {
value={bio}
onChange={this.handleChange}
/>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
</ValidationFormGroup>
<FormControls
visibilityId="visibilityBio"
saveState={saveState}

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import {
FormattedDate, FormattedMessage, injectIntl, intlShape,
} from '@edx/frontend-platform/i18n';
import { Hyperlink } from '@openedx/paragon';
import { Hyperlink } from '@edx/paragon';
import { connect } from 'react-redux';
import get from 'lodash.get';
@@ -68,7 +68,7 @@ class Certificates extends React.Component {
})();
return (
<div key={`${modifiedDate}-${courseId}`} className="col-12 col-sm-6 d-flex align-items-stretch">
<div key={`${modifiedDate}-${courseId}`} className="col col-sm-6 d-flex align-items-stretch">
<div className="card mb-4 certificate flex-grow-1">
<div
className="certificate-type-illustration"

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon';
import { ValidationFormGroup } from '@edx/paragon';
import messages from './Country.messages';
@@ -67,9 +67,10 @@ class Country extends React.Component {
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={this.handleSubmit}>
<Form.Group
controlId={formId}
isInvalid={error !== null}
<ValidationFormGroup
for={formId}
invalid={error !== null}
invalidMessage={error}
>
<label className="edit-section-header" htmlFor={formId}>
{intl.formatMessage(messages['profile.country.label'])}
@@ -88,12 +89,7 @@ class Country extends React.Component {
<option key={code} value={code}>{name}</option>
))}
</select>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
</ValidationFormGroup>
<FormControls
visibilityId="visibilityCountry"
saveState={saveState}

View File

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import get from 'lodash.get';
import { Form } from '@openedx/paragon';
import { ValidationFormGroup } from '@edx/paragon';
import messages from './Education.messages';
@@ -63,9 +63,10 @@ class Education extends React.Component {
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={this.handleSubmit}>
<Form.Group
controlId={formId}
isInvalid={error !== null}
<ValidationFormGroup
for={formId}
invalid={error !== null}
invalidMessage={error}
>
<label className="edit-section-header" htmlFor={formId}>
{intl.formatMessage(messages['profile.education.education'])}
@@ -89,12 +90,7 @@ class Education extends React.Component {
</option>
))}
</select>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
</ValidationFormGroup>
<FormControls
visibilityId="visibilityLevelOfEducation"
saveState={saveState}

View File

@@ -1,92 +0,0 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import get from 'lodash.get';
// Mock Data
import mockData from '../data/mock_data';
import messages from './LearningGoal.messages';
// Components
import EditableItemHeader from './elements/EditableItemHeader';
import SwitchContent from './elements/SwitchContent';
// Selectors
import { editableFormSelector } from '../data/selectors';
const LearningGoal = (props) => {
let { learningGoal, editMode, visibilityLearningGoal } = props;
const { intl } = props;
if (!learningGoal) {
learningGoal = mockData.learningGoal;
}
if (!editMode || editMode === 'empty') { // editMode defaults to 'empty', not sure why yet
editMode = mockData.editMode;
}
if (!visibilityLearningGoal) {
visibilityLearningGoal = mockData.visibilityLearningGoal;
}
return (
<SwitchContent
className="mb-5"
expression={editMode}
cases={{
editable: (
<>
<EditableItemHeader
content={intl.formatMessage(messages['profile.learningGoal.learningGoal'])}
showVisibility={visibilityLearningGoal !== null}
visibility={visibilityLearningGoal}
/>
<p data-hj-suppress className="lead">
{intl.formatMessage(get(
messages,
`profile.learningGoal.options.${learningGoal}`,
messages['profile.learningGoal.options.something_else'],
))}
</p>
</>
),
static: (
<>
<EditableItemHeader content={intl.formatMessage(messages['profile.learningGoal.learningGoal'])} />
<p data-hj-suppress className="lead">
{intl.formatMessage(get(
messages,
`profile.learningGoal.options.${learningGoal}`,
messages['profile.learningGoal.options.something_else'],
))}
</p>
</>
),
}}
/>
);
};
LearningGoal.propTypes = {
// From Selector
learningGoal: PropTypes.oneOf(['advance_career', 'start_career', 'learn_something_new', 'something_else']),
visibilityLearningGoal: PropTypes.oneOf(['private', 'all_users']),
editMode: PropTypes.oneOf(['editable', 'static']),
// i18n
intl: intlShape.isRequired,
};
LearningGoal.defaultProps = {
editMode: 'static',
learningGoal: null,
visibilityLearningGoal: 'private',
};
export default connect(
editableFormSelector,
{},
)(injectIntl(LearningGoal));

View File

@@ -1,31 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'profile.learningGoal.learningGoal': {
id: 'profile.learningGoal.learningGoal',
defaultMessage: 'Learning Goal',
description: 'A section of a user profile that displays their current learning goal.',
},
'profile.learningGoal.options.start_career': {
id: 'profile.learningGoal.options.start_career',
defaultMessage: 'I want to start my career',
description: 'Selected by user if their goal is to start their career.',
},
'profile.learningGoal.options.advance_career': {
id: 'profile.learningGoal.options.advance_career',
defaultMessage: 'I want to advance my career',
description: 'Selected by user if their goal is to advance their career.',
},
'profile.learningGoal.options.learn_something_new': {
id: 'profile.learningGoal.options.learn_something_new',
defaultMessage: 'I want to learn something new',
description: 'Selected by user if their goal is to learn something new.',
},
'profile.learningGoal.options.something_else': {
id: 'profile.learningGoal.options.something_else',
defaultMessage: 'Something else',
description: 'Selected by user if their goal is not described by the other choices.',
},
});
export default messages;

View File

@@ -1,116 +0,0 @@
import PropTypes from 'prop-types';
import React, { useMemo } from 'react';
import { Provider } from 'react-redux';
import { render, screen } from '@testing-library/react';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import messages from '../../i18n';
import viewOwnProfileMockStore from '../__mocks__/viewOwnProfile.mockStore';
import savingEditedBioMockStore from '../__mocks__/savingEditedBio.mockStore';
import LearningGoal from './LearningGoal';
const mockStore = configureMockStore([thunk]);
// props to be passed down to LearningGoal component
const requiredLearningGoalProps = {
formId: 'learningGoal',
learningGoal: 'advance_career',
drafts: {},
visibilityLearningGoal: 'private',
editMode: 'static',
saveState: null,
error: null,
openHandler: jest.fn(),
};
configureI18n({
loggingService: { logError: jest.fn() },
config: {
ENVIRONMENT: 'production',
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
},
messages,
});
const LearningGoalWrapper = (props) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
}), []);
return (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={props.store}>
<LearningGoal {...props} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
};
LearningGoalWrapper.defaultProps = {
store: mockStore(viewOwnProfileMockStore),
};
LearningGoalWrapper.propTypes = {
store: PropTypes.shape({}),
};
const LearningGoalWrapperWithStore = ({ store }) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
}), []);
return (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={mockStore(store)}>
<LearningGoal {...requiredLearningGoalProps} formId="learningGoal" />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
};
LearningGoalWrapperWithStore.defaultProps = {
store: mockStore(savingEditedBioMockStore),
};
LearningGoalWrapperWithStore.propTypes = {
store: PropTypes.shape({}),
};
describe('<LearningGoal />', () => {
describe('renders the current learning goal', () => {
it('renders "I want to advance my career"', () => {
render(
<LearningGoalWrapper
{...requiredLearningGoalProps}
formId="learningGoal"
/>,
);
expect(screen.getByText('I want to advance my career')).toBeTruthy();
});
it('renders "Something else"', () => {
requiredLearningGoalProps.learningGoal = 'something_else';
render(
<LearningGoalWrapper
{...requiredLearningGoalProps}
formId="learningGoal"
/>,
);
expect(screen.getByText('Something else')).toBeTruthy();
});
});
});

View File

@@ -2,7 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Form } from '@openedx/paragon';
import { ValidationFormGroup } from '@edx/paragon';
import messages from './PreferredLanguage.messages';
@@ -77,9 +77,10 @@ class PreferredLanguage extends React.Component {
editing: (
<div role="dialog" aria-labelledby={`${formId}-label`}>
<form onSubmit={this.handleSubmit}>
<Form.Group
controlId={formId}
isInvalid={error !== null}
<ValidationFormGroup
for={formId}
invalid={error !== null}
invalidMessage={error}
>
<label className="edit-section-header" htmlFor={formId}>
{intl.formatMessage(messages['profile.preferredlanguage.label'])}
@@ -97,12 +98,7 @@ class PreferredLanguage extends React.Component {
<option key={code} value={code}>{name}</option>
))}
</select>
{error !== null && (
<Form.Control.Feedback hasIcon={false}>
{error}
</Form.Control.Feedback>
)}
</Form.Group>
</ValidationFormGroup>
<FormControls
visibilityId="visibilityLanguageProficiencies"
saveState={saveState}

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, Dropdown } from '@openedx/paragon';
import { Button, Dropdown } from '@edx/paragon';
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { ReactComponent as DefaultAvatar } from '../assets/avatar.svg';

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Alert } from '@openedx/paragon';
import { StatusAlert } from '@edx/paragon';
import { connect } from 'react-redux';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTwitter, faFacebook, faLinkedin } from '@fortawesome/free-brands-svg-icons';
@@ -33,108 +33,6 @@ const platformDisplayInfo = {
},
};
const SocialLink = ({ url, name, platform }) => (
<a href={url} className="font-weight-bold">
<FontAwesomeIcon className="mr-2" icon={platformDisplayInfo[platform].icon} />
{name}
</a>
);
SocialLink.propTypes = {
url: PropTypes.string.isRequired,
platform: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
};
const EditableListItem = ({
url, platform, onClickEmptyContent, name,
}) => {
const linkDisplay = url ? (
<SocialLink name={name} url={url} platform={platform} />
) : (
<EmptyContent onClick={onClickEmptyContent}>Add {name}</EmptyContent>
);
return <li className="form-group">{linkDisplay}</li>;
};
EditableListItem.propTypes = {
url: PropTypes.string,
platform: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
onClickEmptyContent: PropTypes.func,
};
EditableListItem.defaultProps = {
url: null,
onClickEmptyContent: null,
};
const EditingListItem = ({
platform, name, value, onChange, error,
}) => (
<li className="form-group">
<label htmlFor={`social-${platform}`}>{name}</label>
<input
className={classNames('form-control', { 'is-invalid': Boolean(error) })}
type="text"
id={`social-${platform}`}
name={platform}
value={value || ''}
onChange={onChange}
aria-describedby="social-error-feedback"
/>
</li>
);
EditingListItem.propTypes = {
platform: PropTypes.string.isRequired,
value: PropTypes.string,
name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
error: PropTypes.string,
};
EditingListItem.defaultProps = {
value: null,
error: null,
};
const EmptyListItem = ({ onClick, name }) => (
<li className="mb-4">
<EmptyContent onClick={onClick}>
<FormattedMessage
id="profile.sociallinks.add"
defaultMessage="Add {network}"
values={{
network: name,
}}
description="{network} is the name of a social network such as Facebook or Twitter"
/>
</EmptyContent>
</li>
);
EmptyListItem.propTypes = {
name: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
const StaticListItem = ({ name, url, platform }) => (
<li className="mb-2">
<SocialLink name={name} url={url} platform={platform} />
</li>
);
StaticListItem.propTypes = {
name: PropTypes.string.isRequired,
url: PropTypes.string,
platform: PropTypes.string.isRequired,
};
StaticListItem.defaultProps = {
url: null,
};
class SocialLinks extends React.Component {
constructor(props) {
super(props);
@@ -167,19 +65,6 @@ class SocialLinks extends React.Component {
}
}
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
mergeWithDrafts(newSocialLink) {
const knownPlatforms = ['twitter', 'facebook', 'linkedin'];
const updated = [];
@@ -193,6 +78,19 @@ class SocialLinks extends React.Component {
return updated;
}
handleSubmit(e) {
e.preventDefault();
this.props.submitHandler(this.props.formId);
}
handleClose() {
this.props.closeHandler(this.props.formId);
}
handleOpen() {
this.props.openHandler(this.props.formId);
}
render() {
const {
socialLinks, visibilitySocialLinks, editMode, saveState, error, intl,
@@ -260,19 +158,14 @@ class SocialLinks extends React.Component {
),
editing: (
<div role="dialog" aria-labelledby="social-links-label">
<form aria-labelledby="editing-form" onSubmit={this.handleSubmit}>
<form onSubmit={this.handleSubmit}>
<EditableItemHeader
headingId="social-links-label"
content={intl.formatMessage(messages['profile.sociallinks.social.links'])}
/>
{/* TODO: Replace this alert with per-field errors. Needs API update. */}
<div id="social-error-feedback">
{error !== null
? (
<Alert variant="danger" dismissible={false} show>
{error}
</Alert>
) : null}
{error !== null ? <StatusAlert alertType="danger" dialog={error} dismissible={false} open /> : null}
</div>
<ul className="list-unstyled">
{socialLinks.map(({ platform, socialLink }) => (
@@ -345,3 +238,113 @@ export default connect(
editableFormSelector,
{},
)(injectIntl(SocialLinks));
function SocialLink({ url, name, platform }) {
return (
<a href={url} className="font-weight-bold">
<FontAwesomeIcon className="mr-2" icon={platformDisplayInfo[platform].icon} />
{name}
</a>
);
}
SocialLink.propTypes = {
url: PropTypes.string.isRequired,
platform: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
};
function EditableListItem({
url, platform, onClickEmptyContent, name,
}) {
const linkDisplay = url ? (
<SocialLink name={name} url={url} platform={platform} />
) : (
<EmptyContent onClick={onClickEmptyContent}>Add {name}</EmptyContent>
);
return <li className="form-group">{linkDisplay}</li>;
}
EditableListItem.propTypes = {
url: PropTypes.string,
platform: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
onClickEmptyContent: PropTypes.func,
};
EditableListItem.defaultProps = {
url: null,
onClickEmptyContent: null,
};
function EditingListItem({
platform, name, value, onChange, error,
}) {
return (
<li className="form-group">
<label htmlFor={`social-${platform}`}>{name}</label>
<input
className={classNames('form-control', { 'is-invalid': Boolean(error) })}
type="text"
id={`social-${platform}`}
name={platform}
value={value || ''}
onChange={onChange}
aria-describedby="social-error-feedback"
/>
</li>
);
}
EditingListItem.propTypes = {
platform: PropTypes.string.isRequired,
value: PropTypes.string,
name: PropTypes.string.isRequired,
onChange: PropTypes.func.isRequired,
error: PropTypes.string,
};
EditingListItem.defaultProps = {
value: null,
error: null,
};
function EmptyListItem({ onClick, name }) {
return (
<li className="mb-4">
<EmptyContent onClick={onClick}>
<FormattedMessage
id="profile.sociallinks.add"
defaultMessage="Add {network}"
values={{
network: name,
}}
description="{network} is the name of a social network such as Facebook or Twitter"
/>
</EmptyContent>
</li>
);
}
EmptyListItem.propTypes = {
name: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
};
function StaticListItem({ name, url, platform }) {
return (
<li className="mb-2">
<SocialLink name={name} url={url} platform={platform} />
</li>
);
}
StaticListItem.propTypes = {
name: PropTypes.string.isRequired,
url: PropTypes.string,
platform: PropTypes.string.isRequired,
};
StaticListItem.defaultProps = {
url: null,
};

View File

@@ -1,165 +0,0 @@
import { render, fireEvent, screen } from '@testing-library/react';
import PropTypes from 'prop-types';
import React, { useMemo } from 'react';
import { Provider } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { configure as configureI18n, IntlProvider } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { AppContext } from '@edx/frontend-platform/react';
import SocialLinks from './SocialLinks';
import * as savingEditedBio from '../__mocks__/savingEditedBio.mockStore';
import messages from '../../i18n';
const mockStore = configureMockStore([thunk]);
const defaultProps = {
formId: 'socialLinks',
socialLinks: [
{
platform: 'facebook',
socialLink: 'https://www.facebook.com/aloha',
},
{
platform: 'twitter',
socialLink: 'https://www.twitter.com/ALOHA',
},
],
drafts: {},
visibilitySocialLinks: 'private',
editMode: 'static',
saveState: null,
error: null,
changeHandler: jest.fn(),
submitHandler: jest.fn(),
closeHandler: jest.fn(),
openHandler: jest.fn(),
};
configureI18n({
loggingService: { logError: jest.fn() },
config: {
ENVIRONMENT: 'production',
LANGUAGE_PREFERENCE_COOKIE_NAME: 'yum',
},
messages,
});
const SocialLinksWrapper = (props) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
}), []);
return (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={props.store}>
<SocialLinks {...props} />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
};
SocialLinksWrapper.defaultProps = {
store: mockStore(savingEditedBio),
};
SocialLinksWrapper.propTypes = {
store: PropTypes.shape({}),
};
const SocialLinksWrapperWithStore = ({ store }) => {
const contextValue = useMemo(() => ({
authenticatedUser: { userId: null, username: null, administrator: false },
config: getConfig(),
}), []);
return (
<AppContext.Provider
value={contextValue}
>
<IntlProvider locale="en">
<Provider store={mockStore(store)}>
<SocialLinks {...defaultProps} formId="bio" />
</Provider>
</IntlProvider>
</AppContext.Provider>
);
};
SocialLinksWrapperWithStore.defaultProps = {
store: mockStore(savingEditedBio),
};
SocialLinksWrapperWithStore.propTypes = {
store: PropTypes.shape({}),
};
describe('<SocialLinks />', () => {
['certificates', 'bio', 'goals', 'socialLinks'].forEach(editMode => (
it(`calls social links with edit mode ${editMode}`, () => {
const component = <SocialLinksWrapper {...defaultProps} formId={editMode} />;
const { container: tree } = render(component);
expect(tree).toMatchSnapshot();
})
));
it('calls social links with editing', () => {
const changeHandler = jest.fn();
const submitHandler = jest.fn();
const closeHandler = jest.fn();
const { container } = render(
<SocialLinksWrapper
{...defaultProps}
formId="bio"
changeHandler={changeHandler}
submitHandler={submitHandler}
closeHandler={closeHandler}
/>,
);
const { platform } = defaultProps.socialLinks[0];
const inputField = container.querySelector(`#social-${platform}`);
fireEvent.change(inputField, { target: { value: 'test', name: platform } });
expect(changeHandler).toHaveBeenCalledTimes(1);
const selectElement = container.querySelector('#visibilitySocialLinks');
expect(selectElement.value).toBe('private');
fireEvent.change(selectElement, { target: { value: 'all_users', name: 'visibilitySocialLinks' } });
expect(changeHandler).toHaveBeenCalledTimes(2);
fireEvent.submit(container.querySelector('[aria-labelledby="editing-form"]'));
expect(submitHandler).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(closeHandler).toHaveBeenCalledTimes(1);
});
it('calls social links with static', () => {
const openHandler = jest.fn();
render(
<SocialLinksWrapper
{...defaultProps}
formId="goals"
openHandler={openHandler}
/>,
);
const addFacebookButton = screen.getByRole('button', { name: 'Add Facebook' });
fireEvent.click(addFacebookButton);
expect(openHandler).toHaveBeenCalledTimes(1);
});
it('calls social links with error', () => {
const newStore = JSON.parse(JSON.stringify(savingEditedBio));
newStore.profilePage.errors.bio = { userMessage: 'error' };
const { container } = render(<SocialLinksWrapperWithStore store={newStore} />);
const alertDanger = container.querySelector('.alert-danger');
expect(alertDanger).toBeInTheDocument();
});
});

View File

@@ -1,504 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<SocialLinks /> calls social links with edit mode bio 1`] = `
<div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
aria-labelledby="social-links-label"
role="dialog"
>
<form
aria-labelledby="editing-form"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
id="social-links-label"
>
Social Links
</h2>
</div>
<div
id="social-error-feedback"
/>
<ul
class="list-unstyled"
>
<li
class="form-group"
>
<label
for="social-facebook"
>
Facebook
</label>
<input
aria-describedby="social-error-feedback"
class="form-control"
id="social-facebook"
name="facebook"
type="text"
value="https://www.facebook.com/aloha"
/>
</li>
<li
class="form-group"
>
<label
for="social-twitter"
>
Twitter
</label>
<input
aria-describedby="social-error-feedback"
class="form-control"
id="social-twitter"
name="twitter"
type="text"
value="https://www.twitter.com/ALOHA"
/>
</li>
</ul>
<div
class="d-flex flex-row-reverse flex-wrap justify-content-end align-items-center"
>
<div
class="form-group d-flex flex-wrap"
>
<label
class="col-form-label"
for="visibilitySocialLinks"
>
Who can see this:
</label>
<span
class="d-flex align-items-center"
>
<span
class="d-inline-block ml-1 mr-2"
style="width: 1.5rem;"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye-slash "
data-icon="eye-slash"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zm151 118.3C226 97.7 269.5 80 320 80c65.2 0 118.8 29.6 159.9 67.7C518.4 183.5 545 226 558.6 256c-12.6 28-36.6 66.8-70.9 100.9l-53.8-42.2c9.1-17.6 14.2-37.5 14.2-58.7c0-70.7-57.3-128-128-128c-32.2 0-61.7 11.9-84.2 31.5l-46.1-36.1zM394.9 284.2l-81.5-63.9c4.2-8.5 6.6-18.2 6.6-28.3c0-5.5-.7-10.9-2-16c.7 0 1.3 0 2 0c44.2 0 80 35.8 80 80c0 9.9-1.8 19.4-5.1 28.2zm9.4 130.3C378.8 425.4 350.7 432 320 432c-65.2 0-118.8-29.6-159.9-67.7C121.6 328.5 95 286 81.4 256c8.3-18.4 21.5-41.5 39.4-64.8L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5l-41.9-33zM192 256c0 70.7 57.3 128 128 128c13.3 0 26.1-2 38.2-5.8L302 334c-23.5-5.4-43.1-21.2-53.7-42.3l-56.1-44.2c-.2 2.8-.3 5.6-.3 8.5z"
fill="currentColor"
/>
</svg>
</span>
<select
class="d-inline-block form-control"
id="visibilitySocialLinks"
name="visibilitySocialLinks"
type="select"
>
<option
value="private"
>
Just me
</option>
<option
value="all_users"
>
Everyone on localhost
</option>
</select>
</span>
</div>
<div
class="form-group flex-shrink-0 flex-grow-1"
>
<button
aria-disabled="false"
aria-live="assertive"
class="pgn__stateful-btn pgn__stateful-btn-state-pending btn btn-primary"
type="submit"
>
<span
class="d-flex align-items-center justify-content-center"
>
<span
class="pgn__stateful-btn-icon"
>
<span
class="pgn__icon icon-spin"
>
<svg
aria-hidden="true"
fill="none"
focusable="false"
height="24"
role="img"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M22 12A10 10 0 1 1 6.122 3.91l1.176 1.618A8 8 0 1 0 20 12h2Z"
fill="currentColor"
/>
</svg>
</span>
</span>
<span>
Saving
</span>
</span>
</button>
<button
class="btn btn-link"
type="button"
>
Cancel
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
`;
exports[`<SocialLinks /> calls social links with edit mode certificates 1`] = `
<div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Social Links
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye-slash "
data-icon="eye-slash"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zm151 118.3C226 97.7 269.5 80 320 80c65.2 0 118.8 29.6 159.9 67.7C518.4 183.5 545 226 558.6 256c-12.6 28-36.6 66.8-70.9 100.9l-53.8-42.2c9.1-17.6 14.2-37.5 14.2-58.7c0-70.7-57.3-128-128-128c-32.2 0-61.7 11.9-84.2 31.5l-46.1-36.1zM394.9 284.2l-81.5-63.9c4.2-8.5 6.6-18.2 6.6-28.3c0-5.5-.7-10.9-2-16c.7 0 1.3 0 2 0c44.2 0 80 35.8 80 80c0 9.9-1.8 19.4-5.1 28.2zm9.4 130.3C378.8 425.4 350.7 432 320 432c-65.2 0-118.8-29.6-159.9-67.7C121.6 328.5 95 286 81.4 256c8.3-18.4 21.5-41.5 39.4-64.8L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5l-41.9-33zM192 256c0 70.7 57.3 128 128 128c13.3 0 26.1-2 38.2-5.8L302 334c-23.5-5.4-43.1-21.2-53.7-42.3l-56.1-44.2c-.2 2.8-.3 5.6-.3 8.5z"
fill="currentColor"
/>
</svg>
Just me
</span>
</p>
</div>
<ul
class="list-unstyled"
>
<li
class="form-group"
>
<a
class="font-weight-bold"
href="https://www.facebook.com/aloha"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-facebook mr-2"
data-icon="facebook"
data-prefix="fab"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M512 256C512 114.6 397.4 0 256 0S0 114.6 0 256C0 376 82.7 476.8 194.2 504.5V334.2H141.4V256h52.8V222.3c0-87.1 39.4-127.5 125-127.5c16.2 0 44.2 3.2 55.7 6.4V172c-6-.6-16.5-1-29.6-1c-42 0-58.2 15.9-58.2 57.2V256h83.6l-14.4 78.2H287V510.1C413.8 494.8 512 386.9 512 256h0z"
fill="currentColor"
/>
</svg>
Facebook
</a>
</li>
<li
class="form-group"
>
<a
class="font-weight-bold"
href="https://www.twitter.com/ALOHA"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-twitter mr-2"
data-icon="twitter"
data-prefix="fab"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"
fill="currentColor"
/>
</svg>
Twitter
</a>
</li>
</ul>
</div>
</div>
</div>
`;
exports[`<SocialLinks /> calls social links with edit mode goals 1`] = `
<div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Social Links
</h2>
</div>
<ul
class="list-unstyled"
>
<li
class="mb-4"
>
<div>
<button
class="pl-0 text-left btn btn-link"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-plus fa-xs mr-2"
data-icon="plus"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"
fill="currentColor"
/>
</svg>
Add Facebook
</button>
</div>
</li>
<li
class="mb-4"
>
<div>
<button
class="pl-0 text-left btn btn-link"
tabindex="0"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-plus fa-xs mr-2"
data-icon="plus"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 448 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M256 80c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 144L48 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l144 0 0 144c0 17.7 14.3 32 32 32s32-14.3 32-32l0-144 144 0c17.7 0 32-14.3 32-32s-14.3-32-32-32l-144 0 0-144z"
fill="currentColor"
/>
</svg>
Add Twitter
</button>
</div>
</li>
</ul>
</div>
</div>
</div>
`;
exports[`<SocialLinks /> calls social links with edit mode socialLinks 1`] = `
<div>
<div
class="pgn-transition-replace-group position-relative mb-5"
>
<div
style="padding: .1px 0px;"
>
<div
class="editable-item-header mb-2"
>
<h2
class="edit-section-header"
>
Social Links
<button
class="float-right px-0 btn btn-link btn-sm"
style="margin-top: -.35rem;"
type="button"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-pencil mr-1"
data-icon="pencil"
data-prefix="fas"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M410.3 231l11.3-11.3-33.9-33.9-62.1-62.1L291.7 89.8l-11.3 11.3-22.6 22.6L58.6 322.9c-10.4 10.4-18 23.3-22.2 37.4L1 480.7c-2.5 8.4-.2 17.5 6.1 23.7s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L387.7 253.7 410.3 231zM160 399.4l-9.1 22.7c-4 3.1-8.5 5.4-13.3 6.9L59.4 452l23-78.1c1.4-4.9 3.8-9.4 6.9-13.3l22.7-9.1 0 32c0 8.8 7.2 16 16 16l32 0zM362.7 18.7L348.3 33.2 325.7 55.8 314.3 67.1l33.9 33.9 62.1 62.1 33.9 33.9 11.3-11.3 22.6-22.6 14.5-14.5c25-25 25-65.5 0-90.5L453.3 18.7c-25-25-65.5-25-90.5 0zm-47.4 168l-144 144c-6.2 6.2-16.4 6.2-22.6 0s-6.2-16.4 0-22.6l144-144c6.2-6.2 16.4-6.2 22.6 0s6.2 16.4 0 22.6z"
fill="currentColor"
/>
</svg>
Edit
</button>
</h2>
<p
class="mb-0"
>
<span
class="ml-auto small text-muted"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-eye-slash "
data-icon="eye-slash"
data-prefix="far"
focusable="false"
role="img"
viewBox="0 0 640 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2S-1.2 34.7 9.2 42.9l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L525.6 386.7c39.6-40.6 66.4-86.1 79.9-118.4c3.3-7.9 3.3-16.7 0-24.6c-14.9-35.7-46.2-87.7-93-131.1C465.5 68.8 400.8 32 320 32c-68.2 0-125 26.3-169.3 60.8L38.8 5.1zm151 118.3C226 97.7 269.5 80 320 80c65.2 0 118.8 29.6 159.9 67.7C518.4 183.5 545 226 558.6 256c-12.6 28-36.6 66.8-70.9 100.9l-53.8-42.2c9.1-17.6 14.2-37.5 14.2-58.7c0-70.7-57.3-128-128-128c-32.2 0-61.7 11.9-84.2 31.5l-46.1-36.1zM394.9 284.2l-81.5-63.9c4.2-8.5 6.6-18.2 6.6-28.3c0-5.5-.7-10.9-2-16c.7 0 1.3 0 2 0c44.2 0 80 35.8 80 80c0 9.9-1.8 19.4-5.1 28.2zm9.4 130.3C378.8 425.4 350.7 432 320 432c-65.2 0-118.8-29.6-159.9-67.7C121.6 328.5 95 286 81.4 256c8.3-18.4 21.5-41.5 39.4-64.8L83.1 161.5C60.3 191.2 44 220.8 34.5 243.7c-3.3 7.9-3.3 16.7 0 24.6c14.9 35.7 46.2 87.7 93 131.1C174.5 443.2 239.2 480 320 480c47.8 0 89.9-12.9 126.2-32.5l-41.9-33zM192 256c0 70.7 57.3 128 128 128c13.3 0 26.1-2 38.2-5.8L302 334c-23.5-5.4-43.1-21.2-53.7-42.3l-56.1-44.2c-.2 2.8-.3 5.6-.3 8.5z"
fill="currentColor"
/>
</svg>
Just me
</span>
</p>
</div>
<ul
class="list-unstyled"
>
<li
class="form-group"
>
<a
class="font-weight-bold"
href="https://www.facebook.com/aloha"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-facebook mr-2"
data-icon="facebook"
data-prefix="fab"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M512 256C512 114.6 397.4 0 256 0S0 114.6 0 256C0 376 82.7 476.8 194.2 504.5V334.2H141.4V256h52.8V222.3c0-87.1 39.4-127.5 125-127.5c16.2 0 44.2 3.2 55.7 6.4V172c-6-.6-16.5-1-29.6-1c-42 0-58.2 15.9-58.2 57.2V256h83.6l-14.4 78.2H287V510.1C413.8 494.8 512 386.9 512 256h0z"
fill="currentColor"
/>
</svg>
Facebook
</a>
</li>
<li
class="form-group"
>
<a
class="font-weight-bold"
href="https://www.twitter.com/ALOHA"
>
<svg
aria-hidden="true"
class="svg-inline--fa fa-twitter mr-2"
data-icon="twitter"
data-prefix="fab"
focusable="false"
role="img"
viewBox="0 0 512 512"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"
fill="currentColor"
/>
</svg>
Twitter
</a>
</li>
</ul>
</div>
</div>
</div>
`;

View File

@@ -3,24 +3,26 @@ import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPencilAlt } from '@fortawesome/free-solid-svg-icons';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { Button } from '@edx/paragon';
import messages from './EditButton.messages';
const EditButton = ({
function EditButton({
onClick, className, style, intl,
}) => (
<Button
variant="link"
size="sm"
className={className}
onClick={onClick}
style={style}
>
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />
{intl.formatMessage(messages['profile.editbutton.edit'])}
</Button>
);
}) {
return (
<Button
variant="link"
size="sm"
className={className}
onClick={onClick}
style={style}
>
<FontAwesomeIcon className="mr-1" icon={faPencilAlt} />
{intl.formatMessage(messages['profile.editbutton.edit'])}
</Button>
);
}
export default injectIntl(EditButton);

View File

@@ -4,22 +4,26 @@ import PropTypes from 'prop-types';
import EditButton from './EditButton';
import { Visibility } from './Visibility';
const EditableItemHeader = ({
function EditableItemHeader({
content,
showVisibility,
visibility,
showEditButton,
onClickEdit,
headingId,
}) => (
<div className="editable-item-header mb-2">
<h2 className="edit-section-header" id={headingId}>
{content}
{showEditButton ? <EditButton style={{ marginTop: '-.35rem' }} className="float-right px-0" onClick={onClickEdit} /> : null}
</h2>
{showVisibility ? <p className="mb-0"><Visibility to={visibility} /></p> : null}
</div>
);
}) {
return (
<>
<div className="editable-item-header mb-2">
<h2 className="edit-section-header" id={headingId}>
{content}
{showEditButton ? <EditButton style={{ marginTop: '-.35rem' }} className="float-right px-0" onClick={onClickEdit} /> : null}
</h2>
{showVisibility ? <p className="mb-0"><Visibility to={visibility} /></p> : null}
</div>
</>
);
}
export default EditableItemHeader;

View File

@@ -3,22 +3,24 @@ import PropTypes from 'prop-types';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus } from '@fortawesome/free-solid-svg-icons';
const EmptyContent = ({ children, onClick, showPlusIcon }) => (
<div>
{onClick ? (
<button
type="button"
className="pl-0 text-left btn btn-link"
onClick={onClick}
onKeyDown={(e) => { if (e.key === 'Enter') { onClick(); } }}
tabIndex={0}
>
{showPlusIcon ? <FontAwesomeIcon size="xs" className="mr-2" icon={faPlus} /> : null}
{children}
</button>
) : children}
</div>
);
function EmptyContent({ children, onClick, showPlusIcon }) {
return (
<div>
{onClick ? (
<button
type="button"
className="pl-0 text-left btn btn-link"
onClick={onClick}
onKeyDown={(e) => { if (e.key === 'Enter') { onClick(); } }}
tabIndex={0}
>
{showPlusIcon ? <FontAwesomeIcon size="xs" className="mr-2" icon={faPlus} /> : null}
{children}
</button>
) : children}
</div>
);
}
export default EmptyContent;

View File

@@ -1,15 +1,15 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Button, StatefulButton } from '@openedx/paragon';
import { Button, StatefulButton } from '@edx/paragon';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import messages from './FormControls.messages';
import { VisibilitySelect } from './Visibility';
const FormControls = ({
function FormControls({
cancelHandler, changeHandler, visibility, visibilityId, saveState, intl,
}) => {
}) {
// Eliminate error/failed state for save button
const buttonState = saveState === 'error' ? null : saveState;
@@ -57,7 +57,7 @@ const FormControls = ({
</div>
</div>
);
};
}
export default injectIntl(FormControls);

View File

@@ -1,6 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import { TransitionReplace } from '@openedx/paragon';
import { TransitionReplace } from '@edx/paragon';
const onChildExit = (htmlNode) => {
// If the leaving child has focus, take control and redirect it
@@ -22,7 +22,7 @@ const onChildExit = (htmlNode) => {
}
};
const SwitchContent = ({ expression, cases, className }) => {
function SwitchContent({ expression, cases, className }) {
const getContent = (caseKey) => {
if (cases[caseKey]) {
if (typeof cases[caseKey] === 'string') {
@@ -48,7 +48,7 @@ const SwitchContent = ({ expression, cases, className }) => {
{getContent(expression)}
</TransitionReplace>
);
};
}
SwitchContent.propTypes = {
expression: PropTypes.string,

View File

@@ -1,24 +1,23 @@
import React from 'react';
import PropTypes from 'prop-types';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faEyeSlash, faEye } from '@fortawesome/free-regular-svg-icons';
import messages from './Visibility.messages';
const Visibility = ({ to, intl }) => {
function Visibility({ to, intl }) {
const icon = to === 'private' ? faEyeSlash : faEye;
const label = to === 'private'
? intl.formatMessage(messages['profile.visibility.who.just.me'])
: intl.formatMessage(messages['profile.visibility.who.everyone'], { siteName: getConfig().SITE_NAME });
: intl.formatMessage(messages['profile.visibility.who.everyone']);
return (
<span className="ml-auto small text-muted">
<FontAwesomeIcon icon={icon} /> {label}
</span>
);
};
}
Visibility.propTypes = {
to: PropTypes.oneOf(['private', 'all_users']),
@@ -30,7 +29,7 @@ Visibility.defaultProps = {
to: 'private',
};
const VisibilitySelect = ({ intl, className, ...props }) => {
function VisibilitySelect({ intl, className, ...props }) {
const { value } = props;
const icon = value === 'private' ? faEyeSlash : faEye;
@@ -39,17 +38,17 @@ const VisibilitySelect = ({ intl, className, ...props }) => {
<span className="d-inline-block ml-1 mr-2" style={{ width: '1.5rem' }}>
<FontAwesomeIcon icon={icon} />
</span>
<select className="d-inline-block form-control" {...props}>
<select className="d-inline-block w-auto form-control" {...props}>
<option key="private" value="private">
{intl.formatMessage(messages['profile.visibility.who.just.me'])}
</option>
<option key="all_users" value="all_users">
{intl.formatMessage(messages['profile.visibility.who.everyone'], { siteName: getConfig().SITE_NAME })}
{intl.formatMessage(messages['profile.visibility.who.everyone'])}
</option>
</select>
</span>
);
};
}
VisibilitySelect.propTypes = {
id: PropTypes.string,

View File

@@ -8,7 +8,7 @@ const messages = defineMessages({
},
'profile.visibility.who.everyone': {
id: 'profile.visibility.who.everyone',
defaultMessage: 'Everyone on {siteName}',
defaultMessage: 'Everyone on edX',
description: 'What users can see this area?',
},
});

View File

@@ -23,31 +23,9 @@
background-size: auto 85%;
}
.username-description {
width: auto;
position: absolute;
left: 1.5rem;
top: 5.25rem;
color: var(--pgn-color-gray-500);
line-height: 0.9rem;
font-size: 0.8rem;
font-style: normal;
font-weight: 400;
margin-left: 0.9rem;
}
.mb-2rem {
margin-bottom: 2rem;
}
.icon-visibility-off {
height: 1rem;
color: var(--pgn-color-gray-500);
}
.profile-page {
.edit-section-header {
font-size: var(--pgn-typography-font-size-h6-base);
@extend .h6;
display: block;
font-weight: normal;
letter-spacing: 0;
@@ -55,11 +33,11 @@
}
label.edit-section-header {
margin-bottom: calc(var(--pgn-spacing-spacer-base) * .5);
margin-bottom: $spacer * .5;
}
.profile-avatar-wrap {
@media (--pgn-size-breakpoint-min-width-md) {
@include media-breakpoint-up(md) {
max-width: 12rem;
margin-right: 0;
margin-top: -8rem;
@@ -77,25 +55,25 @@
align-items: center;
border-radius: 50%;
@media (--pgn-size-breakpoint-min-width-md) {
@include media-breakpoint-up(md) {
background: linear-gradient(to top, rgba(0,0,0,.65) 4rem, rgba(0,0,0,0) 4rem);
align-items: flex-end;
}
.btn {
text-decoration: none;
@media (--pgn-size-breakpoint-min-width-md) {
@include media-breakpoint-up(md) {
margin-bottom: 1.2rem;
}
}
.dropdown {
@media (--pgn-size-breakpoint-min-width-md) {
@include media-breakpoint-up(md) {
margin-bottom: 1.2rem;
}
.btn {
color: var(--pgn-color-white);
color: $white;
background: transparent;
border-color: transparent;
margin: 0;
@@ -108,7 +86,7 @@
height: 5rem;
position: relative;
@media (--pgn-size-breakpoint-min-width-md) {
@include media-breakpoint-up(md) {
width: 12rem;
height: 12rem;
}
@@ -128,7 +106,7 @@
border-radius:0;
transition: opacity 200ms ease;
@media (--pgn-size-breakpoint-min-width-md) {
@include media-breakpoint-up(md) {
height: 4rem;
}
@@ -142,7 +120,7 @@
position: relative;
.certificate-title {
font-family: var(--pgn-typography-font-family-serif);
font-family: $font-family-serif;
font-weight: 400;
}

View File

@@ -1,21 +0,0 @@
import React from 'react';
import {
AuthenticatedPageRoute,
PageWrap,
} from '@edx/frontend-platform/react';
import { Routes, Route, useNavigate } from 'react-router-dom';
import { ProfilePage, NotFoundPage } from '../profile';
const AppRoutes = () => {
const navigate = useNavigate();
return (
<Routes>
<Route path="/u/:username" element={<AuthenticatedPageRoute><ProfilePage navigate={navigate} /></AuthenticatedPageRoute>} />
<Route path="/notfound" element={<PageWrap><NotFoundPage /></PageWrap>} />
<Route path="*" element={<PageWrap><NotFoundPage /></PageWrap>} />
</Routes>
);
};
export default AppRoutes;

View File

@@ -1,63 +0,0 @@
import React from 'react';
import { AppContext } from '@edx/frontend-platform/react';
import { getConfig } from '@edx/frontend-platform';
import { MemoryRouter as Router } from 'react-router-dom';
import { render, screen } from '@testing-library/react';
import { getLoginRedirectUrl } from '@edx/frontend-platform/auth';
import AppRoutes from './AppRoutes';
jest.mock('@edx/frontend-platform/analytics');
jest.mock('@edx/frontend-platform/auth', () => ({
getLoginRedirectUrl: jest.fn(),
}));
jest.mock('../profile', () => ({
ProfilePage: () => (<div>Profile page</div>),
NotFoundPage: () => (<div>Not found page</div>),
}));
const RoutesWithProvider = (context, path) => (
<AppContext.Provider value={context}>
<Router initialEntries={[`${path}`]}>
<AppRoutes />
</Router>
</AppContext.Provider>
);
const unauthenticatedUser = {
authenticatedUser: null,
config: getConfig(),
};
describe('routes', () => {
test('Profile page should redirect for unauthenticated users', () => {
render(
RoutesWithProvider(unauthenticatedUser, '/u/edx'),
);
expect(getLoginRedirectUrl).toHaveBeenCalled();
});
test('Profile page should be accessible for authenticated users', () => {
render(
RoutesWithProvider(
{
authenticatedUser: {
username: 'edx',
email: 'edx@example.com',
},
config: getConfig(),
},
'/u/edx',
),
);
expect(screen.getByText('Profile page')).toBeTruthy();
});
test('should show NotFound page for a bad route', () => {
render(
RoutesWithProvider(unauthenticatedUser, '/nonMatchingRoute'),
);
expect(screen.getByText('Not found page')).toBeTruthy();
});
});

View File

@@ -1,4 +1,7 @@
/* eslint-disable import/no-extraneous-dependencies */
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import '@testing-library/jest-dom';
import Enzyme from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
Enzyme.configure({ adapter: new Adapter() });

View File

@@ -1,10 +0,0 @@
import React from 'react';
import { useParams } from 'react-router-dom';
const withParams = (WrappedComponent) => {
const WithParamsComponent = (props) => <WrappedComponent params={useParams()} {...props} />;
return WithParamsComponent;
};
export default withParams;