diff --git a/package-lock.json b/package-lock.json
index 616fe050c..9f8e7fad2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,7 @@
"@redux-devtools/extension": "3.3.0",
"classnames": "2.5.1",
"core-js": "3.42.0",
+ "dompurify": "3.2.4",
"history": "5.3.0",
"lodash.camelcase": "4.3.0",
"lodash.get": "4.4.2",
@@ -5846,6 +5847,13 @@
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="
},
+ "node_modules/@types/trusted-types": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
+ "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
+ "license": "MIT",
+ "optional": true
+ },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.3.tgz",
@@ -9234,6 +9242,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
+ "node_modules/dompurify": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.4.tgz",
+ "integrity": "sha512-ysFSFEDVduQpyhzAob/kkuJjf5zWkZD8/A9ywSp1byueyuCfHamrCBa14/Oc2iiB0e51B+NpxSl5gmzn+Ms/mg==",
+ "license": "(MPL-2.0 OR Apache-2.0)",
+ "optionalDependencies": {
+ "@types/trusted-types": "^2.0.7"
+ }
+ },
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
diff --git a/package.json b/package.json
index d381b2080..20bf1529e 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
"@redux-devtools/extension": "3.3.0",
"classnames": "2.5.1",
"core-js": "3.42.0",
+ "dompurify": "3.2.4",
"history": "5.3.0",
"lodash.camelcase": "4.3.0",
"lodash.get": "4.4.2",
diff --git a/src/profile/ProfilePage.jsx b/src/profile/ProfilePage.jsx
index 2dfe72dda..50df2d870 100644
--- a/src/profile/ProfilePage.jsx
+++ b/src/profile/ProfilePage.jsx
@@ -17,6 +17,7 @@ import {
openForm,
closeForm,
updateDraft,
+ getExtendedProfileFields as fetchExtraFieldsInfo,
} from './data/actions';
// Components
@@ -42,6 +43,7 @@ import { profilePageSelector } from './data/selectors';
import messages from './ProfilePage.messages';
import withParams from '../utils/hoc';
+import ExtendedProfileFields from './forms/ExtendedProfileFields';
ensureConfig(['CREDENTIALS_BASE_URL', 'LMS_BASE_URL'], 'ProfilePage');
@@ -65,6 +67,7 @@ class ProfilePage extends React.Component {
componentDidMount() {
this.props.fetchProfile(this.props.params.username);
+ this.props.fetchExtraFieldsInfo();
sendTrackingLogEvent('edx.profile.viewed', {
username: this.props.params.username,
});
@@ -186,6 +189,7 @@ class ProfilePage extends React.Component {
username,
saveState,
navigate,
+ extendedProfileFields,
} = this.props;
if (isLoadingProfile) {
@@ -213,6 +217,11 @@ class ProfilePage extends React.Component {
const isCertificatesBlockVisible = isBlockVisible(courseCertificates.length);
const isNameBlockVisible = isBlockVisible(name);
const isLocationBlockVisible = isBlockVisible(country);
+ // TODO: modify /api/user/v1/accounts/{{username}} to return extended profile field values
+ // So these fields can be shown for no-authenticated user profiles
+ const isExtendedProfileFieldsVisible = isBlockVisible(
+ extendedProfileFields.length > 0 && this.isAuthenticatedUserProfile(),
+ );
return (
@@ -280,6 +289,13 @@ class ProfilePage extends React.Component {
{...commonFormProps}
/>
)}
+ {isExtendedProfileFieldsVisible && (
+
+ )}
{isSocialLinksBLockVisible && (
Renders correctly in various states successfully redire
+
diff --git a/src/profile/data/actions.js b/src/profile/data/actions.js
index 38a5b7968..0d9806172 100644
--- a/src/profile/data/actions.js
+++ b/src/profile/data/actions.js
@@ -147,3 +147,23 @@ export const updateDraft = (name, value) => ({
export const resetDrafts = () => ({
type: RESET_DRAFTS,
});
+
+export const EXTENDED_PROFILE_FIELDS = new AsyncActionType('EXTENDED_PROFILE_FIELDS', 'GET_EXTENDED_PROFILE_FIELDS');
+export const EXTENDED_PROFILE_FIELDS_CLEAR_ERROR_MSG = 'EXTENDED_PROFILE_FIELDS_CLEAR_ERROR_MSG';
+
+export const getExtendedProfileFields = () => ({
+ type: EXTENDED_PROFILE_FIELDS.BASE,
+});
+
+export const getExtendedProfileFieldsBegin = () => ({
+ type: EXTENDED_PROFILE_FIELDS.BEGIN,
+});
+
+export const getExtendedProfileFieldsSuccess = (fields) => ({
+ type: EXTENDED_PROFILE_FIELDS.SUCCESS,
+ payload: fields,
+});
+
+export const getExtendedProfileFieldsFailure = () => ({
+ type: EXTENDED_PROFILE_FIELDS.FAILURE,
+});
diff --git a/src/profile/data/reducers.js b/src/profile/data/reducers.js
index 33b29fb18..227fd4d96 100644
--- a/src/profile/data/reducers.js
+++ b/src/profile/data/reducers.js
@@ -7,6 +7,7 @@ import {
FETCH_PROFILE,
UPDATE_DRAFT,
RESET_DRAFTS,
+ EXTENDED_PROFILE_FIELDS,
} from './actions';
export const initialState = {
@@ -22,6 +23,7 @@ export const initialState = {
drafts: {},
isLoadingProfile: true,
isAuthenticatedUserProfile: false,
+ extendedProfileFields: [],
};
const profilePage = (state = initialState, action = {}) => {
@@ -128,11 +130,23 @@ const profilePage = (state = initialState, action = {}) => {
errors: {},
};
- case UPDATE_DRAFT:
+ case UPDATE_DRAFT: {
+ const { name, value } = action.payload;
+ const updatedDrafts = { ...state.drafts, [name]: value };
+
+ if (name === 'visibilityExtendedProfile') {
+ const visibilityExtendedProfile = {
+ ...state.preferences.visibilityExtendedProfile,
+ ...value,
+ };
+ updatedDrafts[name] = visibilityExtendedProfile;
+ }
+
return {
...state,
- drafts: { ...state.drafts, [action.payload.name]: action.payload.value },
+ drafts: updatedDrafts,
};
+ }
case RESET_DRAFTS:
return {
@@ -155,6 +169,14 @@ const profilePage = (state = initialState, action = {}) => {
};
}
return state;
+ case EXTENDED_PROFILE_FIELDS.BEGIN:
+ case EXTENDED_PROFILE_FIELDS.FAILURE:
+ case EXTENDED_PROFILE_FIELDS.SUCCESS:
+ if (!action.payload) { return state; }
+ return {
+ ...state,
+ extendedProfileFields: action.payload,
+ };
default:
return state;
}
diff --git a/src/profile/data/sagas.js b/src/profile/data/sagas.js
index 7c9e409f8..0768bfc9d 100644
--- a/src/profile/data/sagas.js
+++ b/src/profile/data/sagas.js
@@ -29,6 +29,10 @@ import {
saveProfileSuccess,
SAVE_PROFILE,
SAVE_PROFILE_PHOTO,
+ EXTENDED_PROFILE_FIELDS,
+ getExtendedProfileFieldsBegin,
+ getExtendedProfileFieldsSuccess,
+ getExtendedProfileFieldsFailure,
} from './actions';
import { handleSaveProfileSelector, userAccountSelector } from './selectors';
import * as ProfileApiService from './services';
@@ -93,7 +97,7 @@ export function* handleFetchProfile(action) {
yield put(fetchProfileReset());
} catch (e) {
- if (e.response.status === 404) {
+ if (e.response?.status === 404) {
if (e.processedData && e.processedData.fieldErrors) {
yield put(saveProfileFailure(e.processedData.fieldErrors));
} else {
@@ -117,6 +121,7 @@ export function* handleSaveProfile(action) {
'languageProficiencies',
'name',
'socialLinks',
+ 'extendedProfile',
]);
const preferencesDrafts = pick(drafts, [
@@ -127,6 +132,7 @@ export function* handleSaveProfile(action) {
'visibilityLanguageProficiencies',
'visibilityName',
'visibilitySocialLinks',
+ 'visibilityExtendedProfile',
]);
if (Object.keys(preferencesDrafts).length > 0) {
@@ -204,9 +210,24 @@ export function* handleDeleteProfilePhoto(action) {
}
}
+export function* fetchExtendedProfileFields() {
+ try {
+ yield put(getExtendedProfileFieldsBegin());
+ const {
+ fields,
+ } = yield call(ProfileApiService.getExtendedProfileFields);
+
+ yield put(getExtendedProfileFieldsSuccess(fields));
+ } catch (e) {
+ yield put(getExtendedProfileFieldsFailure());
+ throw e;
+ }
+}
+
export default function* profileSaga() {
yield takeEvery(FETCH_PROFILE.BASE, handleFetchProfile);
yield takeEvery(SAVE_PROFILE.BASE, handleSaveProfile);
yield takeEvery(SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto);
yield takeEvery(DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto);
+ yield takeEvery(EXTENDED_PROFILE_FIELDS.BASE, fetchExtendedProfileFields);
}
diff --git a/src/profile/data/sagas.test.js b/src/profile/data/sagas.test.js
index 379c0cfd6..d64b9b52d 100644
--- a/src/profile/data/sagas.test.js
+++ b/src/profile/data/sagas.test.js
@@ -7,8 +7,10 @@ import {
all,
} from 'redux-saga/effects';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
+import { runSaga } from 'redux-saga';
import * as profileActions from './actions';
+import { getExtendedProfileFields } from './services';
import { handleSaveProfileSelector, userAccountSelector } from './selectors';
jest.mock('./services', () => ({
@@ -19,6 +21,7 @@ jest.mock('./services', () => ({
getPreferences: jest.fn(),
getAccount: jest.fn(),
getCourseCertificates: jest.fn(),
+ getExtendedProfileFields: jest.fn(),
}));
jest.mock('@edx/frontend-platform/auth', () => ({
@@ -32,6 +35,7 @@ import profileSaga, {
handleSaveProfile,
handleSaveProfilePhoto,
handleDeleteProfilePhoto,
+ fetchExtendedProfileFields,
} from './sagas';
import * as ProfileApiService from './services';
/* eslint-enable import/first */
@@ -49,6 +53,8 @@ describe('RootSaga', () => {
.toEqual(takeEvery(profileActions.SAVE_PROFILE_PHOTO.BASE, handleSaveProfilePhoto));
expect(gen.next().value)
.toEqual(takeEvery(profileActions.DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto));
+ expect(gen.next().value)
+ .toEqual(takeEvery(profileActions.EXTENDED_PROFILE_FIELDS.BASE, fetchExtendedProfileFields));
expect(gen.next().value).toBeUndefined();
});
@@ -163,4 +169,43 @@ describe('RootSaga', () => {
expect(gen.next().value).toBeUndefined();
});
});
+
+ describe('fetchExtendedProfileFields', () => {
+ test('should fetch third party auth context', async () => {
+ const dispatched = [];
+ const mockedAction = { payload: { urlParams: {} } };
+ const mockFields = { field1: 'value1' };
+
+ getExtendedProfileFields.mockResolvedValue({ fields: mockFields });
+
+ await runSaga(
+ {
+ dispatch: (action) => dispatched.push(action),
+ },
+ fetchExtendedProfileFields,
+ mockedAction,
+ ).toPromise();
+
+ expect(dispatched).toContainEqual(profileActions.getExtendedProfileFieldsBegin());
+ expect(dispatched).toContainEqual(profileActions.getExtendedProfileFieldsSuccess(mockFields));
+ });
+
+ test('should fail to fetch third party auth context', async () => {
+ const dispatched = [];
+ const mockedAction = { payload: { urlParams: {} } };
+
+ getExtendedProfileFields.mockRejectedValue(new Error('API error'));
+
+ await expect(runSaga(
+ {
+ dispatch: (action) => dispatched.push(action),
+ },
+ fetchExtendedProfileFields,
+ mockedAction,
+ ).toPromise()).rejects.toThrow('API error');
+
+ expect(dispatched).toContainEqual(profileActions.getExtendedProfileFieldsBegin());
+ expect(dispatched).toContainEqual(profileActions.getExtendedProfileFieldsFailure());
+ });
+ });
});
diff --git a/src/profile/data/selectors.js b/src/profile/data/selectors.js
index b04493e6e..5ca7e40bf 100644
--- a/src/profile/data/selectors.js
+++ b/src/profile/data/selectors.js
@@ -6,11 +6,13 @@ import {
getCountryMessages,
getLanguageMessages,
} from '@edx/frontend-platform/i18n'; // eslint-disable-line
+import { moveCheckboxFieldsToEnd } from '../utils';
export const formIdSelector = (state, props) => props.formId;
export const userAccountSelector = state => state.userAccount;
export const profileAccountSelector = state => state.profilePage.account;
+export const extendedProfileFieldsSelector = state => state.profilePage.extendedProfileFields;
export const profileDraftsSelector = state => state.profilePage.drafts;
export const accountPrivacySelector = state => state.profilePage.preferences.accountPrivacy;
export const profilePreferencesSelector = state => state.profilePage.preferences;
@@ -31,10 +33,17 @@ export const editableFormModeSelector = createSelector(
formIdSelector,
currentlyEditingFieldSelector,
(account, isAuthenticatedUserProfile, certificates, formId, currentlyEditingField) => {
+ const [parentPropKey, fieldName] = formId.split('/');
// If the prop doesn't exist, that means it hasn't been set (for the current user's profile)
// 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
+
+ // Overwrite for extended profile fields
+ propExists = formId.includes('extendedProfile') ? (
+ account[parentPropKey]?.some((field) => field.fieldName === fieldName)
+ ) : propExists;
+
// If this isn't the current user's profile
if (!isAuthenticatedUserProfile) {
return 'static';
@@ -238,8 +247,16 @@ export const visibilitiesSelector = createSelector(
visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'all_users',
visibilityName: preferences.visibilityName || 'all_users',
visibilitySocialLinks: preferences.visibilitySocialLinks || 'all_users',
+ visibilityExtendedProfile: preferences.visibilityExtendedProfile || {},
};
- case 'private':
+ case 'private': {
+ const visibilityExtendedProfile = {};
+
+ if (preferences.visibilityExtendedProfile) {
+ Object.keys(preferences.visibilityExtendedProfile).forEach((key) => {
+ visibilityExtendedProfile[key] = 'private';
+ });
+ }
return {
visibilityBio: 'private',
visibilityCourseCertificates: 'private',
@@ -248,13 +265,22 @@ export const visibilitiesSelector = createSelector(
visibilityLanguageProficiencies: 'private',
visibilityName: 'private',
visibilitySocialLinks: 'private',
+ visibilityExtendedProfile,
};
+ }
case 'all_users':
- default:
+ default: {
// All users is intended to fall through to default.
// If there is no value for accountPrivacy in perferences, that means it has not been
// explicitly set yet. The server assumes - today - that this means "all_users",
// so we emulate that here in the client.
+ const visibilityExtendedProfile = {};
+ if (preferences.visibilityExtendedProfile) {
+ Object.keys(preferences.visibilityExtendedProfile).forEach((key) => {
+ visibilityExtendedProfile[key] = 'all_users';
+ });
+ }
+
return {
visibilityBio: 'all_users',
visibilityCourseCertificates: 'all_users',
@@ -263,7 +289,8 @@ export const visibilitiesSelector = createSelector(
visibilityLanguageProficiencies: 'all_users',
visibilityName: 'all_users',
visibilitySocialLinks: 'all_users',
- };
+ visibilityExtendedProfile,
+ }; }
}
},
);
@@ -311,6 +338,10 @@ export const formValuesSelector = createSelector(
drafts.visibilitySocialLinks,
visibilities.visibilitySocialLinks,
),
+ visibilityExtendedProfile: chooseFormValue(
+ drafts.visibilityExtendedProfile,
+ visibilities.visibilityExtendedProfile,
+ ),
}),
);
@@ -323,6 +354,7 @@ export const profilePageSelector = createSelector(
isLoadingProfileSelector,
draftSocialLinksByPlatformSelector,
accountErrorsSelector,
+ extendedProfileFieldsSelector,
(
account,
formValues,
@@ -332,6 +364,7 @@ export const profilePageSelector = createSelector(
isLoadingProfile,
draftSocialLinksByPlatform,
errors,
+ extendedProfileFields,
) => ({
// Account data we need
username: account.username,
@@ -374,5 +407,15 @@ export const profilePageSelector = createSelector(
savePhotoState,
isLoadingProfile,
photoUploadError: errors.photo || null,
+
+ // Extended profile fields
+ // Combine the field properties and its values
+ extendedProfileFields: extendedProfileFields?.map((field) => ({
+ ...field,
+ value: account.extendedProfile?.find(
+ (extendedProfileField) => extendedProfileField.fieldName === field.name,
+ )?.fieldValue,
+ }))?.sort(moveCheckboxFieldsToEnd) ?? [],
}),
+
);
diff --git a/src/profile/data/services.js b/src/profile/data/services.js
index 45bf68777..8892e582f 100644
--- a/src/profile/data/services.js
+++ b/src/profile/data/services.js
@@ -1,5 +1,5 @@
import { ensureConfig, getConfig } from '@edx/frontend-platform';
-import { getAuthenticatedHttpClient as getHttpClient } from '@edx/frontend-platform/auth';
+import { getAuthenticatedHttpClient, getAuthenticatedHttpClient as getHttpClient } from '@edx/frontend-platform/auth';
import { logError } from '@edx/frontend-platform/logging';
import { camelCaseObject, convertKeyNames, snakeCaseObject } from '../utils';
@@ -89,7 +89,13 @@ export async function deleteProfilePhoto(username) {
export async function getPreferences(username) {
const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`);
- return camelCaseObject(data);
+ const processedData = camelCaseObject(data);
+ const visibilityExtendedProfile = Object.prototype.hasOwnProperty.call(data, 'visibilityExtendedProfile');
+
+ return {
+ ...processedData,
+ visibilityExtendedProfile: visibilityExtendedProfile ? JSON.parse(data['visibility.extended_profile']) : {},
+ };
}
// PATCH PREFERENCES
@@ -105,8 +111,11 @@ export async function patchPreferences(username, params) {
visibility_name: 'visibility.name',
visibility_social_links: 'visibility.social_links',
visibility_time_zone: 'visibility.time_zone',
+ visibility_extended_profile: 'visibility.extended_profile',
});
+ processedParams['visibility.extended_profile'] = JSON.stringify(processedParams['visibility.extended_profile']);
+
await getHttpClient().patch(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`, processedParams, {
headers: { 'Content-Type': 'application/merge-patch+json' },
});
@@ -147,3 +156,17 @@ export async function getCourseCertificates(username) {
return [];
}
}
+
+export async function getExtendedProfileFields() {
+ const url = `${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`;
+
+ const { data } = await getAuthenticatedHttpClient()
+ .get(
+ url,
+ )
+ .catch((e) => {
+ throw (e);
+ });
+
+ return { fields: data.fields };
+}
diff --git a/src/profile/forms/ExtendedProfileFields.jsx b/src/profile/forms/ExtendedProfileFields.jsx
new file mode 100644
index 000000000..fad9ccfe5
--- /dev/null
+++ b/src/profile/forms/ExtendedProfileFields.jsx
@@ -0,0 +1,121 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useSelector } from 'react-redux';
+import { intlShape } from '@edx/frontend-platform/i18n';
+
+// Components
+import SwitchContent from './elements/SwitchContent';
+import SelectField from './elements/SelectField';
+import TextField from './elements/TextField';
+import CheckboxField from './elements/CheckboxField';
+
+const ExtendedProfileFields = (props) => {
+ const {
+ extendedProfileFields, formId, changeHandler, submitHandler, closeHandler, openHandler,
+ } = props;
+
+ const draftProfile = useSelector((state) => state.profilePage.drafts);
+ const extendedProfile = useSelector((state) => state.profilePage.account.extendedProfile);
+ const visibilityExtendedProfile = useSelector((state) => state.profilePage.preferences.visibilityExtendedProfile);
+
+ const handleChangeExtendedField = (name, value) => {
+ const [parentPropKey, fieldName] = name.split('/');
+ const isVisibilityChange = name.includes('visibility');
+ const fields = isVisibilityChange ? visibilityExtendedProfile : extendedProfile;
+ const newFields = isVisibilityChange
+ ? {
+ ...fields,
+ [fieldName]: value,
+ }
+ : fields.map(
+ (extendedField) => (
+ extendedField.fieldName === fieldName ? { ...extendedField, fieldValue: value } : extendedField
+ ),
+ );
+ changeHandler(parentPropKey, newFields);
+ };
+
+ const handleSubmitExtendedField = (form) => {
+ submitHandler(form);
+ };
+
+ return (
+
+ {extendedProfileFields?.map((field) => {
+ const value = draftProfile?.extendedProfile?.find(
+ (extendedField) => extendedField.fieldName === field.name,
+ )?.fieldValue ?? field.value;
+
+ const visibility = draftProfile?.visibilityExtendedProfile?.[field.name]
+ ?? visibilityExtendedProfile?.[field.name];
+
+ const commonProps = {
+ ...field,
+ errorMessage: field.errorMessage,
+ formId: `${formId}/${field.name}`,
+ changeHandler: handleChangeExtendedField,
+ submitHandler: handleSubmitExtendedField,
+ closeHandler,
+ openHandler,
+ value,
+ visibility,
+ };
+
+ return (
+ ,
+ text: ,
+ select: ,
+ }}
+ />
+ );
+ })}
+
+ );
+};
+
+ExtendedProfileFields.propTypes = {
+ // From Selector
+ formId: PropTypes.string.isRequired,
+ extendedProfileFields: PropTypes.arrayOf(PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ default: PropTypes.unknown,
+ placeholder: PropTypes.string,
+ instructions: PropTypes.string,
+ options: PropTypes.arrayOf(PropTypes.shape({
+ value: PropTypes.string.isRequired,
+ label: PropTypes.string.isRequired,
+ })),
+ errorMessage: PropTypes.shape({
+ required: PropTypes.string,
+ }),
+ restrictions: PropTypes.shape({
+ max_length: PropTypes.number,
+ min_length: PropTypes.number,
+ }),
+ type: PropTypes.string.isRequired,
+ })).isRequired,
+
+ // Actions
+ changeHandler: PropTypes.func.isRequired,
+ submitHandler: PropTypes.func.isRequired,
+ closeHandler: PropTypes.func.isRequired,
+ openHandler: PropTypes.func.isRequired,
+
+ // Props
+ isAuthenticatedUserProfile: PropTypes.bool.isRequired,
+
+ // i18n
+ intl: intlShape.isRequired,
+};
+
+ExtendedProfileFields.defaultProps = {
+};
+
+export default ExtendedProfileFields;
+
+export const TestableExtendedProfileFields = ExtendedProfileFields;
diff --git a/src/profile/forms/ExtendedProfileFields.test.jsx b/src/profile/forms/ExtendedProfileFields.test.jsx
new file mode 100644
index 000000000..83e275f00
--- /dev/null
+++ b/src/profile/forms/ExtendedProfileFields.test.jsx
@@ -0,0 +1,120 @@
+import React from 'react';
+import {
+ render, screen, fireEvent,
+} from '@testing-library/react';
+import { Provider } from 'react-redux';
+import configureStore from 'redux-mock-store';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+
+import { TestableExtendedProfileFields as ExtendedProfileFields } from './ExtendedProfileFields';
+
+jest.mock('./elements/SelectField', () => jest.fn((props) => (
+
+)));
+jest.mock('./elements/TextField', () => jest.fn((props) => (
+
+)));
+jest.mock('./elements/CheckboxField', () => jest.fn((props) => (
+
+)));
+
+const mockStore = configureStore([]);
+
+describe('ExtendedProfileFields', () => {
+ const store = mockStore({
+ profilePage: {
+ drafts: {},
+ account: {
+ extendedProfile: [
+ { fieldName: 'first_name', fieldValue: 'John' },
+ ],
+ },
+ preferences: { visibilityExtendedProfile: {} },
+ },
+ });
+
+ const renderComponent = (fields, props = {}) => {
+ render(
+
+
+ 'Empty field') }}
+ {...props}
+ />
+
+ ,
+ );
+ };
+
+ it('renders SelectField when field type is select', () => {
+ renderComponent([
+ {
+ name: 'country', type: 'select', label: 'Country', value: '', options: [{ value: 'us', label: 'USA' }],
+ },
+ ]);
+ expect(screen.getByTestId('select-field')).toBeInTheDocument();
+ });
+
+ it('renders TextField when field type is text', () => {
+ renderComponent([
+ {
+ name: 'first_name', type: 'text', label: 'First Name', value: 'John',
+ },
+ ]);
+ expect(screen.getByTestId('text-field')).toBeInTheDocument();
+ });
+
+ it('renders CheckboxField when field type is checkbox', () => {
+ renderComponent([
+ {
+ name: 'newsletter', type: 'checkbox', label: 'Subscribe', value: false,
+ },
+ ]);
+ expect(screen.getByTestId('checkbox-field')).toBeInTheDocument();
+ });
+
+ it('handles change events', () => {
+ const changeHandler = jest.fn();
+ renderComponent([
+ {
+ name: 'first_name', type: 'text', label: 'First Name', value: 'John',
+ },
+ ], { changeHandler });
+
+ const newValue = 'new value';
+ const textField = screen.getByLabelText('First Name');
+ fireEvent.change(textField, { target: { value: newValue } });
+ store.getState().profilePage.account.extendedProfile[0].fieldValue = newValue;
+ expect(changeHandler).toHaveBeenCalledWith('test-form', store.getState().profilePage.account.extendedProfile);
+ });
+
+ it('handles form submission', () => {
+ const submitHandler = jest.fn();
+ renderComponent([
+ {
+ name: 'first_name', type: 'text', label: 'First Name', value: 'John',
+ },
+ ], { submitHandler });
+
+ const form = screen.getByTestId('test-form');
+ fireEvent.submit(form);
+ expect(submitHandler).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/profile/forms/elements/CheckboxField.jsx b/src/profile/forms/elements/CheckboxField.jsx
new file mode 100644
index 000000000..272c8e93d
--- /dev/null
+++ b/src/profile/forms/elements/CheckboxField.jsx
@@ -0,0 +1,166 @@
+/* eslint-disable react/no-danger */
+import React from 'react';
+import DOMPurify from 'dompurify';
+import PropTypes from 'prop-types';
+import { connect } from 'react-redux';
+import { Form } from '@openedx/paragon';
+
+import { editableFormSelector } from '../../data/selectors';
+
+// Components
+import FormControls from './FormControls';
+import EditableItemHeader from './EditableItemHeader';
+import EmptyContent from './EmptyContent';
+import SwitchContent from './SwitchContent';
+
+const CheckboxField = ({
+ formId,
+ value,
+ visibility,
+ editMode,
+ saveState,
+ error,
+ label,
+ placeholder,
+ instructions,
+ changeHandler,
+ submitHandler,
+ closeHandler,
+ openHandler,
+}) => {
+ const handleChange = (e) => {
+ const { name, checked: newValue } = e.target;
+ changeHandler(name, newValue);
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ submitHandler(formId);
+ };
+
+ const handleClose = () => {
+ closeHandler(formId);
+ };
+
+ const handleOpen = () => {
+ openHandler(formId);
+ };
+
+ return (
+
+
+
+
+
+ {error !== null && (
+
+ {error}
+
+ )}
+
+
+
+
+ ),
+ editable: (
+ <>
+