From 3eee9451adb6658cfc36be276d65431c97944350 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Tue, 11 Feb 2025 15:44:04 -0500 Subject: [PATCH 01/16] feat: add extended profile fields functionality to profile page --- src/profile/ProfilePage.jsx | 35 +++++ src/profile/data/actions.js | 26 ++++ src/profile/data/reducers.js | 10 ++ src/profile/data/sagas.js | 20 +++ src/profile/data/selectors.js | 12 ++ src/profile/data/services.js | 25 +++- src/profile/forms/ExtendedProfileFields.jsx | 110 ++++++++++++++ src/profile/forms/elements/SelectField.jsx | 151 +++++++++++++++++++ src/profile/forms/elements/TextField.jsx | 153 ++++++++++++++++++++ 9 files changed, 541 insertions(+), 1 deletion(-) create mode 100644 src/profile/forms/ExtendedProfileFields.jsx create mode 100644 src/profile/forms/elements/SelectField.jsx create mode 100644 src/profile/forms/elements/TextField.jsx diff --git a/src/profile/ProfilePage.jsx b/src/profile/ProfilePage.jsx index 2dfe72dda..fce31a382 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({ is_register_page: true }); sendTrackingLogEvent('edx.profile.viewed', { username: this.props.params.username, }); @@ -291,6 +294,13 @@ class ProfilePage extends React.Component { )}
+ {this.props.extendedProfileFields.length > 0 && ( + + )} {!this.isYOBDisabled() && this.renderAgeMessage()} {isBioBlockVisible && ( ({ export const resetDrafts = () => ({ type: RESET_DRAFTS, }); + +// Third party auth context +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 = (urlParams) => ({ + type: EXTENDED_PROFILE_FIELDS.BASE, + payload: { urlParams }, +}); + +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, +}); + +export const clearThirdPartyAuthContextErrorMessage = () => ({ + type: EXTENDED_PROFILE_FIELDS_CLEAR_ERROR_MSG, +}); diff --git a/src/profile/data/reducers.js b/src/profile/data/reducers.js index 33b29fb18..2511acc8f 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 = {}) => { @@ -155,6 +157,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..89cbb9a34 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'; @@ -117,6 +121,7 @@ export function* handleSaveProfile(action) { 'languageProficiencies', 'name', 'socialLinks', + 'extendedProfile', ]); const preferencesDrafts = pick(drafts, [ @@ -204,9 +209,24 @@ export function* handleDeleteProfilePhoto(action) { } } +export function* fetchThirdPartyAuthContext(action) { + try { + yield put(getExtendedProfileFieldsBegin()); + const { + fields, + } = yield call(ProfileApiService.getExtendedProfileFields, action.payload.urlParams); + + 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, fetchThirdPartyAuthContext); } diff --git a/src/profile/data/selectors.js b/src/profile/data/selectors.js index b04493e6e..5a42a1449 100644 --- a/src/profile/data/selectors.js +++ b/src/profile/data/selectors.js @@ -11,6 +11,7 @@ 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; @@ -323,6 +324,7 @@ export const profilePageSelector = createSelector( isLoadingProfileSelector, draftSocialLinksByPlatformSelector, accountErrorsSelector, + extendedProfileFieldsSelector, ( account, formValues, @@ -332,6 +334,7 @@ export const profilePageSelector = createSelector( isLoadingProfile, draftSocialLinksByPlatform, errors, + extendedProfileFields, ) => ({ // Account data we need username: account.username, @@ -374,5 +377,14 @@ export const profilePageSelector = createSelector( savePhotoState, isLoadingProfile, photoUploadError: errors.photo || null, + + // Extended profile fields + extendedProfileFields: extendedProfileFields.map((field) => ({ + ...field, + value: account.extendedProfile?.find( + (extendedProfileField) => extendedProfileField.fieldName === field.name, + )?.fieldValue, + })), }), + ); diff --git a/src/profile/data/services.js b/src/profile/data/services.js index 45bf68777..df717f4df 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'; @@ -147,3 +147,26 @@ export async function getCourseCertificates(username) { return []; } } + +export async function getExtendedProfileFields(urlParams) { + const requestConfig = { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + params: urlParams, + isPublic: true, + }; + + const { data } = await getAuthenticatedHttpClient() + .get( + `${getConfig().LMS_BASE_URL}/api/mfe_context`, + requestConfig, + ) + .catch((e) => { + throw (e); + }); + + const extendedProfileFields = data.optionalFields.extended_profile + .map((fieldName) => (data.optionalFields.fields[fieldName] ?? data.registrationFields.fields[fieldName])) + .filter(Boolean); + + return { fields: extendedProfileFields }; +} diff --git a/src/profile/forms/ExtendedProfileFields.jsx b/src/profile/forms/ExtendedProfileFields.jsx new file mode 100644 index 000000000..f74293b3e --- /dev/null +++ b/src/profile/forms/ExtendedProfileFields.jsx @@ -0,0 +1,110 @@ +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'; + +// Components +import EditableItemHeader from './elements/EditableItemHeader'; +import SwitchContent from './elements/SwitchContent'; +import SelectField from './elements/SelectField'; +import { editableFormSelector } from '../data/selectors'; +import TextField from './elements/TextField'; + +const ExtendedProfileFields = (props) => { + const { + extendedProfileFields, formId, changeHandler, submitHandler, closeHandler, openHandler, editMode, + } = 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 extendedProfileFields?.map((field) => ( + + +

+ {field.default} +

+ + ), + 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, + })), + error_message: PropTypes.shape({ + required: PropTypes.string, + invalid: PropTypes.string, + }), + restrictions: PropTypes.shape({ + max_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, + + // i18n + intl: intlShape.isRequired, +}; + +ExtendedProfileFields.defaultProps = { +}; + +export default connect(editableFormSelector, {})(injectIntl(ExtendedProfileFields)); diff --git a/src/profile/forms/elements/SelectField.jsx b/src/profile/forms/elements/SelectField.jsx new file mode 100644 index 000000000..12d9f17eb --- /dev/null +++ b/src/profile/forms/elements/SelectField.jsx @@ -0,0 +1,151 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Form } from '@openedx/paragon'; + +import FormControls from './FormControls'; +import EditableItemHeader from './EditableItemHeader'; +import EmptyContent from './EmptyContent'; +import SwitchContent from './SwitchContent'; + +const SelectField = ({ + formId, + value, + visibility, + editMode, + saveState, + error, + options, + label, + emptyMessage, + changeHandler, + submitHandler, + closeHandler, + openHandler, +}) => { + const handleChange = (e) => { + const { name, value: 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: ( + <> + +

{value}

+ + ), + empty: ( + <> + + + {emptyMessage} + + + ), + static: ( + <> + +

{value}

+ + ), + }} + /> + ); +}; + +SelectField.propTypes = { + formId: PropTypes.string.isRequired, + value: PropTypes.string, + visibility: PropTypes.oneOf(['private', 'all_users']), + editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']), + saveState: PropTypes.string, + error: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.shape({ + code: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + })).isRequired, + fieldMessages: PropTypes.objectOf(PropTypes.string).isRequired, + label: PropTypes.string.isRequired, + emptyMessage: PropTypes.string.isRequired, + changeHandler: PropTypes.func.isRequired, + submitHandler: PropTypes.func.isRequired, + closeHandler: PropTypes.func.isRequired, + openHandler: PropTypes.func.isRequired, +}; + +SelectField.defaultProps = { + editMode: 'static', + saveState: null, + value: null, + visibility: 'private', + error: null, +}; + +export default SelectField; diff --git a/src/profile/forms/elements/TextField.jsx b/src/profile/forms/elements/TextField.jsx new file mode 100644 index 000000000..4289c105e --- /dev/null +++ b/src/profile/forms/elements/TextField.jsx @@ -0,0 +1,153 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +// Components +import { Form } from '@openedx/paragon'; +import FormControls from './FormControls'; +import EditableItemHeader from './EditableItemHeader'; +import EmptyContent from './EmptyContent'; +import SwitchContent from './SwitchContent'; + +// Selectors +import { editableFormSelector } from '../../data/selectors'; + +const TextField = ({ + formId, + value, + visibility, + editMode, + saveState, + error, + options, + label, + emptyMessage, + fieldMessages, + placeholder, + instructions, + changeHandler, + submitHandler, + closeHandler, + openHandler, +}) => { + const handleChange = (e) => { + const { name: selected, value: newValue } = e.target; + changeHandler(selected, newValue); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + submitHandler(formId); + }; + + const handleClose = () => { + closeHandler(formId); + }; + + const handleOpen = () => { + openHandler(formId); + }; + + return ( + +
+ + + + {error !== null && ( + + {error} + + )} + + + + + ), + editable: ( + <> + +

{value}

+ + ), + // empty: ( + // <> + // + // + // {intl.formatMessage(messages['profile.name.empty'])} + // + // + // {intl.formatMessage(messages['profile.name.details'])} + // + // + // ), + // static: ( + // <> + // + //

