diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f6d8128e5..378593397f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Add ready only user profile in the learner dashboard preferences page. + This profile display data from LMS profile. - Add search bar on learner dashboard courses pages. - Add a `CertificateHelper` implementing a `getCourse` method - Add search bar on teacher dashboard courses pages. diff --git a/src/frontend/js/api/lms/dummy.ts b/src/frontend/js/api/lms/dummy.ts index 41617c3064..7ebfe10692 100644 --- a/src/frontend/js/api/lms/dummy.ts +++ b/src/frontend/js/api/lms/dummy.ts @@ -6,6 +6,7 @@ import { UnknownEnrollment, OpenEdXEnrollment } from 'types'; import { location } from 'utils/indirection/window'; import { CURRENT_JOANIE_DEV_DEMO_USER, RICHIE_USER_TOKEN } from 'settings'; import { base64Decode } from 'utils/base64Parser'; +import { Gender, LanguageIsoCode, LevelOfEducation, OpenEdxApiProfile } from 'types/openEdx'; type JWTPayload = { email: string; @@ -89,6 +90,21 @@ const API = (APIConf: LMSBackend | AuthenticationBackend): APILms => { localStorage.removeItem(RICHIE_DUMMY_IS_LOGGED_IN); }, accessToken: () => sessionStorage.getItem(RICHIE_USER_TOKEN), + account: { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + get: (username: string) => { + return Promise.resolve({ + username: 'j_do', + name: 'John Do', + email: 'j.do@whois.net', + country: 'fr', + level_of_education: LevelOfEducation.MASTER_OR_PROFESSIONNAL_DEGREE, + gender: Gender.MALE, + year_of_birth: '1971', + language_proficiencies: [{ code: LanguageIsoCode.ENGLISH }], + } as unknown as OpenEdxApiProfile); + }, + }, }, enrollment: { get: async (url: string, user: Nullable) => diff --git a/src/frontend/js/api/lms/openedx-fonzie.spec.ts b/src/frontend/js/api/lms/openedx-fonzie.spec.ts index 3fb479f369..1c76d9fa33 100644 --- a/src/frontend/js/api/lms/openedx-fonzie.spec.ts +++ b/src/frontend/js/api/lms/openedx-fonzie.spec.ts @@ -1,6 +1,7 @@ import { faker } from '@faker-js/faker'; import fetchMock from 'fetch-mock'; import { RICHIE_USER_TOKEN } from 'settings'; +import { OpenEdxApiProfileFactory } from 'utils/test/factories/openEdx'; import FonzieAPIInterface from './openedx-fonzie'; jest.mock('utils/context', () => ({ @@ -30,6 +31,17 @@ describe('Fonzie API', () => { await expect(api.user.me()).resolves.toEqual(user); }); + it('uses its own route to get user profile', async () => { + const openEdxProfile = OpenEdxApiProfileFactory().one(); + fetchMock.get( + `https://demo.endpoint.api/api/user/v1/accounts/${openEdxProfile.username}`, + openEdxProfile, + ); + + const api = FonzieAPIInterface(configuration); + expect(await api.user.account!.get(openEdxProfile.username)).toEqual(openEdxProfile); + }); + it('is able to retrieve access token within the session storage', () => { const accessToken = faker.string.uuid(); sessionStorage.setItem(RICHIE_USER_TOKEN, accessToken); diff --git a/src/frontend/js/api/lms/openedx-fonzie.ts b/src/frontend/js/api/lms/openedx-fonzie.ts index ea960ac364..107301b9e8 100644 --- a/src/frontend/js/api/lms/openedx-fonzie.ts +++ b/src/frontend/js/api/lms/openedx-fonzie.ts @@ -1,6 +1,9 @@ import { AuthenticationBackend, LMSBackend } from 'types/commonDataProps'; import { APILms } from 'types/api'; import { RICHIE_USER_TOKEN } from 'settings'; +import { HttpError, HttpStatusCode } from 'utils/errors/HttpError'; +import { handle } from 'utils/errors/handle'; +import { OpenEdxApiProfile } from 'types/openEdx'; import OpenEdxHawthornApiInterface from './openedx-hawthorn'; /** @@ -24,6 +27,9 @@ const API = (APIConf: AuthenticationBackend | LMSBackend): APILms => { routes: { user: { me: `${APIConf.endpoint}/api/v1.0/user/me`, + account: { + get: `${APIConf.endpoint}/api/user/v1/accounts/:username`, + }, }, }, }; @@ -36,6 +42,19 @@ const API = (APIConf: AuthenticationBackend | LMSBackend): APILms => { accessToken: () => { return sessionStorage.getItem(RICHIE_USER_TOKEN); }, + account: { + get: (username: string) => { + return fetch(APIOptions.routes.user.account.get.replace(':username', username), { + credentials: 'include', + }).then((response) => { + if (response.ok) return response.json() as unknown as OpenEdxApiProfile; + if (response.status >= HttpStatusCode.INTERNAL_SERVER_ERROR) { + handle(new Error(`[GET - Account] > ${response.status} - ${response.statusText}`)); + } + throw new HttpError(response.status, response.statusText); + }); + }, + }, }, }; }; diff --git a/src/frontend/js/components/Form/Form/_styles.scss b/src/frontend/js/components/Form/Form/_styles.scss index 6b2da81978..5ca523955d 100644 --- a/src/frontend/js/components/Form/Form/_styles.scss +++ b/src/frontend/js/components/Form/Form/_styles.scss @@ -25,4 +25,8 @@ flex-direction: column; } } + + &-footer { + margin-top: $vertical-spacing; + } } diff --git a/src/frontend/js/components/Form/Form/index.tsx b/src/frontend/js/components/Form/Form/index.tsx index 9e902f9646..c5f6d7335a 100644 --- a/src/frontend/js/components/Form/Form/index.tsx +++ b/src/frontend/js/components/Form/Form/index.tsx @@ -20,4 +20,8 @@ Form.Row = ({ children, className }: PropsWithChildren<{ className?: string }>) return
{children}
; }; +Form.Footer = ({ children, className }: PropsWithChildren<{ className?: string }>) => { + return
{children}
; +}; + export default Form; diff --git a/src/frontend/js/hooks/useOpenEdxProfile/index.ts b/src/frontend/js/hooks/useOpenEdxProfile/index.ts new file mode 100644 index 0000000000..99cbdde172 --- /dev/null +++ b/src/frontend/js/hooks/useOpenEdxProfile/index.ts @@ -0,0 +1,45 @@ +import { useCallback, useState } from 'react'; +import { defineMessages, useIntl } from 'react-intl'; +import { AuthenticationApi } from 'api/authentication'; +import { useSessionQuery } from 'utils/react-query/useSessionQuery'; +import { OpenEdxProfile, parseOpenEdxApiProfile } from './utils'; + +const messages = defineMessages({ + errorGet: { + id: 'hooks.useOpenEdxProfile.errorGet', + description: 'Error message shown to the user when openEdx profile fetch request fails.', + defaultMessage: 'An error occurred while fetching your profile. Please retry later.', + }, +}); + +interface UseOpenEdxProfileProps { + username: string; +} + +const useOpenEdxProfile = ({ username }: UseOpenEdxProfileProps) => { + if (!AuthenticationApi) { + throw new Error('AuthenticationApi is not defined'); + } + + if (!AuthenticationApi!.account) { + throw new Error('Current AuthenticationApi do not support account request'); + } + + const intl = useIntl(); + const [error, setError] = useState(); + + const queryFn: () => Promise = useCallback(async () => { + try { + const openEdxApiProfile = await AuthenticationApi!.account!.get(username); + return parseOpenEdxApiProfile(intl, openEdxApiProfile); + } catch { + setError(intl.formatMessage(messages.errorGet)); + } + return Promise.reject(); + }, [username]); + + const [queryHandler] = useSessionQuery(['open-edx-profile'], queryFn); + return { data: queryHandler.data, error }; +}; + +export default useOpenEdxProfile; diff --git a/src/frontend/js/hooks/useOpenEdxProfile/utils/index.ts b/src/frontend/js/hooks/useOpenEdxProfile/utils/index.ts new file mode 100644 index 0000000000..8811dd57de --- /dev/null +++ b/src/frontend/js/hooks/useOpenEdxProfile/utils/index.ts @@ -0,0 +1,129 @@ +import { IntlShape, defineMessages } from 'react-intl'; +import countries from 'i18n-iso-countries'; +import { Gender, LevelOfEducation, OpenEdxApiProfile } from 'types/openEdx'; +import { Maybe } from 'types/utils'; + +const levelOfEducationMessages = defineMessages({ + [LevelOfEducation.MASTER_OR_PROFESSIONNAL_DEGREE]: { + id: 'openEdxProfile.levelOfEducation.masterOrProfessionnalDegree', + description: + 'Translation for level of education "master or professional degree" in openEdx profile', + defaultMessage: 'Master', + }, + [LevelOfEducation.PHD_OR_DOCTORATE]: { + id: 'openEdxProfile.levelOfEducation.phdOrDoctorate', + description: 'Translation for level of education "phd or doctorate" in openEdx profile', + defaultMessage: 'PHD', + }, + [LevelOfEducation.BACHELOR_DEGREE]: { + id: 'openEdxProfile.levelOfEducation.bachelorDegree', + description: 'Translation for level of education "bachelor degree" in openEdx profile', + defaultMessage: 'Bachelor degree', + }, + [LevelOfEducation.ASSOCIATE_DEGREE]: { + id: 'openEdxProfile.levelOfEducation.associateDegree', + description: 'Translation for level of education "associate degree" in openEdx profile', + defaultMessage: 'Associate degree', + }, + [LevelOfEducation.SECONDARY_OR_HIGH_SCHOOL]: { + id: 'openEdxProfile.levelOfEducation.secondaryOrHighSchool', + description: 'Translation for level of education "secondary or high school" in openEdx profile', + defaultMessage: 'Secondary or high school', + }, + [LevelOfEducation.JUNIOR_SECONDARY_OR_MIDDLE_SCHOOL]: { + id: 'openEdxProfile.levelOfEducation.juniorSecondaryOrMiddleSchool', + description: + 'Translation for level of education "junior secondary or middle school" in openEdx profile', + defaultMessage: 'Junior secondary or middle school', + }, + [LevelOfEducation.ELEMENTARY_PRIMARY_SCHOOL]: { + id: 'openEdxProfile.levelOfEducation.elementaryPrimarySchool', + description: + 'Translation for level of education "elementary primary school" in openEdx profile', + defaultMessage: 'Elementary primary school', + }, + [LevelOfEducation.NONE]: { + id: 'openEdxProfile.levelOfEducation.none', + description: 'Translation for level of education "none" in openEdx profile', + defaultMessage: 'None', + }, + [LevelOfEducation.OTHER]: { + id: 'openEdxProfile.levelOfEducation.other', + description: 'Translation for level of education "other" in openEdx profile', + defaultMessage: 'Other', + }, +}); + +const genderMessages = defineMessages({ + [Gender.MALE]: { + id: 'openEdxProfile.gender.male', + description: 'Translation for gender "male" in openEdx profile', + defaultMessage: 'Male', + }, + [Gender.FEMALE]: { + id: 'openEdxProfile.gender.female', + description: 'Translation for gender "female" in openEdx profile', + defaultMessage: 'Female', + }, + [Gender.OTHER]: { + id: 'openEdxProfile.gender.other', + description: 'Translation for gender "other" in openEdx profile', + defaultMessage: 'Other', + }, +}); + +export interface OpenEdxProfile { + username: Maybe; + name: Maybe; + country: Maybe; + yearOfBirth: Maybe; + levelOfEducation: Maybe; + email: Maybe; + dateJoined: Maybe; + gender: Maybe; + // FIXME(rlecellier): openEdx do not return language + // language: Maybe; + favoriteLanguage: Maybe; +} + +export const parseOpenEdxApiProfile = ( + intl: IntlShape, + data?: OpenEdxApiProfile, +): OpenEdxProfile => { + const [languageCode] = intl.locale.split('-'); + const defaultValues: OpenEdxProfile = { + username: undefined, + name: undefined, + email: undefined, + // FIXME(rlecellier): openEdx do not return language + // language: undefined, + country: undefined, + levelOfEducation: undefined, + gender: undefined, + yearOfBirth: undefined, + favoriteLanguage: undefined, + dateJoined: undefined, + }; + + const languageNames = new Intl.DisplayNames([intl.locale], { type: 'language' }); + const parsedData = data + ? { + username: data.username, + name: data.name, + email: data.email, + yearOfBirth: data.year_of_birth, + dateJoined: new Date(data.date_joined), + levelOfEducation: + data.level_of_education !== null + ? intl.formatMessage(levelOfEducationMessages[data.level_of_education]) + : undefined, + gender: data.gender !== null ? intl.formatMessage(genderMessages[data.gender]) : undefined, + country: data.country ? countries.getName(data.country, languageCode) : undefined, + favoriteLanguage: data.language_proficiencies.length + ? languageNames.of(data.language_proficiencies[0].code) + : undefined, + } + : defaultValues; + + return parsedData; +}; diff --git a/src/frontend/js/pages/DashboardOpenEdxProfile/index.tsx b/src/frontend/js/pages/DashboardOpenEdxProfile/index.tsx new file mode 100644 index 0000000000..ede9f7e281 --- /dev/null +++ b/src/frontend/js/pages/DashboardOpenEdxProfile/index.tsx @@ -0,0 +1,229 @@ +import { Input } from '@openfun/cunningham-react'; +import { FormattedMessage, defineMessages, useIntl } from 'react-intl'; +import { useSession } from 'contexts/SessionContext'; +import useOpenEdxProfile from 'hooks/useOpenEdxProfile'; +import { DashboardBox } from 'widgets/Dashboard/components/DashboardBox'; +import { DashboardCard } from 'widgets/Dashboard/components/DashboardCard'; +import { RouterButton } from 'widgets/Dashboard/components/RouterButton'; +import Form from 'components/Form'; +import context from 'utils/context'; + +const messages = defineMessages({ + sectionHeader: { + id: 'components.DashboardOpenEdxProfile.header', + description: 'Title of the dashboard open edx profile block', + defaultMessage: 'Profile', + }, + baseInformationHeader: { + id: 'components.DashboardOpenEdxProfile.baseInformationHeader', + description: 'Title of the open edx profile form "basic information" block', + defaultMessage: 'Basic account information', + }, + additionalInformationHeader: { + id: 'components.DashboardOpenEdxProfile.additionalInformationHeader', + description: 'Title of the open edx profile form "additional information" block', + defaultMessage: 'Additional account information', + }, + editButtonLabel: { + id: 'components.DashboardOpenEdxProfile.EditButtonLabel', + description: 'Label of the edit button link of the open edx profile form', + defaultMessage: 'Edit your profile', + }, + + usernameInputLabel: { + id: 'components.DashboardOpenEdxProfile.usernameInputLabel', + description: 'Label of the openEdx profile "username" input', + defaultMessage: 'Username', + }, + fullNameInputLabel: { + id: 'components.DashboardOpenEdxProfile.fullNameInputLabel', + description: 'Label of the openEdx profile "fullName" input', + defaultMessage: 'Full name', + }, + emailInputLabel: { + id: 'components.DashboardOpenEdxProfile.emailInputLabel', + description: 'Label of the openEdx profile "email" input', + defaultMessage: 'Email', + }, + // FIXME(rlecellier): openEdx do not return language + // languageInputLabel: { + // id: 'components.DashboardOpenEdxProfile.languageInputLabel', + // description: 'Label of the openEdx profile "langue" input', + // defaultMessage: 'Langue', + // }, + countryInputLabel: { + id: 'components.DashboardOpenEdxProfile.countryInputLabel', + description: 'Label of the openEdx profile "country" input', + defaultMessage: 'Country', + }, + + levelOfEducationInputLabel: { + id: 'components.DashboardOpenEdxProfile.levelOfEducationInputLabel', + description: 'Label of the openEdx profile "level of education" input', + defaultMessage: 'Level of education', + }, + genderInputLabel: { + id: 'components.DashboardOpenEdxProfile.genderInputLabel', + description: 'Label of the openEdx profile "level of education" input', + defaultMessage: 'Sex', + }, + yearOfBirthInputLabel: { + id: 'components.DashboardOpenEdxProfile.yearOfBirthInputLabel', + description: 'Label of the openEdx profile "year of birth" input', + defaultMessage: 'Year of birth', + }, + favoritLanguageInputLabel: { + id: 'components.DashboardOpenEdxProfile.favoritLanguageInputLabel', + description: 'Label of the openEdx profile "favorite language" input', + defaultMessage: 'Favorite language', + }, + usernameInputDescription: { + id: 'components.DashboardOpenEdxProfile.usernameInputDescription', + description: 'Description of the openEdx profile "username" input', + defaultMessage: 'Your name on FUN-MOOC. You cannot change your username.', + }, + fullNameInputDescription: { + id: 'components.DashboardOpenEdxProfile.fullNameInputDescription', + description: 'Description of the openEdx profile "fullName" input', + defaultMessage: + 'The name that appears on your certificates. Other learners never see your full name', + }, + emailInputDescription: { + id: 'components.DashboardOpenEdxProfile.emailInputDescription', + description: 'Description of the openEdx profile "email" input', + defaultMessage: + 'Email used when sign-up, FUN-MOOC and leasons communications will be sent at this address', + }, + // FIXME(rlecellier): openEdx do not return language + // languageInputDescription: { + // id: 'components.DashboardOpenEdxProfile.languageInputDescription', + // description: 'Description of the openEdx profile "langue" input', + // defaultMessage: 'The language used on the website. The website languages are limitated.', + // }, +}); + +const DashboardOpenEdxProfile = () => { + const intl = useIntl(); + const { user } = useSession(); + const { data: openEdxProfileData } = useOpenEdxProfile({ username: user!.username }); + + return ( + }> + + }> + + + + + + + + + + + + {/* FIXME(rlecellier): openEdx do not return language */} + {/* + + */} + + + + + + }> + + + + + + + + + + + + + + + + + + + + + + ); +}; +export default DashboardOpenEdxProfile; diff --git a/src/frontend/js/pages/DashboardPreferences/index.tsx b/src/frontend/js/pages/DashboardPreferences/index.tsx index 62ceb20141..5cdd16c947 100644 --- a/src/frontend/js/pages/DashboardPreferences/index.tsx +++ b/src/frontend/js/pages/DashboardPreferences/index.tsx @@ -2,6 +2,7 @@ import { DashboardCreditCardsManagement } from 'pages/DashboardCreditCardsManage import { DashboardAddressesManagement } from 'pages/DashboardAddressesManagement'; import { LearnerDashboardPaths } from 'widgets/Dashboard/utils/learnerRouteMessages'; import { useDashboardNavigate } from 'widgets/Dashboard/hooks/useDashboardRouter'; +import DashboardOpenEdxProfile from 'pages/DashboardOpenEdxProfile'; /** * This component relies on react-router. @@ -10,6 +11,7 @@ export const DashboardPreferences = () => { const navigate = useDashboardNavigate(); return (
+ navigate(LearnerDashboardPaths.PREFERENCES_ADDRESS_CREATION)} onClickEdit={(address) => diff --git a/src/frontend/js/types/api.ts b/src/frontend/js/types/api.ts index 7d8d867e99..75f80b5819 100644 --- a/src/frontend/js/types/api.ts +++ b/src/frontend/js/types/api.ts @@ -1,6 +1,7 @@ import { Maybe, Nullable } from 'types/utils'; import { User } from 'types/User'; import { UnknownEnrollment } from 'types'; +import { OpenEdxApiProfile } from './openEdx'; export interface APIListRequestParams { [key: string]: Maybe; @@ -15,11 +16,15 @@ export interface APIResponseListMeta { total_count: number; } export interface APIAuthentication { - accessToken?: () => Nullable; login: () => void; logout: () => Promise; me: () => Promise>; register: () => void; + // routes below are only defined for fonzie auth backend + accessToken?: () => Nullable; + account?: { + get: (username: string) => Promise; + }; } export interface APIEnrollment { diff --git a/src/frontend/js/types/openEdx.ts b/src/frontend/js/types/openEdx.ts new file mode 100644 index 0000000000..73ebc450cc --- /dev/null +++ b/src/frontend/js/types/openEdx.ts @@ -0,0 +1,42 @@ +import { Nullable } from './utils'; + +export enum LevelOfEducation { + PHD_OR_DOCTORATE = 'p', + MASTER_OR_PROFESSIONNAL_DEGREE = 'm', + BACHELOR_DEGREE = 'b', + ASSOCIATE_DEGREE = 'a', + SECONDARY_OR_HIGH_SCHOOL = 'hs', + JUNIOR_SECONDARY_OR_MIDDLE_SCHOOL = 'jhs', + ELEMENTARY_PRIMARY_SCHOOL = 'el', + NONE = 'none', + OTHER = 'o', +} + +// * null +// * "f" +// * "m" +// * "o" +export enum Gender { + FEMALE = 'f', + MALE = 'm', + OTHER = 'o', +} + +export enum LanguageIsoCode { + ENGLISH = 'en', + FRENCH = 'fr', +} + +export interface OpenEdxApiProfile { + username: string; + name: string; + country: string; + year_of_birth: string; + level_of_education: Nullable; + email: string; + date_joined: string; + gender: Nullable; + // FIXME(rlecellier): openEdx do not return language + // language: LanguageIsoCode; + language_proficiencies: { code: LanguageIsoCode }[]; +} diff --git a/src/frontend/js/utils/test/factories/openEdx.tsx b/src/frontend/js/utils/test/factories/openEdx.tsx new file mode 100644 index 0000000000..be686cd744 --- /dev/null +++ b/src/frontend/js/utils/test/factories/openEdx.tsx @@ -0,0 +1,19 @@ +import { faker } from '@faker-js/faker'; +import { Gender, LanguageIsoCode, LevelOfEducation, OpenEdxApiProfile } from 'types/openEdx'; +import { factory } from './factories'; + +export const OpenEdxApiProfileFactory = factory((): OpenEdxApiProfile => { + return { + username: faker.internet.userName(), + name: faker.person.fullName(), + country: faker.location.countryCode(), + year_of_birth: faker.date.past().toISOString(), + level_of_education: LevelOfEducation.ELEMENTARY_PRIMARY_SCHOOL, + email: faker.internet.email(), + date_joined: faker.date.past().toISOString(), + gender: Gender.MALE, + // FIXME(rlecellier): openEdx do not return language + // language: LanguageIsoCode; + language_proficiencies: [{ code: LanguageIsoCode.FRENCH }], + }; +});