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 Apr 10, 2024
1 parent c2763af commit c39d8a9
Show file tree
Hide file tree
Showing 20 changed files with 875 additions and 34 deletions.
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.
- Management command to migrate course runs course link to joanie
- Add search bar on learner dashboard courses pages.
- Add a `CertificateHelper` implementing a `getCourse` method
Expand Down
22 changes: 22 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,12 @@ 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 {
OpenEdxGender,
OpenEdxLanguageIsoCode,
OpenEdxLevelOfEducation,
OpenEdxApiProfile,
} from 'types/openEdx';

type JWTPayload = {
email: string;
Expand Down Expand Up @@ -89,6 +95,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): Promise<OpenEdxApiProfile> => {
return Promise.resolve({
username: 'j_do',
name: 'John Do',
email: '[email protected]',
country: 'fr',
level_of_education: OpenEdxLevelOfEducation.MASTER_OR_PROFESSIONNAL_DEGREE,
gender: OpenEdxGender.MALE,
year_of_birth: '1971',
'pref-lang': OpenEdxLanguageIsoCode.ENGLISH,
language_proficiencies: [{ code: OpenEdxLanguageIsoCode.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
35 changes: 35 additions & 0 deletions src/frontend/js/api/lms/openedx-fonzie.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { AuthenticationBackend, LMSBackend } from 'types/commonDataProps';
import { APILms } from 'types/api';
import { RICHIE_USER_TOKEN } from 'settings';
import { isHttpError } from 'utils/errors/HttpError';
import { handle } from 'utils/errors/handle';
import { OpenEdxApiProfile } from 'types/openEdx';
import { checkStatus } from 'api/utils';
import OpenEdxHawthornApiInterface from './openedx-hawthorn';

/**
Expand All @@ -24,6 +28,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 +42,35 @@ const API = (APIConf: AuthenticationBackend | LMSBackend): APILms => {
accessToken: () => {
return sessionStorage.getItem(RICHIE_USER_TOKEN);
},
account: {
get: async (username: string) => {
const options: RequestInit = {
credentials: 'include',
};

try {
const account = await fetch(
APIOptions.routes.user.account.replace(':username', username),
options,
).then(checkStatus);
const preferences = await fetch(
APIOptions.routes.user.preferences.replace(':username', username),
options,
).then(checkStatus);

return {
...account,
...preferences,
} as OpenEdxApiProfile;
} catch (e) {
if (isHttpError(e)) {
handle(new Error(`[GET - Account] > ${e.code} - ${e.message}`));
}

throw e;
}
},
},
},
};
};
Expand Down
2 changes: 1 addition & 1 deletion src/frontend/js/api/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export function getResponseBody(response: Response) {
}

/*
A util to manage Joanie API responses.
A util to manage API responses.
It parses properly the response according to its `Content-Type`
otherwise it throws an `HttpError`.
Expand Down
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 @@ -16,4 +16,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 does 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, AuthenticationApi]);

const [queryHandler] = useSessionQuery<OpenEdxProfile>(['open-edx-profile'], queryFn);
return { data: queryHandler.data, error };
};

export default useOpenEdxProfile;
69 changes: 69 additions & 0 deletions src/frontend/js/hooks/useOpenEdxProfile/utils/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { createIntl } from 'react-intl';
import { faker } from '@faker-js/faker';
import { OpenEdxApiProfileFactory } from 'utils/test/factories/openEdx';
import { OpenEdxGender, OpenEdxLanguageIsoCode, OpenEdxLevelOfEducation } from 'types/openEdx';
import { parseOpenEdxApiProfile } from '.';

describe('useOpenEdxProfile > utils', () => {
it('parseOpenEdxApiProfile should format values', () => {
const profile = parseOpenEdxApiProfile(
createIntl({ locale: 'en' }),
OpenEdxApiProfileFactory({
username: 'John',
name: 'Do',
email: '[email protected]',
'pref-lang': OpenEdxLanguageIsoCode.FRENCH,
country: 'fr',
level_of_education: OpenEdxLevelOfEducation.MASTER_OR_PROFESSIONNAL_DEGREE,
gender: OpenEdxGender.MALE,
year_of_birth: '01/01/1970',
language_proficiencies: [{ code: OpenEdxLanguageIsoCode.ENGLISH }],
date_joined: '01/01/1970',
}).one(),
);

expect(profile).toStrictEqual({
username: 'John',
name: 'Do',
email: '[email protected]',
language: 'French',
country: 'France',
levelOfEducation: 'Master',
gender: 'Male',
yearOfBirth: '01/01/1970',
favoriteLanguage: 'English',
dateJoined: '01/01/1970',
});
});

it('parseOpenEdxApiProfile should format default values', () => {
const profile = parseOpenEdxApiProfile(
createIntl({ locale: 'en' }),
OpenEdxApiProfileFactory({
username: faker.internet.userName(),
name: '',
email: faker.internet.email(),
'pref-lang': undefined,
country: null,
level_of_education: null,
gender: null,
year_of_birth: null,
language_proficiencies: [],
date_joined: Date.toString(),
}).one(),
);

expect(profile).toStrictEqual({
username: profile.username,
name: ' - ',
email: profile.email,
language: ' - ',
country: ' - ',
levelOfEducation: ' - ',
gender: ' - ',
yearOfBirth: ' - ',
favoriteLanguage: ' - ',
dateJoined: profile.dateJoined,
});
});
});
Loading

0 comments on commit c39d8a9

Please sign in to comment.