Skip to content

Commit

Permalink
✨(frontend) add OpenEdx profile page
Browse files Browse the repository at this point in the history
We want to display OpenEdx read-only informations about the user.
To edit theses informations, a link open a new tab with OpenEdx profile
form page.
  • Loading branch information
rlecellier committed Mar 26, 2024
1 parent 46dbe53 commit 781b733
Show file tree
Hide file tree
Showing 13 changed files with 562 additions and 1 deletion.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions src/frontend/js/api/lms/dummy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -89,6 +90,22 @@ 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: '[email protected]',
country: 'fr',
level_of_education: LevelOfEducation.MASTER_OR_PROFESSIONNAL_DEGREE,
gender: Gender.MALE,
year_of_birth: '1971',
'pref-lang': LanguageIsoCode.ENGLISH,
language_proficiencies: [{ code: LanguageIsoCode.ENGLISH }],
} as OpenEdxApiProfile);
},
},
},
enrollment: {
get: async (url: string, user: Nullable<User>) =>
Expand Down
20 changes: 20 additions & 0 deletions src/frontend/js/api/lms/openedx-fonzie.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -30,6 +31,25 @@ describe('Fonzie API', () => {
await expect(api.user.me()).resolves.toEqual(user);
});

it('uses its own route to get user profile', async () => {
const openEdxApiProfile = OpenEdxApiProfileFactory().one();
const { 'pref-lang': language, ...account } = openEdxApiProfile;
fetchMock.get(
`https://demo.endpoint.api/api/user/v1/accounts/${openEdxApiProfile.username}`,
account,
);
fetchMock.get(
`https://demo.endpoint.api/api/user/v1/preferences/${openEdxApiProfile.username}`,
{
'pref-lang': language,
},
);

const api = FonzieAPIInterface(configuration);
expect(await api.user.account!.get(openEdxApiProfile.username)).toEqual(openEdxApiProfile);
expect(fetchMock.calls()).toHaveLength(2);
});

it('is able to retrieve access token within the session storage', () => {
const accessToken = faker.string.uuid();
sessionStorage.setItem(RICHIE_USER_TOKEN, accessToken);
Expand Down
52 changes: 52 additions & 0 deletions src/frontend/js/api/lms/openedx-fonzie.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand All @@ -24,6 +27,8 @@ const API = (APIConf: AuthenticationBackend | LMSBackend): APILms => {
routes: {
user: {
me: `${APIConf.endpoint}/api/v1.0/user/me`,
account: `${APIConf.endpoint}/api/user/v1/accounts/:username`,
preferences: `${APIConf.endpoint}/api/user/v1/preferences/:username`,
},
},
};
Expand All @@ -36,6 +41,53 @@ const API = (APIConf: AuthenticationBackend | LMSBackend): APILms => {
accessToken: () => {
return sessionStorage.getItem(RICHIE_USER_TOKEN);
},
account: {
get: async (username: string) => {
const options: RequestInit = {
credentials: 'include',
};
const accountResponse = await fetch(
APIOptions.routes.user.account.replace(':username', username),
options,
);
const preferencesResponse = await fetch(
APIOptions.routes.user.preferences.replace(':username', username),
options,
);

const isResponseOk = accountResponse.ok && preferencesResponse.ok;
if (isResponseOk) {
const account = await accountResponse.json();
const preferences = await preferencesResponse.json();
return {
...account,
...preferences,
} as unknown as OpenEdxApiProfile;
}

const isAccountResponseError =
accountResponse.status >= HttpStatusCode.INTERNAL_SERVER_ERROR;
if (isAccountResponseError) {
handle(
new Error(
`[GET - Account] > ${accountResponse.status} - ${accountResponse.statusText}`,
),
);
}
const isPreferencesResponseError =
accountResponse.status >= HttpStatusCode.INTERNAL_SERVER_ERROR;
if (isPreferencesResponseError) {
handle(
new Error(
`[GET - Account] > ${preferencesResponse.status} - ${preferencesResponse.statusText}`,
),
);
}
const responseError = isAccountResponseError ? accountResponse : preferencesResponse;

throw new HttpError(responseError.status, responseError.statusText);
},
},
},
};
};
Expand Down
4 changes: 4 additions & 0 deletions src/frontend/js/components/Form/Form/_styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,8 @@
flex-direction: column;
}
}

&-footer {
margin-top: $vertical-spacing;
}
}
4 changes: 4 additions & 0 deletions src/frontend/js/components/Form/Form/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ Form.Row = ({ children, className }: PropsWithChildren<{ className?: string }>)
return <div className={c('form-row', className)}>{children}</div>;
};

Form.Footer = ({ children, className }: PropsWithChildren<{ className?: string }>) => {
return <div className={c('form-footer', className)}>{children}</div>;
};

export default Form;
45 changes: 45 additions & 0 deletions src/frontend/js/hooks/useOpenEdxProfile/index.ts
Original file line number Diff line number Diff line change
@@ -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<string>();

const queryFn: () => Promise<OpenEdxProfile> = 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<OpenEdxProfile>(['open-edx-profile'], queryFn);
return { data: queryHandler.data, error };
};

export default useOpenEdxProfile;
128 changes: 128 additions & 0 deletions src/frontend/js/hooks/useOpenEdxProfile/utils/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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>({
[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>({
[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<string>;
name: Maybe<string>;
country: Maybe<string>;
yearOfBirth: Maybe<string>;
levelOfEducation: Maybe<string>;
email: Maybe<string>;
dateJoined: Maybe<Date>;
gender: Maybe<string>;
language: Maybe<string>;
favoriteLanguage: Maybe<string>;
}

export const parseOpenEdxApiProfile = (
intl: IntlShape,
data?: OpenEdxApiProfile,
): OpenEdxProfile => {
const [languageCode] = intl.locale.split('-');
const defaultValues: OpenEdxProfile = {
username: undefined,
name: undefined,
email: undefined,
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,
language: data['pref-lang'] ? languageNames.of(data['pref-lang']) : undefined,
favoriteLanguage: data.language_proficiencies.length
? languageNames.of(data.language_proficiencies[0].code)
: undefined,
}
: defaultValues;

return parsedData;
};
Loading

0 comments on commit 781b733

Please sign in to comment.