{name}

+ // + // ), + }} + /> + ); +}; + +TextField.propTypes = { + formId: PropTypes.string.isRequired, + value: PropTypes.string, + visibility: PropTypes.oneOf(['private', 'all_users']), + editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']), + saveState: PropTypes.string, + error: PropTypes.string, + options: PropTypes.arrayOf(PropTypes.shape({ + code: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + })).isRequired, + fieldMessages: PropTypes.objectOf(PropTypes.string).isRequired, + label: PropTypes.string.isRequired, + emptyMessage: PropTypes.string.isRequired, + changeHandler: PropTypes.func.isRequired, + submitHandler: PropTypes.func.isRequired, + closeHandler: PropTypes.func.isRequired, + openHandler: PropTypes.func.isRequired, +}; + +TextField.defaultProps = { + editMode: 'static', + saveState: null, + value: null, + visibility: 'private', + error: null, +}; + +export default connect( + editableFormSelector, + {}, +)(TextField); From 022034c33dd3001dfd2f1b74a0ab7db16c089708 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Tue, 11 Feb 2025 15:45:21 -0500 Subject: [PATCH 02/16] feat: add visibility settings for extended profile fields in profile management --- src/profile/data/sagas.js | 1 + src/profile/data/selectors.js | 7 ++ src/profile/data/services.js | 10 ++- src/profile/forms/ExtendedProfileFields.jsx | 80 ++++++++++++++------- src/profile/forms/elements/SelectField.jsx | 5 +- src/profile/utils.js | 4 ++ 6 files changed, 77 insertions(+), 30 deletions(-) diff --git a/src/profile/data/sagas.js b/src/profile/data/sagas.js index 89cbb9a34..95fd71229 100644 --- a/src/profile/data/sagas.js +++ b/src/profile/data/sagas.js @@ -132,6 +132,7 @@ export function* handleSaveProfile(action) { 'visibilityLanguageProficiencies', 'visibilityName', 'visibilitySocialLinks', + 'visibilityExtendedProfile', ]); if (Object.keys(preferencesDrafts).length > 0) { diff --git a/src/profile/data/selectors.js b/src/profile/data/selectors.js index 5a42a1449..97d48ecc0 100644 --- a/src/profile/data/selectors.js +++ b/src/profile/data/selectors.js @@ -239,6 +239,7 @@ export const visibilitiesSelector = createSelector( visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'all_users', visibilityName: preferences.visibilityName || 'all_users', visibilitySocialLinks: preferences.visibilitySocialLinks || 'all_users', + visibilityExtendedProfile: preferences.visibilityExtendedProfile || 'all_users', }; case 'private': return { @@ -249,6 +250,7 @@ export const visibilitiesSelector = createSelector( visibilityLanguageProficiencies: 'private', visibilityName: 'private', visibilitySocialLinks: 'private', + visibilityExtendedProfile: 'private', }; case 'all_users': default: @@ -264,6 +266,7 @@ export const visibilitiesSelector = createSelector( visibilityLanguageProficiencies: 'all_users', visibilityName: 'all_users', visibilitySocialLinks: 'all_users', + visibilityExtendedProfile: 'all_users', }; } }, @@ -312,6 +315,10 @@ export const formValuesSelector = createSelector( drafts.visibilitySocialLinks, visibilities.visibilitySocialLinks, ), + visibilityExtendedProfile: chooseFormValue( + drafts.visibilityExtendedProfile, + visibilities.visibilityExtendedProfile, + ), }), ); diff --git a/src/profile/data/services.js b/src/profile/data/services.js index df717f4df..cfbeccd8e 100644 --- a/src/profile/data/services.js +++ b/src/profile/data/services.js @@ -89,7 +89,12 @@ 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); + + return { + ...processedData, + visibilityExtendedProfile: JSON.parse(data['visibility.extended_profile']), + }; } // PATCH PREFERENCES @@ -105,8 +110,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' }, }); diff --git a/src/profile/forms/ExtendedProfileFields.jsx b/src/profile/forms/ExtendedProfileFields.jsx index f74293b3e..368546c05 100644 --- a/src/profile/forms/ExtendedProfileFields.jsx +++ b/src/profile/forms/ExtendedProfileFields.jsx @@ -1,11 +1,7 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; +import { connect, useSelector } from 'react-redux'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; -import get from 'lodash.get'; - -// Mock Data -import mockData from '../data/mock_data'; // Components import EditableItemHeader from './elements/EditableItemHeader'; @@ -19,17 +15,41 @@ const ExtendedProfileFields = (props) => { extendedProfileFields, formId, changeHandler, submitHandler, closeHandler, openHandler, editMode, } = props; - // if (!learningGoal) { - // learningGoal = mockData.learningGoal; - // } + const draftProfile = useSelector((state) => state.profilePage.drafts); + const extendedProfile = useSelector((state) => state.profilePage.account.extendedProfile); + const visibilityExtendedProfile = useSelector((state) => state.profilePage.preferences.visibilityExtendedProfile); + const [currentEditingField, setCurrentEditingField] = useState(null); + + const handleOpenEdit = (form) => { + const [parentFormId, fieldId] = form.split('/'); + + openHandler(parentFormId); + setCurrentEditingField(fieldId); + }; + + const handleCloseEdit = () => { + closeHandler(null); + setCurrentEditingField(null); + }; - // if (!editMode || editMode === 'empty') { // editMode defaults to 'empty', not sure why yet - // editMode = mockData.editMode; - // } + const handleChangeExtendedField = (name, value) => { + const [parentFormId, fieldName] = name.split('/'); + if (name.includes('visibility')) { + changeHandler(parentFormId, { [fieldName]: value }); + } else { + const fieldIndex = extendedProfile.findIndex((field) => field.fieldName === fieldName); + const newFields = extendedProfile.map( + (extendedField, index) => (index === fieldIndex ? { ...extendedField, fieldValue: value } : extendedField), + ); + changeHandler(parentFormId, newFields); + } + }; - // if (!visibilityLearningGoal) { - // visibilityLearningGoal = mockData.visibilityLearningGoal; - // } + const handleSubmitExtendedField = (form) => { + const [parentFormId] = form.split('/'); + submitHandler(parentFormId); + setCurrentEditingField(null); + }; return extendedProfileFields?.map((field) => ( { ), text: ( ), select: ( extendedField.fieldName === field.name, + )?.fieldValue ?? field.value} + visibility={ + draftProfile?.visibilityExtendedProfile?.[field.name] ?? visibilityExtendedProfile?.[field.name] + } /> ), }} diff --git a/src/profile/forms/elements/SelectField.jsx b/src/profile/forms/elements/SelectField.jsx index 12d9f17eb..74643b2cc 100644 --- a/src/profile/forms/elements/SelectField.jsx +++ b/src/profile/forms/elements/SelectField.jsx @@ -6,6 +6,7 @@ import FormControls from './FormControls'; import EditableItemHeader from './EditableItemHeader'; import EmptyContent from './EmptyContent'; import SwitchContent from './SwitchContent'; +import { capitalizeFirstLetter } from '../../utils'; const SelectField = ({ formId, @@ -76,7 +77,7 @@ const SelectField = ({ )} Date: Thu, 13 Feb 2025 12:27:48 -0500 Subject: [PATCH 03/16] feat: add extended profile fields handling and visibility logic in profile components --- package-lock.json | 17 +++ package.json | 1 + src/profile/ProfilePage.jsx | 20 ++- src/profile/data/reducers.js | 16 +- src/profile/data/selectors.js | 7 + src/profile/forms/ExtendedProfileFields.jsx | 139 ++++++++--------- src/profile/forms/elements/CheckboxField.jsx | 153 +++++++++++++++++++ src/profile/forms/elements/FormControls.jsx | 6 +- src/profile/forms/elements/SelectField.jsx | 11 +- src/profile/forms/elements/TextField.jsx | 65 ++++---- src/profile/utils.js | 17 +++ 11 files changed, 325 insertions(+), 127 deletions(-) create mode 100644 src/profile/forms/elements/CheckboxField.jsx diff --git a/package-lock.json b/package-lock.json index 616fe050c..00d1b5d11 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", @@ -5852,6 +5853,13 @@ "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", "license": "MIT" }, + "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/warning": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.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 fce31a382..766c6552a 100644 --- a/src/profile/ProfilePage.jsx +++ b/src/profile/ProfilePage.jsx @@ -189,6 +189,7 @@ class ProfilePage extends React.Component { username, saveState, navigate, + extendedProfileFields, } = this.props; if (isLoadingProfile) { @@ -216,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 fields + // So this can be shown for no-authenticated user profiles + const isExtendedProfileFieldsVisible = isBlockVisible( + extendedProfileFields.length > 0 && this.isAuthenticatedUserProfile(), + ); return (
@@ -283,6 +289,13 @@ class ProfilePage extends React.Component { {...commonFormProps} /> )} + {isExtendedProfileFieldsVisible && ( + + )} {isSocialLinksBLockVisible && (
- {this.props.extendedProfileFields.length > 0 && ( - - )} {!this.isYOBDisabled() && this.renderAgeMessage()} {isBioBlockVisible && ( { 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 { diff --git a/src/profile/data/selectors.js b/src/profile/data/selectors.js index 97d48ecc0..729ba5a37 100644 --- a/src/profile/data/selectors.js +++ b/src/profile/data/selectors.js @@ -32,10 +32,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'; diff --git a/src/profile/forms/ExtendedProfileFields.jsx b/src/profile/forms/ExtendedProfileFields.jsx index 368546c05..ba720a90a 100644 --- a/src/profile/forms/ExtendedProfileFields.jsx +++ b/src/profile/forms/ExtendedProfileFields.jsx @@ -1,100 +1,86 @@ -import React, { useState } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { connect, useSelector } from 'react-redux'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useSelector } from 'react-redux'; +import { intlShape } from '@edx/frontend-platform/i18n'; // Components -import EditableItemHeader from './elements/EditableItemHeader'; import SwitchContent from './elements/SwitchContent'; import SelectField from './elements/SelectField'; -import { editableFormSelector } from '../data/selectors'; import TextField from './elements/TextField'; +import CheckboxField from './elements/CheckboxField'; +import { moveCheckboxFieldsToEnd } from '../utils'; const ExtendedProfileFields = (props) => { const { - extendedProfileFields, formId, changeHandler, submitHandler, closeHandler, openHandler, editMode, + 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 [currentEditingField, setCurrentEditingField] = useState(null); - - const handleOpenEdit = (form) => { - const [parentFormId, fieldId] = form.split('/'); - - openHandler(parentFormId); - setCurrentEditingField(fieldId); - }; - - const handleCloseEdit = () => { - closeHandler(null); - setCurrentEditingField(null); - }; const handleChangeExtendedField = (name, value) => { - const [parentFormId, fieldName] = name.split('/'); - if (name.includes('visibility')) { - changeHandler(parentFormId, { [fieldName]: value }); - } else { - const fieldIndex = extendedProfile.findIndex((field) => field.fieldName === fieldName); - const newFields = extendedProfile.map( - (extendedField, index) => (index === fieldIndex ? { ...extendedField, fieldValue: value } : extendedField), + 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(parentFormId, newFields); - } + changeHandler(parentPropKey, newFields); }; const handleSubmitExtendedField = (form) => { - const [parentFormId] = form.split('/'); - submitHandler(parentFormId); - setCurrentEditingField(null); + submitHandler(form); }; - return extendedProfileFields?.map((field) => ( - - -

- {field.default} -

- - ), - text: ( - - ), - select: ( - extendedField.fieldName === field.name, - )?.fieldValue ?? field.value} - visibility={ - draftProfile?.visibilityExtendedProfile?.[field.name] ?? visibilityExtendedProfile?.[field.name] - } + return ( +
+ {extendedProfileFields.sort(moveCheckboxFieldsToEnd)?.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, + formId: `${formId}/${field.name}`, + changeHandler: handleChangeExtendedField, + submitHandler: handleSubmitExtendedField, + closeHandler, + openHandler, + value, + visibility, + }; + + return ( + + ), + text: ( + + ), + select: ( + + ), + }} /> - ), - }} - /> - )); + ); + })} +
+ ); }; ExtendedProfileFields.propTypes = { @@ -126,6 +112,9 @@ ExtendedProfileFields.propTypes = { closeHandler: PropTypes.func.isRequired, openHandler: PropTypes.func.isRequired, + // Props + isAuthenticatedUserProfile: PropTypes.bool.isRequired, + // i18n intl: intlShape.isRequired, }; @@ -133,4 +122,4 @@ ExtendedProfileFields.propTypes = { ExtendedProfileFields.defaultProps = { }; -export default connect(editableFormSelector, {})(injectIntl(ExtendedProfileFields)); +export default ExtendedProfileFields; diff --git a/src/profile/forms/elements/CheckboxField.jsx b/src/profile/forms/elements/CheckboxField.jsx new file mode 100644 index 000000000..88126384a --- /dev/null +++ b/src/profile/forms/elements/CheckboxField.jsx @@ -0,0 +1,153 @@ +/* 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: ( + <> + +
+ + )} + showEditButton + onClickEdit={handleOpen} + showVisibility={false} + visibility={visibility} + /> +

