diff --git a/src/account-settings/AccountSettingsPage.jsx b/src/account-settings/AccountSettingsPage.jsx index 1ec196a..98e7789 100644 --- a/src/account-settings/AccountSettingsPage.jsx +++ b/src/account-settings/AccountSettingsPage.jsx @@ -1,17 +1,18 @@ import { AppContext } from '@edx/frontend-platform/react'; -import { getConfig, getQueryParameters } from '@edx/frontend-platform'; -import React from 'react'; +import { getConfig } from '@edx/frontend-platform'; +import { + useEffect, useContext, useMemo, createRef, useState, +} from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import memoize from 'memoize-one'; import findIndex from 'lodash.findindex'; import { sendTrackingLogEvent } from '@edx/frontend-platform/analytics'; import { - injectIntl, - intlShape, FormattedMessage, getCountryList, getLanguageList, + useIntl, } from '@edx/frontend-platform/i18n'; import { Container, Hyperlink, Icon, Alert, @@ -54,117 +55,87 @@ import { fetchNotificationPreferences } from '../notification-preferences/data/t import NotificationSettings from '../notification-preferences/NotificationSettings'; import { withLocation, withNavigate } from './hoc'; -class AccountSettingsPage extends React.Component { - constructor(props, context) { - super(props, context); +const AccountSettingsPage = ({ + loading = false, + loaded = false, + loadingError = null, + nameChangeModal = {} || false, + navigate, + countriesCodesList = [], + profileDataManager = null, + committedValues = { + useVerifiedNameForCerts: false, + verified_name: null, + country: '', + }, + ...props +}) => { + const intl = useIntl(); + const appContext = useContext(AppContext); + const [duplicateTpaProvider, setDuplicateTpaProvider] = useState(null); - const duplicateTpaProvider = getQueryParameters().duplicate_provider; - this.state = { - duplicateTpaProvider, - }; - - this.navLinkRefs = { - '#basic-information': React.createRef(), - '#profile-information': React.createRef(), - '#social-media': React.createRef(), - '#notifications': React.createRef(), - '#site-preferences': React.createRef(), - '#linked-accounts': React.createRef(), - '#delete-account': React.createRef(), - }; - } - - componentDidMount() { - this.props.fetchNotificationPreferences(); - this.props.fetchSettings(); - this.props.fetchSiteLanguages(this.props.navigate); + useEffect(() => { + const searchParams = new URLSearchParams(window.location.search); + const initialDuplicateTpaProvider = searchParams.get('duplicate_provider'); + if (initialDuplicateTpaProvider) { + setDuplicateTpaProvider(initialDuplicateTpaProvider); + } + props.fetchNotificationPreferences(); + props.fetchSettings(); + props.fetchSiteLanguages(navigate); sendTrackingLogEvent('edx.user.settings.viewed', { page: 'account', visibility: null, - user_id: this.context.authenticatedUser.userId, + user_id: appContext.authenticatedUser.userId, }); - } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - componentDidUpdate(prevProps) { - if (prevProps.loading && !prevProps.loaded && this.props.loaded) { + const navLinkRefs = useMemo(() => ({ + '#basic-information': createRef(), + '#profile-information': createRef(), + '#social-media': createRef(), + '#notifications': createRef(), + '#site-preferences': createRef(), + '#linked-accounts': createRef(), + '#delete-account': createRef(), + }), []); + + useEffect(() => { + if (loading && !loaded && loaded) { const locationHash = global.location.hash; // Check for the locationHash in the URL and then scroll to it if it is in the // NavLinks list if (typeof locationHash !== 'string') { return; } - if (Object.keys(this.navLinkRefs).includes(locationHash) && this.navLinkRefs[locationHash].current) { - window.scrollTo(0, this.navLinkRefs[locationHash].current.offsetTop); + if (Object.keys(navLinkRefs).includes(locationHash) && navLinkRefs[locationHash].current) { + window.scrollTo(0, navLinkRefs[locationHash].current.offsetTop); } } - } + }, [loading, loaded, navLinkRefs]); // NOTE: We need 'locale' for the memoization in getLocalizedTimeZoneOptions. Don't remove it! // eslint-disable-next-line no-unused-vars - getLocalizedTimeZoneOptions = memoize((timeZoneOptions, countryTimeZoneOptions, locale) => { + const getLocalizedTimeZoneOptions = memoize((timeZoneOptions, countryTimeZoneOptions, locale) => { const concatTimeZoneOptions = [{ - label: this.props.intl.formatMessage(messages['account.settings.field.time.zone.default']), + label: intl.formatMessage(messages['account.settings.field.time.zone.default']), value: '', }]; if (countryTimeZoneOptions.length) { concatTimeZoneOptions.push({ - label: this.props.intl.formatMessage(messages['account.settings.field.time.zone.country']), + label: intl.formatMessage(messages['account.settings.field.time.zone.country']), group: countryTimeZoneOptions, }); } concatTimeZoneOptions.push({ - label: this.props.intl.formatMessage(messages['account.settings.field.time.zone.all']), + label: intl.formatMessage(messages['account.settings.field.time.zone.all']), group: timeZoneOptions, }); return concatTimeZoneOptions; }); - getLocalizedOptions = memoize((locale, country) => ({ - countryOptions: [{ - value: '', - label: this.props.intl.formatMessage(messages['account.settings.field.country.options.empty']), - }].concat( - this.removeDisabledCountries( - getCountryList(locale).map(({ code, name }) => ({ - value: code, - label: name, - disabled: this.isDisabledCountry(code), - })), - ), - ), - stateOptions: [{ - value: '', - label: this.props.intl.formatMessage(messages['account.settings.field.state.options.empty']), - }].concat(getStatesList(country)), - languageProficiencyOptions: [{ - value: '', - label: this.props.intl.formatMessage(messages['account.settings.field.language_proficiencies.options.empty']), - }].concat(getLanguageList(locale).map(({ code, name }) => ({ value: code, label: name }))), - yearOfBirthOptions: [{ - value: '', - label: this.props.intl.formatMessage(messages['account.settings.field.year_of_birth.options.empty']), - }].concat(YEAR_OF_BIRTH_OPTIONS), - educationLevelOptions: EDUCATION_LEVELS.map(key => ({ - value: key, - label: this.props.intl.formatMessage(messages[`account.settings.field.education.levels.${key || 'empty'}`]), - })), - genderOptions: GENDER_OPTIONS.map(key => ({ - value: key, - label: this.props.intl.formatMessage(messages[`account.settings.field.gender.options.${key || 'empty'}`]), - })), - workExperienceOptions: WORK_EXPERIENCE_OPTIONS.map(key => ({ - value: key, - label: key === '' ? this.props.intl.formatMessage(messages['account.settings.field.work.experience.options.empty']) : key, - })), - })); - - canDeleteAccount = () => { - const { committedValues } = this.props; - return !getConfig().COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED.includes(committedValues.country); - }; - - removeDisabledCountries = (countryList) => { - const { countriesCodesList, committedValues } = this.props; + const removeDisabledCountries = (countryList) => { const committedCountry = committedValues?.country; if (!countriesCodesList.length) { @@ -173,16 +144,59 @@ class AccountSettingsPage extends React.Component { return countryList.filter(({ value }) => value === committedCountry || countriesCodesList.find(x => x === value)); }; - handleEditableFieldChange = (name, value) => { - this.props.updateDraft(name, value); + const isDisabledCountry = (country) => countriesCodesList.length > 0 && !countriesCodesList.find(x => x === country); + + const getLocalizedOptions = memoize((locale, country) => ({ + countryOptions: [{ + value: '', + label: intl.formatMessage(messages['account.settings.field.country.options.empty']), + }].concat( + removeDisabledCountries( + getCountryList(locale).map(({ code, name }) => ({ + value: code, + label: name, + disabled: isDisabledCountry(code), + })), + ), + ), + stateOptions: [{ + value: '', + label: intl.formatMessage(messages['account.settings.field.state.options.empty']), + }].concat(getStatesList(country)), + languageProficiencyOptions: [{ + value: '', + label: intl.formatMessage(messages['account.settings.field.language_proficiencies.options.empty']), + }].concat(getLanguageList(locale).map(({ code, name }) => ({ value: code, label: name }))), + yearOfBirthOptions: [{ + value: '', + label: intl.formatMessage(messages['account.settings.field.year_of_birth.options.empty']), + }].concat(YEAR_OF_BIRTH_OPTIONS), + educationLevelOptions: EDUCATION_LEVELS.map(key => ({ + value: key, + label: intl.formatMessage(messages[`account.settings.field.education.levels.${key || 'empty'}`]), + })), + genderOptions: GENDER_OPTIONS.map(key => ({ + value: key, + label: intl.formatMessage(messages[`account.settings.field.gender.options.${key || 'empty'}`]), + })), + workExperienceOptions: WORK_EXPERIENCE_OPTIONS.map(key => ({ + value: key, + label: key === '' ? intl.formatMessage(messages['account.settings.field.work.experience.options.empty']) : key, + })), + })); + + const canDeleteAccount = () => !getConfig().COUNTRIES_WITH_DELETE_ACCOUNT_DISABLED.includes(committedValues.country); + + const handleEditableFieldChange = (name, value) => { + updateDraft(name, value); }; - handleSubmit = (formId, values) => { - if (formId === FIELD_LABELS.COUNTRY && this.isDisabledCountry(values)) { + const handleSubmit = (formId, values) => { + if (formId === FIELD_LABELS.COUNTRY && isDisabledCountry(values)) { return; } - const { formValues } = this.props; + const { formValues } = props; let extendedProfileObject = {}; if ('extended_profile' in formValues && formValues.extended_profile.some((field) => field.field_name === formId)) { @@ -192,55 +206,45 @@ class AccountSettingsPage extends React.Component { : field)), }; } - this.props.saveSettings(formId, values, extendedProfileObject); + saveSettings(formId, values, extendedProfileObject); }; - handleSubmitProfileName = (formId, values) => { - if (Object.keys(this.props.drafts).includes('useVerifiedNameForCerts')) { - this.props.saveMultipleSettings([ + const handleSubmitProfileName = (formId, values) => { + if (Object.keys(props.drafts).includes('useVerifiedNameForCerts')) { + saveMultipleSettings([ { formId, commitValues: values, }, { formId: 'useVerifiedNameForCerts', - commitValues: this.props.formValues.useVerifiedNameForCerts, + commitValues: props.formValues.useVerifiedNameForCerts, }, ], formId); } else { - this.props.saveSettings(formId, values); + saveSettings(formId, values); } }; - handleSubmitVerifiedName = (formId, values) => { - if (Object.keys(this.props.drafts).includes('useVerifiedNameForCerts')) { - this.props.saveSettings('useVerifiedNameForCerts', this.props.formValues.useVerifiedNameForCerts); + const handleSubmitVerifiedName = (formId, values) => { + if (Object.keys(props.drafts).includes('useVerifiedNameForCerts')) { + saveSettings('useVerifiedNameForCerts', props.formValues.useVerifiedNameForCerts); } - if (values !== this.props.committedValues?.verified_name) { - this.props.beginNameChange(formId); + if (values !== props.committedValues?.verified_name) { + beginNameChange(formId); } else { - this.props.saveSettings(formId, values); + saveSettings(formId, values); } }; - isDisabledCountry = (country) => { - const { countriesCodesList } = this.props; + const isEditable = (fieldName) => !props.staticFields.includes(fieldName); - return countriesCodesList.length > 0 && !countriesCodesList.find(x => x === country); - }; + // Enterprise customer profiles are managed by their organizations. We determine whether + // a profile is managed or not by the presence of the profileDataManager prop. + const isManagedProfile = () => Boolean(profileDataManager); - isEditable(fieldName) { - return !this.props.staticFields.includes(fieldName); - } - - isManagedProfile() { - // Enterprise customer profiles are managed by their organizations. We determine whether - // a profile is managed or not by the presence of the profileDataManager prop. - return Boolean(this.props.profileDataManager); - } - - renderDuplicateTpaProviderMessage() { - if (!this.state.duplicateTpaProvider) { + const renderDuplicateTpaProviderMessage = () => { + if (!duplicateTpaProvider) { return null; } @@ -248,7 +252,7 @@ class AccountSettingsPage extends React.Component { // way of telling us that the provider account the user tried to link is already linked // to another user account on the platform. We use this to display a message to that effect, // and remove the parameter from the URL. - this.props.navigate(this.props.location, { replace: true }); + navigate(props.location, { replace: true }); return (
@@ -258,17 +262,17 @@ class AccountSettingsPage extends React.Component { defaultMessage="The {provider} account you selected is already linked to another {siteName} account." description="alert message informing the user that the third-party account they attempted to link is already linked to another account" values={{ - provider: {this.state.duplicateTpaProvider}, + provider: {duplicateTpaProvider}, siteName: getConfig().SITE_NAME, }} />
); - } + }; - renderManagedProfileMessage() { - if (!this.isManagedProfile()) { + const renderManagedProfileMessage = () => { + if (!isManagedProfile()) { return null; } @@ -280,7 +284,7 @@ class AccountSettingsPage extends React.Component { defaultMessage="Your profile settings are managed by {managerTitle}. Contact your administrator or {support} for help." description="alert message informing the user their account data is managed by a third party" values={{ - managerTitle: {this.props.profileDataManager}, + managerTitle: {profileDataManager}, support: ( ); - } + }; - renderFullNameHelpText = (status, proctoredExamId) => { - if (!this.props.verifiedNameHistory) { - return this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text']); + const renderFullNameHelpText = (status, proctoredExamId) => { + if (!props.verifiedNameHistory) { + return intl.formatMessage(messages['account.settings.field.full.name.help.text']); } let messageString = 'account.settings.field.full.name.help.text'; @@ -313,14 +317,14 @@ class AccountSettingsPage extends React.Component { messageString += '.default'; } - if (!this.props.committedValues.useVerifiedNameForCerts) { + if (!props.committedValues.useVerifiedNameForCerts) { messageString += '.certificate'; } - return this.props.intl.formatMessage(messages[messageString]); + return intl.formatMessage(messages[messageString]); }; - renderVerifiedNameSuccessMessage = (verifiedName, created) => { + const renderVerifiedNameSuccessMessage = (verifiedName, created) => { const dateValue = new Date(created).valueOf(); const id = `dismissedVerifiedNameSuccessMessage-${verifiedName}-${dateValue}`; @@ -329,13 +333,13 @@ class AccountSettingsPage extends React.Component { id={id} variant="success" icon={CheckCircle} - header={this.props.intl.formatMessage(messages['account.settings.field.name.verified.success.message.header'])} - body={this.props.intl.formatMessage(messages['account.settings.field.name.verified.success.message'])} + header={intl.formatMessage(messages['account.settings.field.name.verified.success.message.header'])} + body={intl.formatMessage(messages['account.settings.field.name.verified.success.message'])} /> ); }; - renderVerifiedNameFailureMessage = (verifiedName, created) => { + const renderVerifiedNameFailureMessage = (verifiedName, created) => { const dateValue = new Date(created).valueOf(); const id = `dismissedVerifiedNameFailureMessage-${verifiedName}-${dateValue}`; @@ -344,11 +348,11 @@ class AccountSettingsPage extends React.Component { id={id} variant="danger" icon={Error} - header={this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message.header'])} + header={intl.formatMessage(messages['account.settings.field.name.verified.failure.message.header'])} body={ (
- {this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message'])} + {intl.formatMessage(messages['account.settings.field.name.verified.failure.message'])}
) } @@ -356,25 +360,25 @@ class AccountSettingsPage extends React.Component { ); }; - renderVerifiedNameSubmittedMessage = (willCertNameChange) => ( + const renderVerifiedNameSubmittedMessage = (willCertNameChange) => ( - {this.props.intl.formatMessage(messages['account.settings.field.name.verified.submitted.message.header'])} + {intl.formatMessage(messages['account.settings.field.name.verified.submitted.message.header'])}

- {this.props.intl.formatMessage(messages['account.settings.field.name.verified.submitted.message'])}{' '} + {intl.formatMessage(messages['account.settings.field.name.verified.submitted.message'])}{' '} { willCertNameChange - && this.props.intl.formatMessage(messages['account.settings.field.name.verified.submitted.message.certificate']) + && intl.formatMessage(messages['account.settings.field.name.verified.submitted.message.certificate']) }

); - renderVerifiedNameMessage = verifiedNameRecord => { + const renderVerifiedNameMessage = verifiedNameRecord => { const { created, status, @@ -387,13 +391,13 @@ class AccountSettingsPage extends React.Component { if ( ( // User submitted a profile name change, and uses their profile name on certificates - this.props.committedValues.name !== profileName - && !this.props.committedValues.useVerifiedNameForCerts + props.committedValues.name !== profileName + && !props.committedValues.useVerifiedNameForCerts ) || ( // User submitted a verified name change, and uses their verified name on certificates - this.props.committedValues.name === profileName - && this.props.committedValues.useVerifiedNameForCerts + props.committedValues.name === profileName + && props.committedValues.useVerifiedNameForCerts ) ) { willCertNameChange = true; @@ -405,17 +409,17 @@ class AccountSettingsPage extends React.Component { switch (status) { case 'approved': - return this.renderVerifiedNameSuccessMessage(verifiedName, created); + return renderVerifiedNameSuccessMessage(verifiedName, created); case 'denied': - return this.renderVerifiedNameFailureMessage(verifiedName, created); + return renderVerifiedNameFailureMessage(verifiedName, created); case 'submitted': - return this.renderVerifiedNameSubmittedMessage(willCertNameChange); + return renderVerifiedNameSubmittedMessage(willCertNameChange); default: return null; } }; - renderVerifiedNameIcon = (status) => { + const renderVerifiedNameIcon = (status) => { switch (status) { case 'approved': return (); @@ -426,7 +430,7 @@ class AccountSettingsPage extends React.Component { } }; - renderVerifiedNameHelpText = (status, proctoredExamId) => { + const renderVerifiedNameHelpText = (status, proctoredExamId) => { let messageStr = 'account.settings.field.name.verified.help.text'; // add additional string based on status @@ -444,50 +448,50 @@ class AccountSettingsPage extends React.Component { } // add additional string based on certificate name use - if (this.props.committedValues.useVerifiedNameForCerts) { + if (props.committedValues.useVerifiedNameForCerts) { messageStr += '.certificate'; } - return this.props.intl.formatMessage(messages[messageStr]); + return intl.formatMessage(messages[messageStr]); }; - renderEmptyStaticFieldMessage() { - if (this.isManagedProfile()) { - return this.props.intl.formatMessage(messages['account.settings.static.field.empty'], { - enterprise: this.props.profileDataManager, + const renderEmptyStaticFieldMessage = () => { + if (isManagedProfile()) { + return intl.formatMessage(messages['account.settings.static.field.empty'], { + enterprise: profileDataManager, }); } - return this.props.intl.formatMessage(messages['account.settings.static.field.empty.no.admin']); - } + return intl.formatMessage(messages['account.settings.static.field.empty.no.admin']); + }; - renderNameChangeModal() { - if (this.props.nameChangeModal && this.props.nameChangeModal.formId) { - return ; + const renderNameChangeModal = () => { + if (nameChangeModal && nameChangeModal.formId) { + return ; } return null; - } + }; - renderSecondaryEmailField(editableFieldProps) { - if (!this.props.formValues.secondary_email_enabled) { + const renderSecondaryEmailField = (editableFieldProps) => { + if (!props.formValues.secondary_email_enabled) { return null; } return ( ); - } + }; - renderContent() { + const renderContent = () => { const editableFieldProps = { - onChange: this.handleEditableFieldChange, - onSubmit: this.handleSubmit, + onChange: handleEditableFieldChange, + onSubmit: handleSubmit, }; // Memoized options lists @@ -499,28 +503,28 @@ class AccountSettingsPage extends React.Component { educationLevelOptions, genderOptions, workExperienceOptions, - } = this.getLocalizedOptions(this.context.locale, this.props.formValues.country); + } = getLocalizedOptions(appContext.locale, props.formValues.country); // Show State field only if the country is US (could include Canada later) - const { country } = this.props.formValues; - const showState = country === COUNTRY_WITH_STATES && !this.isDisabledCountry(country); - const { verifiedName } = this.props; + const { country } = props.formValues; + const showState = country === COUNTRY_WITH_STATES && !isDisabledCountry(country); + const { verifiedName } = props; - const hasWorkExperience = !!this.props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience'); + const hasWorkExperience = !!props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience'); - const timeZoneOptions = this.getLocalizedTimeZoneOptions( - this.props.timeZoneOptions, - this.props.countryTimeZoneOptions, - this.context.locale, + const timeZoneOptions = getLocalizedTimeZoneOptions( + props.timeZoneOptions, + props.countryTimeZoneOptions, + appContext.locale, ); - const hasLinkedTPA = findIndex(this.props.tpaProviders, provider => provider.connected) >= 0; + const hasLinkedTPA = findIndex(props.tpaProviders, provider => provider.connected) >= 0; // if user is under 13 and does not have cookie set const shouldUpdateDOB = ( getConfig().ENABLE_COPPA_COMPLIANCE && getConfig().ENABLE_DOB_UPDATE - && this.props.formValues.year_of_birth.toString() >= COPPA_COMPLIANCE_YEAR.toString() + && props.formValues.year_of_birth.toString() >= COPPA_COMPLIANCE_YEAR.toString() && !localStorage.getItem('submittedDOB') ); return ( @@ -531,10 +535,10 @@ class AccountSettingsPage extends React.Component { {...editableFieldProps} /> )} -
+
{ - this.props.mostRecentVerifiedName - && this.renderVerifiedNameMessage(this.props.mostRecentVerifiedName) + props.mostRecentVerifiedName + && renderVerifiedNameMessage(props.mostRecentVerifiedName) } {localStorage.getItem('submittedDOB') && ( @@ -542,25 +546,25 @@ class AccountSettingsPage extends React.Component { id="updated-dob" variant="success" icon={CheckCircle} - header={this.props.intl.formatMessage(messages['account.settings.field.dob.form.success'])} + header={intl.formatMessage(messages['account.settings.field.dob.form.success'])} body="" /> )}

- {this.props.intl.formatMessage(messages['account.settings.section.account.information'])} + {intl.formatMessage(messages['account.settings.section.account.information'])}

-

{this.props.intl.formatMessage(messages['account.settings.section.account.information.description'])}

- {this.renderManagedProfileMessage()} +

{intl.formatMessage(messages['account.settings.section.account.information.description'])}

+ {renderManagedProfileMessage()} - {this.renderNameChangeModal()} + {renderNameChangeModal()} {verifiedName && ( - {this.props.intl.formatMessage(messages['account.settings.field.name.verified'])} + {intl.formatMessage(messages['account.settings.field.name.verified'])} { - this.renderVerifiedNameIcon(verifiedName.status) + renderVerifiedNameIcon(verifiedName.status) }
) } - helpText={this.renderVerifiedNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)} - isEditable={this.isEditable('verifiedName')} - isGrayedOut={!this.isEditable('verifiedName')} - onChange={this.handleEditableFieldChange} - onSubmit={this.handleSubmitVerifiedName} + helpText={renderVerifiedNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)} + isEditable={isEditable('verifiedName')} + isGrayedOut={!isEditable('verifiedName')} + onChange={handleEditableFieldChange} + onSubmit={handleSubmitVerifiedName} /> )} - {this.renderSecondaryEmailField(editableFieldProps)} - + {renderSecondaryEmailField(editableFieldProps)} + {(!getConfig().ENABLE_COPPA_COMPLIANCE) && ( @@ -656,15 +660,15 @@ class AccountSettingsPage extends React.Component { {showState @@ -672,43 +676,43 @@ class AccountSettingsPage extends React.Component { )}
-
+

- {this.props.intl.formatMessage(messages['account.settings.section.profile.information'])} + {intl.formatMessage(messages['account.settings.section.profile.information'])}

option.value !== 'el') : educationLevelOptions} - label={this.props.intl.formatMessage(messages['account.settings.field.education'])} - emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.education.empty'])} + label={intl.formatMessage(messages['account.settings.field.education'])} + emptyLabel={intl.formatMessage(messages['account.settings.field.education.empty'])} {...editableFieldProps} /> {hasWorkExperience @@ -716,29 +720,29 @@ class AccountSettingsPage extends React.Component { field.field_name === 'work_experience')?.field_value} + value={props.formValues?.extended_profile?.find(field => field.field_name === 'work_experience')?.field_value} options={workExperienceOptions} - label={this.props.intl.formatMessage(messages['account.settings.field.work.experience'])} - emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.work.experience.empty'])} + label={intl.formatMessage(messages['account.settings.field.work.experience'])} + emptyLabel={intl.formatMessage(messages['account.settings.field.work.experience.empty'])} {...editableFieldProps} /> )}

- {this.props.intl.formatMessage(messages['account.settings.section.social.media'])} + {intl.formatMessage(messages['account.settings.section.social.media'])}

- {this.props.intl.formatMessage( + {intl.formatMessage( messages['account.settings.section.social.media.description'], { siteName: getConfig().SITE_NAME }, )} @@ -747,67 +751,67 @@ class AccountSettingsPage extends React.Component {

-
+
-
+

- {this.props.intl.formatMessage(messages['account.settings.section.site.preferences'])} + {intl.formatMessage(messages['account.settings.section.site.preferences'])}

{ // the endpoint will not accept an empty string. it must be null - this.handleSubmit(formId, value || null); + handleSubmit(formId, value || null); }} />
-
-

{this.props.intl.formatMessage(messages['account.settings.section.linked.accounts'])}

+
+

{intl.formatMessage(messages['account.settings.section.linked.accounts'])}

- {this.props.intl.formatMessage( + {intl.formatMessage( messages['account.settings.section.linked.accounts.description'], { siteName: getConfig().SITE_NAME }, )} @@ -816,68 +820,53 @@ class AccountSettingsPage extends React.Component {

{getConfig().ENABLE_ACCOUNT_DELETION && ( -
+
)} ); - } + }; - renderError() { - return ( + const renderError = () => ( +
+ {intl.formatMessage(messages['account.settings.loading.error'], { + error: loadingError, + })} +
+ ); + + const renderLoading = () => ( + + ); + + return ( + + {renderDuplicateTpaProviderMessage()} +

+ {intl.formatMessage(messages['account.settings.page.heading'])} +

- {this.props.intl.formatMessage(messages['account.settings.loading.error'], { - error: this.props.loadingError, - })} -
- ); - } - - renderLoading() { - return ( - - ); - } - - render() { - const { - loading, - loaded, - loadingError, - } = this.props; - - return ( - - {this.renderDuplicateTpaProviderMessage()} -

- {this.props.intl.formatMessage(messages['account.settings.page.heading'])} -

-
-
-
- -
-
- {loading ? this.renderLoading() : null} - {loaded ? this.renderContent() : null} - {loadingError ? this.renderError() : null} -
+
+
+ +
+
+ {loading ? renderLoading() : null} + {loaded ? renderContent() : null} + {loadingError ? renderError() : null}
- - ); - } -} - -AccountSettingsPage.contextType = AppContext; +
+ + ); +}; AccountSettingsPage.propTypes = { - intl: intlShape.isRequired, loading: PropTypes.bool, loaded: PropTypes.bool, loadingError: PropTypes.string, @@ -1017,4 +1006,4 @@ export default withLocation(withNavigate(connect(accountSettingsPageSelector, { updateDraft, fetchSiteLanguages, beginNameChange, -})(injectIntl(AccountSettingsPage)))); +})(AccountSettingsPage))); diff --git a/src/account-settings/test/AccountSettingsPage.test.jsx b/src/account-settings/test/AccountSettingsPage.test.jsx index 385a27d..79d241e 100644 --- a/src/account-settings/test/AccountSettingsPage.test.jsx +++ b/src/account-settings/test/AccountSettingsPage.test.jsx @@ -7,7 +7,7 @@ import { render, screen, fireEvent, } from '@testing-library/react'; import configureStore from 'redux-mock-store'; -import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; import AccountSettingsPage from '../AccountSettingsPage'; import mockData from './mockData'; @@ -25,8 +25,6 @@ jest.mock('react-redux', () => ({ jest.mock('@edx/frontend-platform/auth'); -const IntlAccountSettingsPage = injectIntl(AccountSettingsPage); - const middlewares = [thunk]; const mockStore = configureStore(middlewares); @@ -82,7 +80,7 @@ describe('AccountSettingsPage', () => { }); it('renders AccountSettingsPage correctly with editing enabled', async () => { - const { getByText, rerender, getByLabelText } = render(reduxWrapper()); + const { getByText, rerender, getByLabelText } = render(reduxWrapper()); const workExperienceText = getByText('Work Experience'); const workExperienceEditButton = workExperienceText.parentElement.querySelector('button'); @@ -96,7 +94,7 @@ describe('AccountSettingsPage', () => { openFormId: 'work_experience', }, }); - rerender(reduxWrapper()); + rerender(reduxWrapper()); const submitButton = screen.getByText('Save'); expect(submitButton).toBeInTheDocument(); diff --git a/src/id-verification/Camera.jsx b/src/id-verification/Camera.jsx index 66da101..01c8a6d 100644 --- a/src/id-verification/Camera.jsx +++ b/src/id-verification/Camera.jsx @@ -1,66 +1,53 @@ /* eslint-disable jsx-a11y/media-has-caption */ /* eslint-disable jsx-a11y/no-access-key */ -import React from 'react'; +import { + useState, + useRef, + useEffect, + useCallback, +} from 'react'; import PropTypes from 'prop-types'; import { sendTrackEvent } from '@edx/frontend-platform/analytics'; // eslint-disable-next-line import/no-unresolved import * as blazeface from '@tensorflow-models/blazeface'; import CameraPhoto, { FACING_MODES } from 'jslib-html5-camera-photo'; -import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { Form, Spinner } from '@openedx/paragon'; import shutter from './data/camera-shutter.base64.json'; import messages from './IdVerification.messages'; -class Camera extends React.Component { - constructor(props, context) { - super(props, context); - this.cameraPhoto = null; - this.videoRef = React.createRef(); - this.canvasRef = React.createRef(); - this.setDetection = this.setDetection.bind(this); - this.state = { - dataUri: '', - videoHasLoaded: false, - shouldDetect: false, - isFinishedLoadingDetection: true, - shouldGiveFeedback: true, - feedback: '', - }; - } +const Camera = ({ onImageCapture, isPortrait }) => { + const intl = useIntl(); + const videoRef = useRef(null); + const canvasRef = useRef(null); + const [cameraPhoto, setCameraPhoto] = useState(null); + const [dataUri, setDataUri] = useState(''); + const [videoHasLoaded, setVideoHasLoaded] = useState(false); + const [shouldDetect, setShouldDetect] = useState(false); + const [isFinishedLoadingDetection, setIsFinishedLoadingDetection] = useState(true); + const [shouldGiveFeedback, setShouldGiveFeedback] = useState(true); + const [feedback, setFeedback] = useState(''); - componentDidMount() { - this.cameraPhoto = new CameraPhoto(this.videoRef.current); - this.cameraPhoto.startCamera( - this.props.isPortrait ? FACING_MODES.USER : FACING_MODES.ENVIRONMENT, + useEffect(() => { + const camera = new CameraPhoto(videoRef.current); + setCameraPhoto(camera); + camera.startCamera( + isPortrait ? FACING_MODES.USER : FACING_MODES.ENVIRONMENT, { width: 640, height: 480 }, ); - } - async componentWillUnmount() { - this.cameraPhoto.stopCamera(); - } + return () => { + camera.stopCamera(); + }; + }, [isPortrait]); - setDetection() { - this.setState( - (state) => ({ shouldDetect: !state.shouldDetect }), - () => { - if (this.state.shouldDetect) { - this.setState({ isFinishedLoadingDetection: false }); - this.startDetection(); - } - this.sendEvent(); - }, - ); - } + const handleVideoLoad = () => { + setVideoHasLoaded(true); + }; - setVideoHasLoaded() { - this.setState({ videoHasLoaded: 'true' }); - } - - getGridPosition(coordinates) { + const getGridPosition = useCallback((coordinates) => { // Used to determine where a face is (i.e. top-left, center-right, bottom-center, etc.) - const x = coordinates[0]; const y = coordinates[1]; @@ -89,11 +76,11 @@ class Camera extends React.Component { } return messageBase; - } + }, []); - getSizeFactor() { + const getSizeFactor = useCallback(() => { let sizeFactor = 1; - const settings = this.cameraPhoto.getCameraSettings(); + const settings = cameraPhoto?.getCameraSettings(); if (settings) { const videoWidth = settings.width; const videoHeight = settings.height; @@ -113,24 +100,46 @@ class Camera extends React.Component { } } return sizeFactor; - } + }, [cameraPhoto]); - detectFromVideoFrame = (model, video) => { - model.estimateFaces(video).then((predictions) => { - if (this.state.shouldDetect && !this.state.dataUri) { - this.showDetections(predictions); + const isInRangeForPortrait = useCallback((x, y) => x > 47 && x < 570 && y > 100 && y < 410, []); - requestAnimationFrame(() => { - this.detectFromVideoFrame(model, video); - }); + const isInRangeForID = useCallback((x, y) => x > 120 && x < 470 && y > 120 && y < 350, []); + + const giveFeedback = useCallback((numFaces, rightEye, isCorrect) => { + if (shouldGiveFeedback) { + const currentFeedback = feedback; + let newFeedback = ''; + if (numFaces === 1) { + // only give feedback if one face is detected otherwise + // it would be difficult to tell a user which face to move + if (isCorrect) { + newFeedback = intl.formatMessage(messages['id.verification.photo.feedback.correct']); + } else { + // give feedback based on where user is + newFeedback = intl.formatMessage(messages[getGridPosition(rightEye)]); + } + } else if (numFaces > 1) { + newFeedback = intl.formatMessage(messages['id.verification.photo.feedback.two.faces']); + } else { + newFeedback = intl.formatMessage(messages['id.verification.photo.feedback.no.faces']); } - }); - }; + if (currentFeedback !== newFeedback) { + // only update status if it is different, so we don't overload the user with status updates + setFeedback(newFeedback); + } + // turn off feedback for one to ensure that instructions aren't disruptive/interrupting + setShouldGiveFeedback(false); + setTimeout(() => { + setShouldGiveFeedback(true); + }, 1000); + } + }, [shouldGiveFeedback, feedback, intl, getGridPosition]); - showDetections = (predictions) => { + const showDetections = useCallback((predictions) => { let canvasContext; if (predictions.length > 0) { - canvasContext = this.canvasRef.current.getContext('2d'); + canvasContext = canvasRef.current.getContext('2d'); canvasContext.clearRect(0, 0, canvasContext.canvas.width, canvasContext.canvas.height); } // predictions is an array of objects describing each detected face @@ -149,10 +158,10 @@ class Camera extends React.Component { const y = features[j][1]; let isInRange; - if (this.props.isPortrait) { - isInRange = this.isInRangeForPortrait(x, y); + if (isPortrait) { + isInRange = isInRangeForPortrait(x, y); } else { - isInRange = this.isInRangeForID(x, y); + isInRange = isInRangeForID(x, y); } // if it is not in range, give feedback depending on which feature is out of range isInPosition = isInPosition && isInRange; @@ -164,202 +173,188 @@ class Camera extends React.Component { canvasContext.lineWidth = 6; canvasContext.strokeRect(start[0], start[1], size[0], size[1]); // give positive feedback here if user is in correct position - this.giveFeedback(predictions.length, [], true); + giveFeedback(predictions.length, [], true); } else { canvasContext.fillStyle = 'rgba(255, 51, 0, 0.75)'; canvasContext.fillRect(start[0], start[1], size[0], size[1]); - this.giveFeedback(predictions.length, features[0], false); + giveFeedback(predictions.length, features[0], false); } }); if (predictions.length === 0) { - this.giveFeedback(predictions.length, [], false); + giveFeedback(predictions.length, [], false); } - }; + }, [isPortrait, giveFeedback, isInRangeForPortrait, isInRangeForID]); - startDetection() { + const detectFromVideoFrame = useCallback((model, video) => { + model.estimateFaces(video).then((predictions) => { + if (shouldDetect && !dataUri) { + showDetections(predictions); + + requestAnimationFrame(() => { + detectFromVideoFrame(model, video); + }); + } + }); + }, [shouldDetect, dataUri, showDetections]); + + const startDetection = useCallback(() => { setTimeout(() => { - if (this.state.videoHasLoaded) { + if (videoHasLoaded) { const loadModelPromise = blazeface.load(); Promise.all([loadModelPromise]) .then((values) => { - this.setState({ isFinishedLoadingDetection: true }); - this.detectFromVideoFrame(values[0], this.videoRef.current); + setIsFinishedLoadingDetection(true); + detectFromVideoFrame(values[0], videoRef.current); }); } else { - this.setState({ isFinishedLoadingDetection: true }); - this.setState({ shouldDetect: false }); + setIsFinishedLoadingDetection(true); + setShouldDetect(false); // TODO: add error message } }, 1000); - } + }, [videoHasLoaded, detectFromVideoFrame]); - sendEvent() { + const sendEvent = useCallback((currentShouldDetect) => { let eventName = 'edx.id_verification'; - if (this.props.isPortrait) { + if (isPortrait) { eventName += '.user_photo'; } else { eventName += '.id_photo'; } - if (this.state.shouldDetect) { + if (currentShouldDetect) { eventName += '.face_detection_enabled'; } else { eventName += '.face_detection_disabled'; } sendTrackEvent(eventName); - } + }, [isPortrait]); - giveFeedback(numFaces, rightEye, isCorrect) { - if (this.state.shouldGiveFeedback) { - const currentFeedback = this.state.feedback; - let newFeedback = ''; - if (numFaces === 1) { - // only give feedback if one face is detected otherwise - // it would be difficult to tell a user which face to move - if (isCorrect) { - newFeedback = this.props.intl.formatMessage(messages['id.verification.photo.feedback.correct']); - } else { - // give feedback based on where user is - newFeedback = this.props.intl.formatMessage(messages[this.getGridPosition(rightEye)]); - } - } else if (numFaces > 1) { - newFeedback = this.props.intl.formatMessage(messages['id.verification.photo.feedback.two.faces']); - } else { - newFeedback = this.props.intl.formatMessage(messages['id.verification.photo.feedback.no.faces']); - } - if (currentFeedback !== newFeedback) { - // only update status if it is different, so we don't overload the user with status updates - this.setState({ feedback: newFeedback }); - } - // turn off feedback for one to ensure that instructions aren't disruptive/interrupting - this.setState({ shouldGiveFeedback: false }); - setTimeout(() => { - this.setState({ shouldGiveFeedback: true }); - }, 1000); + const playShutterClick = useCallback(() => { + const audio = new Audio(`data:audio/mp3;base64,${shutter.base64}`); + audio.play(); + }, []); + + const reset = useCallback(() => { + setDataUri(''); + if (shouldDetect) { + startDetection(); } - } + }, [shouldDetect, startDetection]); - isInRangeForPortrait(x, y) { - return x > 47 && x < 570 && y > 100 && y < 410; - } - - isInRangeForID(x, y) { - return x > 120 && x < 470 && y > 120 && y < 350; - } - - takePhoto() { - if (this.state.dataUri) { - this.reset(); + const takePhoto = useCallback(() => { + if (dataUri) { + reset(); return; } const config = { - sizeFactor: this.getSizeFactor(), + sizeFactor: getSizeFactor(), }; - this.playShutterClick(); - const dataUri = this.cameraPhoto.getDataUri(config); - this.setState({ dataUri }); - this.props.onImageCapture(dataUri); - } + playShutterClick(); + const newDataUri = cameraPhoto.getDataUri(config); + setDataUri(newDataUri); + onImageCapture(newDataUri); + }, [dataUri, cameraPhoto, getSizeFactor, onImageCapture, playShutterClick, reset]); - playShutterClick() { - const audio = new Audio(`data:audio/mp3;base64,${shutter.base64}`); - audio.play(); - } + const setDetection = useCallback(() => { + setShouldDetect((prevShouldDetect) => { + const newShouldDetect = !prevShouldDetect; - reset() { - this.setState({ dataUri: '' }); - if (this.state.shouldDetect) { - this.startDetection(); - } - } + if (newShouldDetect) { + setIsFinishedLoadingDetection(false); + setTimeout(() => startDetection(), 0); + } - render() { - const cameraFlashClass = this.state.dataUri - ? 'do-transition camera-flash' - : 'camera-flash'; - return ( -
- - - {!this.state.isFinishedLoadingDetection && } - - {this.props.isPortrait - ? this.props.intl.formatMessage(messages['id.verification.photo.enable.detection.portrait.help.text']) - : this.props.intl.formatMessage(messages['id.verification.photo.enable.detection.id.help.text'])} - - -
-
-
- + playsInline + /> + + imgCamera +
{feedback}
- ); - } -} + +
+ ); +}; Camera.propTypes = { - intl: intlShape.isRequired, onImageCapture: PropTypes.func.isRequired, isPortrait: PropTypes.bool.isRequired, }; -export default injectIntl(Camera); +export default Camera; diff --git a/src/id-verification/tests/Camera.test.jsx b/src/id-verification/tests/Camera.test.jsx index 8b33158..a265770 100644 --- a/src/id-verification/tests/Camera.test.jsx +++ b/src/id-verification/tests/Camera.test.jsx @@ -1,13 +1,14 @@ /* eslint-disable no-import-assign */ -import React from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; import { render, cleanup, screen, act, fireEvent, + waitFor, } from '@testing-library/react'; -import { injectIntl, IntlProvider } from '@edx/frontend-platform/i18n'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; // eslint-disable-next-line import/no-unresolved import * as blazeface from '@tensorflow-models/blazeface'; import * as analytics from '@edx/frontend-platform/analytics'; +import CameraPhoto from 'jslib-html5-camera-photo'; import IdVerificationContext from '../IdVerificationContext'; import Camera from '../Camera'; @@ -17,19 +18,15 @@ jest.mock('@edx/frontend-platform/analytics'); analytics.sendTrackEvent = jest.fn(); -window.HTMLMediaElement.prototype.play = () => {}; +window.HTMLMediaElement.prototype.play = jest.fn().mockImplementation(() => Promise.resolve()); -const IntlCamera = injectIntl(Camera); - -describe('SubmittedPanel', () => { +describe('Camera Component', () => { const defaultProps = { - intl: {}, onImageCapture: jest.fn(), isPortrait: true, }; const idProps = { - intl: {}, onImageCapture: jest.fn(), isPortrait: false, }; @@ -38,6 +35,7 @@ describe('SubmittedPanel', () => { afterEach(() => { cleanup(); + jest.clearAllMocks(); }); it('takes photo', async () => { @@ -45,7 +43,7 @@ describe('SubmittedPanel', () => { - + @@ -61,7 +59,7 @@ describe('SubmittedPanel', () => { - + @@ -75,7 +73,7 @@ describe('SubmittedPanel', () => { - + @@ -90,7 +88,7 @@ describe('SubmittedPanel', () => { - + @@ -108,7 +106,7 @@ describe('SubmittedPanel', () => { - + @@ -128,7 +126,7 @@ describe('SubmittedPanel', () => { - + @@ -147,18 +145,22 @@ describe('SubmittedPanel', () => { - + ))); - await fireEvent.loadedData(screen.queryByTestId('video')); + fireEvent.loadedData(screen.queryByTestId('video')); const checkbox = await screen.findByLabelText('Enable Face Detection'); - await fireEvent.click(checkbox); - expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.id_verification.user_photo.face_detection_enabled'); - await fireEvent.click(checkbox); - expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.id_verification.user_photo.face_detection_disabled'); + fireEvent.click(checkbox); + await waitFor(() => { + expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.id_verification.user_photo.face_detection_enabled'); + }); + fireEvent.click(checkbox); + await waitFor(() => { + expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.id_verification.user_photo.face_detection_disabled'); + }); }); it('sends tracking events on id photo page', async () => { @@ -168,7 +170,7 @@ describe('SubmittedPanel', () => { - + @@ -176,9 +178,108 @@ describe('SubmittedPanel', () => { await fireEvent.loadedData(screen.queryByTestId('video')); const checkbox = await screen.findByLabelText('Enable Face Detection'); - await fireEvent.click(checkbox); - expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.id_verification.id_photo.face_detection_enabled'); - await fireEvent.click(checkbox); - expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.id_verification.id_photo.face_detection_disabled'); + fireEvent.click(checkbox); + await waitFor(() => { + expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.id_verification.id_photo.face_detection_enabled'); + }); + fireEvent.click(checkbox); + await waitFor(() => { + expect(analytics.sendTrackEvent).toHaveBeenCalledWith('edx.id_verification.id_photo.face_detection_disabled'); + }); + }); + + describe('Camera getSizeFactor method', () => { + let mockGetDataUri; + + beforeEach(() => { + jest.clearAllMocks(); + mockGetDataUri = jest.fn().mockReturnValue('data:image/jpeg;base64,test'); + }); + + it('scales down large resolutions to stay under 10MB limit', async () => { + const currentSettings = { width: 4000, height: 3000 }; + + CameraPhoto.mockImplementation(() => ({ + startCamera: jest.fn(), + stopCamera: jest.fn(), + getDataUri: mockGetDataUri, + getCameraSettings: jest.fn().mockReturnValue(currentSettings), + })); + + await act(async () => render(( + + + + + + + + ))); + + const button = await screen.findByRole('button', { name: /take photo/i }); + fireEvent.click(button); + + // For large resolution: size = 4000 * 3000 * 3 = 36,000,000 bytes + // Ratio = 9,999,999 / 36,000,000 ≈ 0.278 + expect(mockGetDataUri).toHaveBeenCalledWith(expect.objectContaining({ + sizeFactor: expect.closeTo(0.278, 2), + })); + }); + + it('scales up 640x480 resolution to improve quality', async () => { + const currentSettings = { width: 640, height: 480 }; + + CameraPhoto.mockImplementation(() => ({ + startCamera: jest.fn(), + stopCamera: jest.fn(), + getDataUri: mockGetDataUri, + getCameraSettings: jest.fn().mockReturnValue(currentSettings), + })); + + await act(async () => render(( + + + + + + + + ))); + + const button = await screen.findByRole('button', { name: /take photo/i }); + fireEvent.click(button); + + expect(mockGetDataUri).toHaveBeenCalledWith(expect.objectContaining({ + sizeFactor: 2, + })); + }); + + it('maintains original size for medium resolutions', async () => { + const currentSettings = { width: 1280, height: 720 }; + + CameraPhoto.mockImplementation(() => ({ + startCamera: jest.fn(), + stopCamera: jest.fn(), + getDataUri: mockGetDataUri, + getCameraSettings: jest.fn().mockReturnValue(currentSettings), + })); + + await act(async () => render(( + + + + + + + + ))); + + const button = await screen.findByRole('button', { name: /take photo/i }); + fireEvent.click(button); + + expect(mockGetDataUri).toHaveBeenCalledWith(expect.objectContaining({ + sizeFactor: 1, + })); + }); }); });