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
+
@@ -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/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) => ( +
+ 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 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: ( + <> + +
+ + )} + showEditButton + onClickEdit={handleOpen} + showVisibility={false} + visibility={visibility} + /> +

{value}

+ + ), + empty: ( + <> + + + {instructions} + + + {placeholder} + + + ), + static: value && ( + <> + +
+ + )} + showVisibility={false} + /> +

{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); + +export const TestableCheckboxField = CheckboxField; diff --git a/src/profile/forms/elements/CheckboxField.test.jsx b/src/profile/forms/elements/CheckboxField.test.jsx new file mode 100644 index 000000000..f4f51b9d4 --- /dev/null +++ b/src/profile/forms/elements/CheckboxField.test.jsx @@ -0,0 +1,91 @@ +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 { TestableCheckboxField } from './CheckboxField'; + +const mockStore = configureStore([]); + +describe('CheckboxField', () => { + 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/FormControls.jsx b/src/profile/forms/elements/FormControls.jsx index e1fcaac78..20652d91e 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, disabled, }) => { // Eliminate error/failed state for save button const buttonState = saveState === 'error' ? null : saveState; return (
+ {showVisibilitySelect && (
+ )}
+ ), + editable: ( + <> + +

{value}

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

{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({ + value: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + })).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 connect(editableFormSelector, {})(SelectField); + +export const TestableSelectField = SelectField; diff --git a/src/profile/forms/elements/SelectField.test.jsx b/src/profile/forms/elements/SelectField.test.jsx new file mode 100644 index 000000000..e2a212cc1 --- /dev/null +++ b/src/profile/forms/elements/SelectField.test.jsx @@ -0,0 +1,94 @@ +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 { TestableSelectField } from './SelectField'; + +const mockStore = configureStore([]); + +describe('SelectField', () => { + const defaultProps = { + formId: 'test-select', + value: '', + visibility: 'private', + editMode: 'editing', + saveState: null, + error: null, + options: [ + { value: 'ca', name: 'Canada' }, + { value: 'us', name: 'United States' }, + { value: 'mx', name: 'Mexico' }, + ], + 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 new file mode 100644 index 000000000..46d2f0326 --- /dev/null +++ b/src/profile/forms/elements/TextField.jsx @@ -0,0 +1,178 @@ +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; + +// 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'; +import { editableFormSelector } from '../../data/selectors'; + +const TextField = ({ + formId, + value, + visibility, + editMode, + saveState, + label, + placeholder, + instructions, + restrictions, + errorMessage, + changeHandler, + submitHandler, + closeHandler, + openHandler, +}) => { + const [displayedMessage, setErrorMessage] = useState(''); + + 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); + } else { + setErrorMessage(null); + } + }, [ + errorMessage?.max_length, + errorMessage?.min_length, + errorMessage?.required, + restrictions?.max_length, + restrictions?.min_length, + value, + ]); + + 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 ( + +
+ + + + {displayedMessage !== null && ( + + {displayedMessage} + + )} + + + +
+ ), + editable: ( + <> + +

{value}

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

{value}

+ + ), + }} + /> + ); +}; + +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, + 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, + changeHandler: PropTypes.func.isRequired, + submitHandler: PropTypes.func.isRequired, + closeHandler: PropTypes.func.isRequired, + openHandler: PropTypes.func.isRequired, +}; + +TextField.defaultProps = { + editMode: 'static', + saveState: null, + value: null, + placeholder: '', + instructions: '', + visibility: 'private', +}; + +export default connect(editableFormSelector, {})(TextField); + +export const TestableTextField = TextField; diff --git a/src/profile/forms/elements/TextField.test.jsx b/src/profile/forms/elements/TextField.test.jsx new file mode 100644 index 000000000..faf362dcf --- /dev/null +++ b/src/profile/forms/elements/TextField.test.jsx @@ -0,0 +1,99 @@ +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 { TestableTextField } from './TextField'; + +const mockStore = configureStore([]); + +describe('TextField', () => { + const defaultProps = { + formId: 'test-text', + value: '', + visibility: 'private', + editMode: 'editing', + saveState: 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', + 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'); + }); +}); diff --git a/src/profile/utils.js b/src/profile/utils.js index 29981a600..080c0f1f4 100644 --- a/src/profile/utils.js +++ b/src/profile/utils.js @@ -23,6 +23,10 @@ export function modifyObjectKeys(object, modify) { return result; } +export function capitalizeFirstLetter(val) { + return String(val).charAt(0).toUpperCase() + String(val).slice(1); +} + export function camelCaseObject(object) { return modifyObjectKeys(object, camelCase); } @@ -69,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; +}; 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'); + }); + }); });