{value}

+ + ), + empty: ( + <> + + + {instructions} + + + {placeholder} + + + ), + static: ( + <> + +

{value}

+ + ), + }} + /> + ); +}; + +CheckboxField.propTypes = { + formId: PropTypes.string.isRequired, + value: PropTypes.bool, + visibility: PropTypes.oneOf(['private', 'all_users']), + editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']), + saveState: PropTypes.string, + error: PropTypes.string, + placeholder: PropTypes.string, + instructions: PropTypes.string, + label: PropTypes.string.isRequired, + changeHandler: PropTypes.func.isRequired, + submitHandler: PropTypes.func.isRequired, + closeHandler: PropTypes.func.isRequired, + openHandler: PropTypes.func.isRequired, +}; + +CheckboxField.defaultProps = { + editMode: 'static', + saveState: null, + value: false, + placeholder: '', + instructions: '', + visibility: 'private', + error: null, +}; + +export default connect(editableFormSelector, {})(CheckboxField); diff --git a/src/profile/forms/elements/FormControls.jsx b/src/profile/forms/elements/FormControls.jsx index e1fcaac78..0b1c574ec 100644 --- a/src/profile/forms/elements/FormControls.jsx +++ b/src/profile/forms/elements/FormControls.jsx @@ -8,13 +8,14 @@ import messages from './FormControls.messages'; import { VisibilitySelect } from './Visibility'; const FormControls = ({ - cancelHandler, changeHandler, visibility, visibilityId, saveState, intl, + cancelHandler, changeHandler, visibility, visibilityId, saveState, intl, showVisibilitySelect, }) => { // Eliminate error/failed state for save button const buttonState = saveState === 'error' ? null : saveState; return (
+ {showVisibilitySelect && (
+ )}
@@ -149,4 +150,4 @@ SelectField.defaultProps = { error: null, }; -export default SelectField; +export default connect(editableFormSelector, {})(SelectField); diff --git a/src/profile/forms/elements/TextField.jsx b/src/profile/forms/elements/TextField.jsx index 4289c105e..0517846ba 100644 --- a/src/profile/forms/elements/TextField.jsx +++ b/src/profile/forms/elements/TextField.jsx @@ -1,15 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; // Components import { Form } from '@openedx/paragon'; +import { connect } from 'react-redux'; import FormControls from './FormControls'; import EditableItemHeader from './EditableItemHeader'; import EmptyContent from './EmptyContent'; import SwitchContent from './SwitchContent'; - -// Selectors import { editableFormSelector } from '../../data/selectors'; const TextField = ({ @@ -19,10 +17,7 @@ const TextField = ({ editMode, saveState, error, - options, label, - emptyMessage, - fieldMessages, placeholder, instructions, changeHandler, @@ -31,8 +26,8 @@ const TextField = ({ openHandler, }) => { const handleChange = (e) => { - const { name: selected, value: newValue } = e.target; - changeHandler(selected, newValue); + const { name, value: newValue } = e.target; + changeHandler(name, newValue); }; const handleSubmit = (e) => { @@ -50,7 +45,6 @@ const TextField = ({ return ( @@ -97,23 +93,23 @@ const TextField = ({

{value}

), - // empty: ( - // <> - // - // - // {intl.formatMessage(messages['profile.name.empty'])} - // - // - // {intl.formatMessage(messages['profile.name.details'])} - // - // - // ), - // static: ( - // <> - // - //

{name}

- // - // ), + empty: ( + <> + + + {instructions} + + + {placeholder} + + + ), + static: ( + <> + +

{value}

+ + ), }} /> ); @@ -126,13 +122,9 @@ TextField.propTypes = { editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']), saveState: PropTypes.string, error: PropTypes.string, - options: PropTypes.arrayOf(PropTypes.shape({ - code: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - })).isRequired, - fieldMessages: PropTypes.objectOf(PropTypes.string).isRequired, + placeholder: PropTypes.string, + instructions: PropTypes.string, label: PropTypes.string.isRequired, - emptyMessage: PropTypes.string.isRequired, changeHandler: PropTypes.func.isRequired, submitHandler: PropTypes.func.isRequired, closeHandler: PropTypes.func.isRequired, @@ -143,11 +135,10 @@ TextField.defaultProps = { editMode: 'static', saveState: null, value: null, + placeholder: '', + instructions: '', visibility: 'private', error: null, }; -export default connect( - editableFormSelector, - {}, -)(TextField); +export default connect(editableFormSelector, {})(TextField); diff --git a/src/profile/utils.js b/src/profile/utils.js index 51efec050..080c0f1f4 100644 --- a/src/profile/utils.js +++ b/src/profile/utils.js @@ -73,3 +73,20 @@ export class AsyncActionType { return `${this.topic}__${this.name}__RESET`; } } + +/** + * Sort fields so that checkbox fields are at the end of the list. + * @param {object} a the first field to compare + * @param {object} b the second field to compare + * @returns a negative integer if a should come before b, a positive integer + * if a should come after b, or 0 if a and b have the same order + */ +export const moveCheckboxFieldsToEnd = (a, b) => { + if (a.type === 'checkbox' && b.type !== 'checkbox') { + return 1; + } + if (a.type !== 'checkbox' && b.type === 'checkbox') { + return -1; + } + return 0; +}; From 29b9673778381b55f1b71f9f189e3e44202da976 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Mon, 17 Feb 2025 08:40:42 -0500 Subject: [PATCH 04/16] feat: enhance visibility handling for extended profile fields in selectors and forms --- src/profile/data/selectors.js | 28 ++++++++++++++++----- src/profile/forms/ExtendedProfileFields.jsx | 2 +- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/src/profile/data/selectors.js b/src/profile/data/selectors.js index 729ba5a37..e0897f922 100644 --- a/src/profile/data/selectors.js +++ b/src/profile/data/selectors.js @@ -246,9 +246,16 @@ export const visibilitiesSelector = createSelector( visibilityLanguageProficiencies: preferences.visibilityLanguageProficiencies || 'all_users', visibilityName: preferences.visibilityName || 'all_users', visibilitySocialLinks: preferences.visibilitySocialLinks || 'all_users', - visibilityExtendedProfile: preferences.visibilityExtendedProfile || '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', @@ -257,14 +264,22 @@ export const visibilitiesSelector = createSelector( visibilityLanguageProficiencies: 'private', visibilityName: 'private', visibilitySocialLinks: 'private', - visibilityExtendedProfile: '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', @@ -273,8 +288,8 @@ export const visibilitiesSelector = createSelector( visibilityLanguageProficiencies: 'all_users', visibilityName: 'all_users', visibilitySocialLinks: 'all_users', - visibilityExtendedProfile: 'all_users', - }; + visibilityExtendedProfile, + }; } } }, ); @@ -393,6 +408,7 @@ export const profilePageSelector = createSelector( photoUploadError: errors.photo || null, // Extended profile fields + // Combine the field properties and its values extendedProfileFields: extendedProfileFields.map((field) => ({ ...field, value: account.extendedProfile?.find( diff --git a/src/profile/forms/ExtendedProfileFields.jsx b/src/profile/forms/ExtendedProfileFields.jsx index ba720a90a..a3bd55927 100644 --- a/src/profile/forms/ExtendedProfileFields.jsx +++ b/src/profile/forms/ExtendedProfileFields.jsx @@ -41,7 +41,7 @@ const ExtendedProfileFields = (props) => { }; return ( -
+
{extendedProfileFields.sort(moveCheckboxFieldsToEnd)?.map((field) => { const value = draftProfile?.extendedProfile?.find( (extendedField) => extendedField.fieldName === field.name, From 70c93e3ef01c965699de966b7eabfad38fdc9e11 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Mon, 17 Feb 2025 11:27:23 -0500 Subject: [PATCH 05/16] fix: solve visibility issues when user requiresParentalConsent --- src/profile/ProfilePage.jsx | 4 ++-- src/profile/data/sagas.js | 2 +- src/profile/data/services.js | 3 ++- src/profile/forms/ExtendedProfileFields.jsx | 12 +++--------- src/profile/forms/elements/CheckboxField.jsx | 15 +++++++++++++-- src/profile/forms/elements/SelectField.jsx | 2 +- src/profile/forms/elements/TextField.jsx | 2 +- 7 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/profile/ProfilePage.jsx b/src/profile/ProfilePage.jsx index 766c6552a..aea421c33 100644 --- a/src/profile/ProfilePage.jsx +++ b/src/profile/ProfilePage.jsx @@ -217,8 +217,8 @@ 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 fields - // So this can be shown for no-authenticated user profiles + // 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(), ); diff --git a/src/profile/data/sagas.js b/src/profile/data/sagas.js index 95fd71229..fca1bd5d2 100644 --- a/src/profile/data/sagas.js +++ b/src/profile/data/sagas.js @@ -97,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 { diff --git a/src/profile/data/services.js b/src/profile/data/services.js index cfbeccd8e..c453af4cc 100644 --- a/src/profile/data/services.js +++ b/src/profile/data/services.js @@ -90,10 +90,11 @@ export async function getPreferences(username) { const { data } = await getHttpClient().get(`${getConfig().LMS_BASE_URL}/api/user/v1/preferences/${username}`); const processedData = camelCaseObject(data); + const visibilityExtendedProfile = Object.prototype.hasOwnProperty.call(data, 'visibilityExtendedProfile'); return { ...processedData, - visibilityExtendedProfile: JSON.parse(data['visibility.extended_profile']), + visibilityExtendedProfile: visibilityExtendedProfile ? JSON.parse(data['visibility.extended_profile']) : {}, }; } diff --git a/src/profile/forms/ExtendedProfileFields.jsx b/src/profile/forms/ExtendedProfileFields.jsx index a3bd55927..a7b87cff3 100644 --- a/src/profile/forms/ExtendedProfileFields.jsx +++ b/src/profile/forms/ExtendedProfileFields.jsx @@ -66,15 +66,9 @@ const ExtendedProfileFields = (props) => { className="mb-5" expression={field.type} cases={{ - checkbox: ( - - ), - text: ( - - ), - select: ( - - ), + checkbox: , + text: , + select: , }} /> ); diff --git a/src/profile/forms/elements/CheckboxField.jsx b/src/profile/forms/elements/CheckboxField.jsx index 88126384a..c243969e5 100644 --- a/src/profile/forms/elements/CheckboxField.jsx +++ b/src/profile/forms/elements/CheckboxField.jsx @@ -113,9 +113,20 @@ const CheckboxField = ({ ), - static: ( + static: value && ( <> - + +
+ + )} + showVisibility={false} + />

{value}

), diff --git a/src/profile/forms/elements/SelectField.jsx b/src/profile/forms/elements/SelectField.jsx index 0955f4fd2..be287eb81 100644 --- a/src/profile/forms/elements/SelectField.jsx +++ b/src/profile/forms/elements/SelectField.jsx @@ -109,7 +109,7 @@ const SelectField = ({ ), - static: ( + static: value && ( <> ), - static: ( + static: value && ( <>

{value}

From 695ac7e05dbd83286df79150990b223820b04017 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Mon, 24 Feb 2025 14:50:54 -0500 Subject: [PATCH 06/16] fix: solve tests & snapshot issues --- src/profile/__snapshots__/ProfilePage.test.jsx.snap | 9 +++++++++ src/profile/data/sagas.test.js | 3 +++ src/profile/data/selectors.js | 2 +- 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/profile/__snapshots__/ProfilePage.test.jsx.snap b/src/profile/__snapshots__/ProfilePage.test.jsx.snap index e43a2e185..6eb2be128 100644 --- a/src/profile/__snapshots__/ProfilePage.test.jsx.snap +++ b/src/profile/__snapshots__/ProfilePage.test.jsx.snap @@ -442,6 +442,7 @@ exports[` Renders correctly in various states successfully redire
+
@@ -2483,6 +2484,7 @@ exports[` Renders correctly in various states test country edit w

+
@@ -3482,6 +3484,7 @@ exports[` Renders correctly in various states test education edit
+
@@ -5356,6 +5359,7 @@ exports[` Renders correctly in various states test preferreded la

+
@@ -6076,6 +6080,7 @@ exports[` Renders correctly in various states viewing other profi

+
@@ -6639,6 +6644,7 @@ exports[` Renders correctly in various states viewing own profile

+
@@ -7516,6 +7522,7 @@ exports[` Renders correctly in various states while saving an edi

+
@@ -8457,6 +8464,7 @@ exports[` Renders correctly in various states while saving an edi

+
@@ -9326,6 +9334,7 @@ exports[` Renders correctly in various states without credentials

+
diff --git a/src/profile/data/sagas.test.js b/src/profile/data/sagas.test.js index 379c0cfd6..dbfb0647c 100644 --- a/src/profile/data/sagas.test.js +++ b/src/profile/data/sagas.test.js @@ -32,6 +32,7 @@ import profileSaga, { handleSaveProfile, handleSaveProfilePhoto, handleDeleteProfilePhoto, + fetchThirdPartyAuthContext, } from './sagas'; import * as ProfileApiService from './services'; /* eslint-enable import/first */ @@ -49,6 +50,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, fetchThirdPartyAuthContext)); expect(gen.next().value).toBeUndefined(); }); diff --git a/src/profile/data/selectors.js b/src/profile/data/selectors.js index e0897f922..ae80a36b2 100644 --- a/src/profile/data/selectors.js +++ b/src/profile/data/selectors.js @@ -409,7 +409,7 @@ export const profilePageSelector = createSelector( // Extended profile fields // Combine the field properties and its values - extendedProfileFields: extendedProfileFields.map((field) => ({ + extendedProfileFields: extendedProfileFields?.map((field) => ({ ...field, value: account.extendedProfile?.find( (extendedProfileField) => extendedProfileField.fieldName === field.name, From 4f0eb8a7d2cdeede434d52218dcfcbbb5d6486d1 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Wed, 26 Feb 2025 14:28:49 -0500 Subject: [PATCH 07/16] test: improve coverage --- src/profile/forms/ExtendedProfileFields.jsx | 2 + .../forms/ExtendedProfileFields.test.jsx | 120 ++++++++++++++++++ src/profile/forms/elements/CheckboxField.jsx | 4 +- .../forms/elements/CheckboxField.test.jsx | 91 +++++++++++++ src/profile/forms/elements/SelectField.jsx | 4 +- .../forms/elements/SelectField.test.jsx | 94 ++++++++++++++ src/profile/forms/elements/TextField.jsx | 4 +- src/profile/forms/elements/TextField.test.jsx | 91 +++++++++++++ 8 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 src/profile/forms/ExtendedProfileFields.test.jsx create mode 100644 src/profile/forms/elements/CheckboxField.test.jsx create mode 100644 src/profile/forms/elements/SelectField.test.jsx create mode 100644 src/profile/forms/elements/TextField.test.jsx diff --git a/src/profile/forms/ExtendedProfileFields.jsx b/src/profile/forms/ExtendedProfileFields.jsx index a7b87cff3..1170501b3 100644 --- a/src/profile/forms/ExtendedProfileFields.jsx +++ b/src/profile/forms/ExtendedProfileFields.jsx @@ -117,3 +117,5 @@ 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) => ( +
+ props.changeHandler(props.formId, e.target.value)} /> +
+))); +jest.mock('./elements/CheckboxField', () => jest.fn((props) => ( +
+ props.changeHandler(props.formId, e.target.checked)} /> +
+))); + +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 index c243969e5..272c8e93d 100644 --- a/src/profile/forms/elements/CheckboxField.jsx +++ b/src/profile/forms/elements/CheckboxField.jsx @@ -52,7 +52,7 @@ const CheckboxField = ({ cases={{ editing: (
-
+ { + const defaultProps = { + formId: 'test-checkbox', + value: false, + visibility: 'private', + editMode: 'editing', + saveState: null, + error: null, + label: 'Accept Terms', + placeholder: 'Please accept', + instructions: 'Check the box to continue', + changeHandler: jest.fn(), + submitHandler: jest.fn(), + closeHandler: jest.fn(), + openHandler: jest.fn(), + }; + + let store; + + beforeEach(() => { + store = mockStore({}); + }); + + const renderComponent = (props = {}) => { + render( + + + + + , + ); + }; + + it('renders checkbox in editing mode', () => { + renderComponent(); + expect(screen.getByRole('checkbox')).toBeInTheDocument(); + }); + + it('calls changeHandler when checkbox is clicked', () => { + renderComponent(); + const checkbox = screen.getByRole('checkbox'); + fireEvent.click(checkbox); + expect(defaultProps.changeHandler).toHaveBeenCalledWith('test-checkbox', true); + }); + + it('calls submitHandler when form is submitted', () => { + renderComponent(); + const form = screen.getByTestId('test-form'); + fireEvent.submit(form); + expect(defaultProps.submitHandler).toHaveBeenCalledWith('test-checkbox'); + }); + + it('calls closeHandler when form is closed', () => { + renderComponent(); + fireEvent.click(screen.getByText('Cancel')); + expect(defaultProps.closeHandler).toHaveBeenCalledWith('test-checkbox'); + }); + + it('renders empty state when in empty mode', () => { + renderComponent({ editMode: 'empty' }); + expect(screen.getByText('Check the box to continue')).toBeInTheDocument(); + }); + + it('renders static mode with checked value', () => { + renderComponent({ editMode: 'static', value: true }); + expect(screen.getByLabelText('Accept Terms')).toBeChecked(); + }); + + it('renders editable mode with edit button', () => { + renderComponent({ editMode: 'editable' }); + expect(screen.getByText('Accept Terms')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); + }); + + it('calls openHandler when edit button is clicked', () => { + renderComponent({ editMode: 'editable' }); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); + expect(defaultProps.openHandler).toHaveBeenCalledWith('test-checkbox'); + }); +}); diff --git a/src/profile/forms/elements/SelectField.jsx b/src/profile/forms/elements/SelectField.jsx index be287eb81..8c9a89147 100644 --- a/src/profile/forms/elements/SelectField.jsx +++ b/src/profile/forms/elements/SelectField.jsx @@ -48,7 +48,7 @@ const SelectField = ({ cases={{ editing: (
- + { + const defaultProps = { + formId: 'test-select', + value: '', + visibility: 'private', + editMode: 'editing', + saveState: null, + error: null, + options: [ + ['us', 'United States'], + ['ca', 'Canada'], + ['uk', 'United Kingdom'], + ], + label: 'Country', + emptyMessage: 'No country selected', + changeHandler: jest.fn(), + submitHandler: jest.fn(), + closeHandler: jest.fn(), + openHandler: jest.fn(), + }; + + let store; + + beforeEach(() => { + store = mockStore({}); + }); + + const renderComponent = (props = {}) => { + render( + + + + + , + ); + }; + + it('renders select field in editing mode', () => { + renderComponent(); + expect(screen.getByLabelText('Country')).toBeInTheDocument(); + }); + + it('calls changeHandler when an option is selected', () => { + renderComponent(); + const select = screen.getByLabelText('Country'); + fireEvent.change(select, { target: { value: 'ca' } }); + expect(defaultProps.changeHandler).toHaveBeenCalledWith('test-select', 'ca'); + }); + + it('calls submitHandler when form is submitted', () => { + renderComponent(); + const form = screen.getByTestId('test-form'); + fireEvent.submit(form); + expect(defaultProps.submitHandler).toHaveBeenCalledWith('test-select'); + }); + + it('calls closeHandler when form is closed', () => { + renderComponent(); + defaultProps.closeHandler.mockClear(); + fireEvent.click(screen.getByText('Cancel')); + expect(defaultProps.closeHandler).toHaveBeenCalledWith('test-select'); + }); + + it('renders empty state when in empty mode', () => { + renderComponent({ editMode: 'empty' }); + expect(screen.getByText('No country selected')).toBeInTheDocument(); + }); + + it('renders static mode with selected value', () => { + renderComponent({ editMode: 'static', value: 'United States' }); + expect(screen.getByText('United States')).toBeInTheDocument(); + }); + + it('renders editable mode with edit button', () => { + renderComponent({ editMode: 'editable' }); + expect(screen.getByText('Country')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); + }); + + it('calls openHandler when edit button is clicked', () => { + renderComponent({ editMode: 'editable' }); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); + expect(defaultProps.openHandler).toHaveBeenCalledWith('test-select'); + }); +}); diff --git a/src/profile/forms/elements/TextField.jsx b/src/profile/forms/elements/TextField.jsx index 5d355e3d6..e91df31b6 100644 --- a/src/profile/forms/elements/TextField.jsx +++ b/src/profile/forms/elements/TextField.jsx @@ -49,7 +49,7 @@ const TextField = ({ cases={{ editing: (
- + { + const defaultProps = { + formId: 'test-text', + value: '', + visibility: 'private', + editMode: 'editing', + saveState: null, + error: null, + label: 'Username', + placeholder: 'Enter your username', + instructions: 'Provide a unique username', + changeHandler: jest.fn(), + submitHandler: jest.fn(), + closeHandler: jest.fn(), + openHandler: jest.fn(), + }; + + let store; + + beforeEach(() => { + store = mockStore({}); + }); + + const renderComponent = (props = {}) => { + render( + + + + + , + ); + }; + + it('renders text field in editing mode', () => { + renderComponent(); + expect(screen.getByLabelText('Username')).toBeInTheDocument(); + }); + + it('calls changeHandler when text is entered', () => { + renderComponent(); + const input = screen.getByLabelText('Username'); + fireEvent.change(input, { target: { value: 'new-user' } }); + expect(defaultProps.changeHandler).toHaveBeenCalledWith('test-text', 'new-user'); + }); + + it('calls submitHandler when form is submitted', () => { + renderComponent(); + const form = screen.getByTestId('test-form'); + fireEvent.submit(form); + expect(defaultProps.submitHandler).toHaveBeenCalledWith('test-text'); + }); + + it('calls closeHandler when form is closed', () => { + renderComponent(); + defaultProps.closeHandler.mockClear(); + fireEvent.click(screen.getByText('Cancel')); + expect(defaultProps.closeHandler).toHaveBeenCalledWith('test-text'); + }); + + it('renders empty state when in empty mode', () => { + renderComponent({ editMode: 'empty' }); + expect(screen.getByText('Provide a unique username')).toBeInTheDocument(); + expect(screen.getByText('Enter your username')).toBeInTheDocument(); + }); + + it('renders static mode with entered value', () => { + renderComponent({ editMode: 'static', value: 'existing-user' }); + expect(screen.getByText('existing-user')).toBeInTheDocument(); + }); + + it('renders editable mode with edit button', () => { + renderComponent({ editMode: 'editable' }); + expect(screen.getByText('Username')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); + }); + + it('calls openHandler when edit button is clicked', () => { + renderComponent({ editMode: 'editable' }); + fireEvent.click(screen.getByRole('button', { name: /edit/i })); + expect(defaultProps.openHandler).toHaveBeenCalledWith('test-text'); + }); +}); From 932049fb578f89ad3989b13480d5d7b782aecbf6 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Wed, 26 Feb 2025 14:52:45 -0500 Subject: [PATCH 08/16] test: improve coverage --- src/profile/data/sagas.test.js | 42 ++++++++++++++++++++++ src/profile/utils.test.js | 66 ++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/src/profile/data/sagas.test.js b/src/profile/data/sagas.test.js index dbfb0647c..47ef65936 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', () => ({ @@ -166,4 +169,43 @@ describe('RootSaga', () => { expect(gen.next().value).toBeUndefined(); }); }); + + describe('fetchThirdPartyAuthContext', () => { + 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), + }, + fetchThirdPartyAuthContext, + 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), + }, + fetchThirdPartyAuthContext, + mockedAction, + ).toPromise()).rejects.toThrow('API error'); + + expect(dispatched).toContainEqual(profileActions.getExtendedProfileFieldsBegin()); + expect(dispatched).toContainEqual(profileActions.getExtendedProfileFieldsFailure()); + }); + }); }); diff --git a/src/profile/utils.test.js b/src/profile/utils.test.js index c015e0eb7..95c9147f4 100644 --- a/src/profile/utils.test.js +++ b/src/profile/utils.test.js @@ -4,6 +4,8 @@ import { camelCaseObject, snakeCaseObject, convertKeyNames, + moveCheckboxFieldsToEnd, + capitalizeFirstLetter, } from './utils'; describe('modifyObjectKeys', () => { @@ -100,4 +102,68 @@ describe('AsyncActionType', () => { expect(actionType.FAILURE).toBe('HOUSE_CATS__START_THE_RACE__FAILURE'); expect(actionType.RESET).toBe('HOUSE_CATS__START_THE_RACE__RESET'); }); + + describe('moveCheckboxFieldsToEnd', () => { + it('returns 1 when first field is checkbox and second field is not', () => { + const a = { type: 'checkbox' }; + const b = { type: 'text' }; + + expect(moveCheckboxFieldsToEnd(a, b)).toEqual(1); + }); + + it('returns -1 when first field is not checkbox and second field is', () => { + const a = { type: 'text' }; + const b = { type: 'checkbox' }; + + expect(moveCheckboxFieldsToEnd(a, b)).toEqual(-1); + }); + + it('returns 0 when both fields are checkboxes', () => { + const a = { type: 'checkbox' }; + const b = { type: 'checkbox' }; + + expect(moveCheckboxFieldsToEnd(a, b)).toEqual(0); + }); + + it('returns 0 when neither field is a checkbox', () => { + const a = { type: 'text' }; + const b = { type: 'text' }; + + expect(moveCheckboxFieldsToEnd(a, b)).toEqual(0); + }); + }); + + describe('capitalizeFirstLetter', () => { + it('capitalizes the first letter of a string', () => { + expect(capitalizeFirstLetter('hello')).toBe('Hello'); + }); + + it('returns an empty string when given an empty string', () => { + expect(capitalizeFirstLetter('')).toBe(''); + }); + + it('handles single character strings', () => { + expect(capitalizeFirstLetter('a')).toBe('A'); + }); + + it('handles strings with only one word', () => { + expect(capitalizeFirstLetter('word')).toBe('Word'); + }); + + it('handles strings with multiple words', () => { + expect(capitalizeFirstLetter('multiple words')).toBe('Multiple words'); + }); + + it('handles non-string inputs by converting them to strings', () => { + expect(capitalizeFirstLetter(123)).toBe('123'); + expect(capitalizeFirstLetter(null)).toBe('Null'); + expect(capitalizeFirstLetter(undefined)).toBe('Undefined'); + expect(capitalizeFirstLetter(true)).toBe('True'); + }); + + it('handles strings with special characters', () => { + expect(capitalizeFirstLetter('!hello')).toBe('!hello'); + expect(capitalizeFirstLetter('@world')).toBe('@world'); + }); + }); }); From f6efefc7662a6870d0ba89380fc5fd38f24b8303 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Wed, 9 Apr 2025 15:08:44 -0500 Subject: [PATCH 09/16] fix: reorder entries in package-lock.json for consistency --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 00d1b5d11..9f8e7fad2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5847,12 +5847,6 @@ "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==" }, - "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", - "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", - "license": "MIT" - }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -5860,6 +5854,12 @@ "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", + "integrity": "sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==", + "license": "MIT" + }, "node_modules/@types/warning": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", From af575d7875c5483160006821d719a341154f42bb Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 11 Apr 2025 15:45:00 -0500 Subject: [PATCH 10/16] fix: adjust formatting and add missing prop types in ProfilePage component --- src/profile/ProfilePage.jsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/profile/ProfilePage.jsx b/src/profile/ProfilePage.jsx index aea421c33..328c2d2bc 100644 --- a/src/profile/ProfilePage.jsx +++ b/src/profile/ProfilePage.jsx @@ -290,11 +290,11 @@ class ProfilePage extends React.Component { /> )} {isExtendedProfileFieldsVisible && ( - + )} {isSocialLinksBLockVisible && ( Date: Fri, 11 Apr 2025 15:45:33 -0500 Subject: [PATCH 11/16] fix: take the fields with the proper restrictions --- src/profile/data/services.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/profile/data/services.js b/src/profile/data/services.js index c453af4cc..de443c807 100644 --- a/src/profile/data/services.js +++ b/src/profile/data/services.js @@ -174,7 +174,7 @@ export async function getExtendedProfileFields(urlParams) { }); const extendedProfileFields = data.optionalFields.extended_profile - .map((fieldName) => (data.optionalFields.fields[fieldName] ?? data.registrationFields.fields[fieldName])) + .map((fieldName) => (data.registrationFields.fields[fieldName])) .filter(Boolean); return { fields: extendedProfileFields }; From c2b8b27cabc432a2173c3d6835451f6a195f83f1 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 11 Apr 2025 15:45:45 -0500 Subject: [PATCH 12/16] fix: enhance error handling and validation in profile form fields --- src/profile/forms/ExtendedProfileFields.jsx | 3 +- src/profile/forms/elements/FormControls.jsx | 5 ++- src/profile/forms/elements/TextField.jsx | 46 +++++++++++++++++---- 3 files changed, 45 insertions(+), 9 deletions(-) diff --git a/src/profile/forms/ExtendedProfileFields.jsx b/src/profile/forms/ExtendedProfileFields.jsx index 1170501b3..ef5c2a49d 100644 --- a/src/profile/forms/ExtendedProfileFields.jsx +++ b/src/profile/forms/ExtendedProfileFields.jsx @@ -52,6 +52,7 @@ const ExtendedProfileFields = (props) => { const commonProps = { ...field, + errorMessage: field.error_message, formId: `${formId}/${field.name}`, changeHandler: handleChangeExtendedField, submitHandler: handleSubmitExtendedField, @@ -92,10 +93,10 @@ ExtendedProfileFields.propTypes = { })), error_message: PropTypes.shape({ required: PropTypes.string, - invalid: PropTypes.string, }), restrictions: PropTypes.shape({ max_length: PropTypes.number, + min_length: PropTypes.number, }), type: PropTypes.string.isRequired, })).isRequired, diff --git a/src/profile/forms/elements/FormControls.jsx b/src/profile/forms/elements/FormControls.jsx index 0b1c574ec..20652d91e 100644 --- a/src/profile/forms/elements/FormControls.jsx +++ b/src/profile/forms/elements/FormControls.jsx @@ -8,7 +8,7 @@ import messages from './FormControls.messages'; import { VisibilitySelect } from './Visibility'; const FormControls = ({ - cancelHandler, changeHandler, visibility, visibilityId, saveState, intl, showVisibilitySelect, + cancelHandler, changeHandler, visibility, visibilityId, saveState, intl, showVisibilitySelect, disabled, }) => { // Eliminate error/failed state for save button const buttonState = saveState === 'error' ? null : saveState; @@ -52,6 +52,7 @@ const FormControls = ({ } }} disabledStates={[]} + disabled={disabled} />
@@ -121,7 +146,15 @@ TextField.propTypes = { visibility: PropTypes.oneOf(['private', 'all_users']), editMode: PropTypes.oneOf(['editing', 'editable', 'empty', 'static']), saveState: PropTypes.string, - error: PropTypes.string, + errorMessage: PropTypes.shape({ + required: PropTypes.string, + max_length: PropTypes.string, + min_length: PropTypes.string, + }), + restrictions: PropTypes.shape({ + max_length: PropTypes.number, + min_length: PropTypes.number, + }), placeholder: PropTypes.string, instructions: PropTypes.string, label: PropTypes.string.isRequired, @@ -138,7 +171,6 @@ TextField.defaultProps = { placeholder: '', instructions: '', visibility: 'private', - error: null, }; export default connect(editableFormSelector, {})(TextField); From 0fb7996b60de317f519eeff18ec7178afa38973e Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 11 Apr 2025 15:58:34 -0500 Subject: [PATCH 13/16] fix: update error handling and restrictions in TextField component tests --- src/profile/forms/elements/TextField.test.jsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/profile/forms/elements/TextField.test.jsx b/src/profile/forms/elements/TextField.test.jsx index cfcfb52e4..faf362dcf 100644 --- a/src/profile/forms/elements/TextField.test.jsx +++ b/src/profile/forms/elements/TextField.test.jsx @@ -14,7 +14,15 @@ describe('TextField', () => { visibility: 'private', editMode: 'editing', saveState: null, - error: null, + errorMessage: { + required: 'This field is required', + max_length: 'This field is too long', + min_length: 'This field is too short', + }, + restrictions: { + max_length: 50, + min_length: 5, + }, label: 'Username', placeholder: 'Enter your username', instructions: 'Provide a unique username', From e394cb2da35ffe68ef9215ad330040601424d9ae Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 9 May 2025 15:05:45 -0500 Subject: [PATCH 14/16] fix: refactor extended profile fields handling and improve error message management --- src/profile/ProfilePage.jsx | 4 ++-- src/profile/data/actions.js | 8 +------ src/profile/data/sagas.js | 6 +++--- src/profile/data/selectors.js | 3 ++- src/profile/data/services.js | 17 ++++----------- src/profile/forms/ExtendedProfileFields.jsx | 7 +++--- src/profile/forms/elements/SelectField.jsx | 7 +++--- src/profile/forms/elements/TextField.jsx | 24 ++++++++++----------- 8 files changed, 30 insertions(+), 46 deletions(-) diff --git a/src/profile/ProfilePage.jsx b/src/profile/ProfilePage.jsx index 328c2d2bc..50df2d870 100644 --- a/src/profile/ProfilePage.jsx +++ b/src/profile/ProfilePage.jsx @@ -67,7 +67,7 @@ class ProfilePage extends React.Component { componentDidMount() { this.props.fetchProfile(this.props.params.username); - this.props.fetchExtraFieldsInfo({ is_register_page: true }); + this.props.fetchExtraFieldsInfo(); sendTrackingLogEvent('edx.profile.viewed', { username: this.props.params.username, }); @@ -396,7 +396,7 @@ ProfilePage.propTypes = { label: PropTypes.string.isRequired, })), // https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/user_api/helpers.py#L179 - error_message: PropTypes.shape({ + errorMessage: PropTypes.shape({ required: PropTypes.string, }), // https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/user_api/helpers.py#L167 diff --git a/src/profile/data/actions.js b/src/profile/data/actions.js index 9e0ea251a..0d9806172 100644 --- a/src/profile/data/actions.js +++ b/src/profile/data/actions.js @@ -148,13 +148,11 @@ export const resetDrafts = () => ({ type: RESET_DRAFTS, }); -// Third party auth context 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 = (urlParams) => ({ +export const getExtendedProfileFields = () => ({ type: EXTENDED_PROFILE_FIELDS.BASE, - payload: { urlParams }, }); export const getExtendedProfileFieldsBegin = () => ({ @@ -169,7 +167,3 @@ export const getExtendedProfileFieldsSuccess = (fields) => ({ export const getExtendedProfileFieldsFailure = () => ({ type: EXTENDED_PROFILE_FIELDS.FAILURE, }); - -export const clearThirdPartyAuthContextErrorMessage = () => ({ - type: EXTENDED_PROFILE_FIELDS_CLEAR_ERROR_MSG, -}); diff --git a/src/profile/data/sagas.js b/src/profile/data/sagas.js index fca1bd5d2..0768bfc9d 100644 --- a/src/profile/data/sagas.js +++ b/src/profile/data/sagas.js @@ -210,12 +210,12 @@ export function* handleDeleteProfilePhoto(action) { } } -export function* fetchThirdPartyAuthContext(action) { +export function* fetchExtendedProfileFields() { try { yield put(getExtendedProfileFieldsBegin()); const { fields, - } = yield call(ProfileApiService.getExtendedProfileFields, action.payload.urlParams); + } = yield call(ProfileApiService.getExtendedProfileFields); yield put(getExtendedProfileFieldsSuccess(fields)); } catch (e) { @@ -229,5 +229,5 @@ export default function* profileSaga() { 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, fetchThirdPartyAuthContext); + yield takeEvery(EXTENDED_PROFILE_FIELDS.BASE, fetchExtendedProfileFields); } diff --git a/src/profile/data/selectors.js b/src/profile/data/selectors.js index ae80a36b2..5ca7e40bf 100644 --- a/src/profile/data/selectors.js +++ b/src/profile/data/selectors.js @@ -6,6 +6,7 @@ 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; @@ -414,7 +415,7 @@ export const profilePageSelector = createSelector( 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 de443c807..8892e582f 100644 --- a/src/profile/data/services.js +++ b/src/profile/data/services.js @@ -157,25 +157,16 @@ export async function getCourseCertificates(username) { } } -export async function getExtendedProfileFields(urlParams) { - const requestConfig = { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - params: urlParams, - isPublic: true, - }; +export async function getExtendedProfileFields() { + const url = `${getConfig().LMS_BASE_URL}/user_api/v1/account/registration/`; const { data } = await getAuthenticatedHttpClient() .get( - `${getConfig().LMS_BASE_URL}/api/mfe_context`, - requestConfig, + url, ) .catch((e) => { throw (e); }); - const extendedProfileFields = data.optionalFields.extended_profile - .map((fieldName) => (data.registrationFields.fields[fieldName])) - .filter(Boolean); - - return { fields: extendedProfileFields }; + return { fields: data.fields }; } diff --git a/src/profile/forms/ExtendedProfileFields.jsx b/src/profile/forms/ExtendedProfileFields.jsx index ef5c2a49d..fad9ccfe5 100644 --- a/src/profile/forms/ExtendedProfileFields.jsx +++ b/src/profile/forms/ExtendedProfileFields.jsx @@ -8,7 +8,6 @@ import SwitchContent from './elements/SwitchContent'; import SelectField from './elements/SelectField'; import TextField from './elements/TextField'; import CheckboxField from './elements/CheckboxField'; -import { moveCheckboxFieldsToEnd } from '../utils'; const ExtendedProfileFields = (props) => { const { @@ -42,7 +41,7 @@ const ExtendedProfileFields = (props) => { return (
- {extendedProfileFields.sort(moveCheckboxFieldsToEnd)?.map((field) => { + {extendedProfileFields?.map((field) => { const value = draftProfile?.extendedProfile?.find( (extendedField) => extendedField.fieldName === field.name, )?.fieldValue ?? field.value; @@ -52,7 +51,7 @@ const ExtendedProfileFields = (props) => { const commonProps = { ...field, - errorMessage: field.error_message, + errorMessage: field.errorMessage, formId: `${formId}/${field.name}`, changeHandler: handleChangeExtendedField, submitHandler: handleSubmitExtendedField, @@ -91,7 +90,7 @@ ExtendedProfileFields.propTypes = { value: PropTypes.string.isRequired, label: PropTypes.string.isRequired, })), - error_message: PropTypes.shape({ + errorMessage: PropTypes.shape({ required: PropTypes.string, }), restrictions: PropTypes.shape({ diff --git a/src/profile/forms/elements/SelectField.jsx b/src/profile/forms/elements/SelectField.jsx index 8c9a89147..84d16c245 100644 --- a/src/profile/forms/elements/SelectField.jsx +++ b/src/profile/forms/elements/SelectField.jsx @@ -66,8 +66,8 @@ const SelectField = ({ onChange={handleChange} > - {options.map(([code, name]) => ( - + {options?.map(({ name, value: optionValue }) => ( + ))} {error !== null && ( @@ -130,10 +130,9 @@ SelectField.propTypes = { saveState: PropTypes.string, error: PropTypes.string, options: PropTypes.arrayOf(PropTypes.shape({ - code: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, name: PropTypes.string.isRequired, })).isRequired, - // fieldMessages: PropTypes.objectOf(PropTypes.string).isRequired, label: PropTypes.string.isRequired, emptyMessage: PropTypes.string.isRequired, changeHandler: PropTypes.func.isRequired, diff --git a/src/profile/forms/elements/TextField.jsx b/src/profile/forms/elements/TextField.jsx index 22f192edd..46d2f0326 100644 --- a/src/profile/forms/elements/TextField.jsx +++ b/src/profile/forms/elements/TextField.jsx @@ -30,20 +30,20 @@ const TextField = ({ useEffect(() => { if (!value && errorMessage?.required) { - setErrorMessage(errorMessage.required); - } else if (restrictions.max_length && value.length > restrictions.max_length) { - setErrorMessage(errorMessage.max_length); - } else if (restrictions.min_length && value.length < restrictions.min_length) { - setErrorMessage(errorMessage.min_length); + setErrorMessage(errorMessage?.required); + } else if (restrictions?.max_length && value?.length > restrictions?.max_length) { + setErrorMessage(errorMessage?.max_length); + } else if (restrictions?.min_length && value?.length < restrictions?.min_length) { + setErrorMessage(errorMessage?.min_length); } else { setErrorMessage(null); } }, [ - errorMessage.max_length, - errorMessage.min_length, - errorMessage.required, - restrictions.max_length, - restrictions.min_length, + errorMessage?.max_length, + errorMessage?.min_length, + errorMessage?.required, + restrictions?.max_length, + restrictions?.min_length, value, ]); @@ -85,8 +85,8 @@ const TextField = ({ name={formId} value={value} onChange={handleChange} - minLength={restrictions.min_length} - maxLength={restrictions.max_length} + minLength={restrictions?.min_length} + maxLength={restrictions?.max_length} /> {displayedMessage !== null && ( From db7bb98b4482c2bd3eff5aeece80c4d985e864af Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 9 May 2025 15:13:41 -0500 Subject: [PATCH 15/16] fix: update saga tests to use fetchExtendedProfileFields instead of fetchThirdPartyAuthContext --- src/profile/data/sagas.test.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/profile/data/sagas.test.js b/src/profile/data/sagas.test.js index 47ef65936..d64b9b52d 100644 --- a/src/profile/data/sagas.test.js +++ b/src/profile/data/sagas.test.js @@ -35,7 +35,7 @@ import profileSaga, { handleSaveProfile, handleSaveProfilePhoto, handleDeleteProfilePhoto, - fetchThirdPartyAuthContext, + fetchExtendedProfileFields, } from './sagas'; import * as ProfileApiService from './services'; /* eslint-enable import/first */ @@ -54,7 +54,7 @@ describe('RootSaga', () => { expect(gen.next().value) .toEqual(takeEvery(profileActions.DELETE_PROFILE_PHOTO.BASE, handleDeleteProfilePhoto)); expect(gen.next().value) - .toEqual(takeEvery(profileActions.EXTENDED_PROFILE_FIELDS.BASE, fetchThirdPartyAuthContext)); + .toEqual(takeEvery(profileActions.EXTENDED_PROFILE_FIELDS.BASE, fetchExtendedProfileFields)); expect(gen.next().value).toBeUndefined(); }); @@ -170,7 +170,7 @@ describe('RootSaga', () => { }); }); - describe('fetchThirdPartyAuthContext', () => { + describe('fetchExtendedProfileFields', () => { test('should fetch third party auth context', async () => { const dispatched = []; const mockedAction = { payload: { urlParams: {} } }; @@ -182,7 +182,7 @@ describe('RootSaga', () => { { dispatch: (action) => dispatched.push(action), }, - fetchThirdPartyAuthContext, + fetchExtendedProfileFields, mockedAction, ).toPromise(); @@ -200,7 +200,7 @@ describe('RootSaga', () => { { dispatch: (action) => dispatched.push(action), }, - fetchThirdPartyAuthContext, + fetchExtendedProfileFields, mockedAction, ).toPromise()).rejects.toThrow('API error'); From a1fbd6483f1b5dd311eb0fbad5376ffb2a371887 Mon Sep 17 00:00:00 2001 From: Brayan Ceron Date: Fri, 9 May 2025 15:21:56 -0500 Subject: [PATCH 16/16] fix: update options format in SelectField tests for consistency --- src/profile/forms/elements/SelectField.test.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/profile/forms/elements/SelectField.test.jsx b/src/profile/forms/elements/SelectField.test.jsx index 9239b5a7d..e2a212cc1 100644 --- a/src/profile/forms/elements/SelectField.test.jsx +++ b/src/profile/forms/elements/SelectField.test.jsx @@ -16,9 +16,9 @@ describe('SelectField', () => { saveState: null, error: null, options: [ - ['us', 'United States'], - ['ca', 'Canada'], - ['uk', 'United Kingdom'], + { value: 'ca', name: 'Canada' }, + { value: 'us', name: 'United States' }, + { value: 'mx', name: 'Mexico' }, ], label: 'Country', emptyMessage: 'No country selected',