Merge pull request #1314 from WGU-Open-edX/1295/replaceInjectIntl10of10

Replace of injectIntl with useIntl() 10/10
This commit is contained in:
sundasnoreen12
2025-08-11 12:32:26 +05:00
committed by GitHub
4 changed files with 652 additions and 569 deletions

View File

@@ -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 (
<div>
@@ -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: <b>{this.state.duplicateTpaProvider}</b>,
provider: <b>{duplicateTpaProvider}</b>,
siteName: getConfig().SITE_NAME,
}}
/>
</Alert>
</div>
);
}
};
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: <b>{this.props.profileDataManager}</b>,
managerTitle: <b>{profileDataManager}</b>,
support: (
<Hyperlink destination={getConfig().SUPPORT_URL} target="_blank">
<FormattedMessage
@@ -295,11 +299,11 @@ class AccountSettingsPage extends React.Component {
</Alert>
</div>
);
}
};
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={
(
<div className="d-flex flex-row">
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.failure.message'])}
{intl.formatMessage(messages['account.settings.field.name.verified.failure.message'])}
</div>
)
}
@@ -356,25 +360,25 @@ class AccountSettingsPage extends React.Component {
);
};
renderVerifiedNameSubmittedMessage = (willCertNameChange) => (
const renderVerifiedNameSubmittedMessage = (willCertNameChange) => (
<Alert
variant="warning"
icon={WarningFilled}
>
<Alert.Heading>
{this.props.intl.formatMessage(messages['account.settings.field.name.verified.submitted.message.header'])}
{intl.formatMessage(messages['account.settings.field.name.verified.submitted.message.header'])}
</Alert.Heading>
<p>
{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'])
}
</p>
</Alert>
);
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 (<Icon src={CheckCircle} className="ml-1" style={{ height: '18px', width: '18px', color: 'green' }} />);
@@ -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 <NameChange targetFormId={this.props.nameChangeModal.formId} />;
const renderNameChangeModal = () => {
if (nameChangeModal && nameChangeModal.formId) {
return <NameChange targetFormId={nameChangeModal.formId} />;
}
return null;
}
};
renderSecondaryEmailField(editableFieldProps) {
if (!this.props.formValues.secondary_email_enabled) {
const renderSecondaryEmailField = (editableFieldProps) => {
if (!props.formValues.secondary_email_enabled) {
return null;
}
return (
<EmailField
name="secondary_email"
label={this.props.intl.formatMessage(messages['account.settings.field.secondary.email'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.secondary.email.empty'])}
value={this.props.formValues.secondary_email}
label={intl.formatMessage(messages['account.settings.field.secondary.email'])}
emptyLabel={intl.formatMessage(messages['account.settings.field.secondary.email.empty'])}
value={props.formValues.secondary_email}
confirmationMessageDefinition={messages['account.settings.field.secondary.email.confirmation']}
{...editableFieldProps}
/>
);
}
};
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}
/>
)}
<div className="account-section pt-3 mb-5" id="basic-information" ref={this.navLinkRefs['#basic-information']}>
<div className="account-section pt-3 mb-5" id="basic-information" ref={navLinkRefs['#basic-information']}>
{
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=""
/>
)}
<h2 className="section-heading h4 mb-3">
{this.props.intl.formatMessage(messages['account.settings.section.account.information'])}
{intl.formatMessage(messages['account.settings.section.account.information'])}
</h2>
<p>{this.props.intl.formatMessage(messages['account.settings.section.account.information.description'])}</p>
{this.renderManagedProfileMessage()}
<p>{intl.formatMessage(messages['account.settings.section.account.information.description'])}</p>
{renderManagedProfileMessage()}
{this.renderNameChangeModal()}
{renderNameChangeModal()}
<EditableField
name="username"
type="text"
value={this.props.formValues.username}
label={this.props.intl.formatMessage(messages['account.settings.field.username'])}
helpText={this.props.intl.formatMessage(
value={props.formValues.username}
label={intl.formatMessage(messages['account.settings.field.username'])}
helpText={intl.formatMessage(
messages['account.settings.field.username.help.text'],
{ siteName: getConfig().SITE_NAME },
)}
@@ -572,83 +576,83 @@ class AccountSettingsPage extends React.Component {
type="text"
value={
verifiedName?.status === 'submitted'
&& this.props.formValues.pending_name_change
? this.props.formValues.pending_name_change
: this.props.formValues.name
&& props.formValues.pending_name_change
? props.formValues.pending_name_change
: props.formValues.name
}
label={this.props.intl.formatMessage(messages['account.settings.field.full.name'])}
label={intl.formatMessage(messages['account.settings.field.full.name'])}
emptyLabel={
this.isEditable('name')
? this.props.intl.formatMessage(messages['account.settings.field.full.name.empty'])
: this.renderEmptyStaticFieldMessage()
isEditable('name')
? intl.formatMessage(messages['account.settings.field.full.name.empty'])
: renderEmptyStaticFieldMessage()
}
helpText={
verifiedName
? this.renderFullNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)
: this.props.intl.formatMessage(messages['account.settings.field.full.name.help.text'])
? renderFullNameHelpText(verifiedName.status, verifiedName.proctored_exam_attempt_id)
: intl.formatMessage(messages['account.settings.field.full.name.help.text'])
}
isEditable={
verifiedName
? this.isEditable('verifiedName') && this.isEditable('name')
: this.isEditable('name')
? isEditable('verifiedName') && isEditable('name')
: isEditable('name')
}
isGrayedOut={
verifiedName && !this.isEditable('verifiedName')
verifiedName && !isEditable('verifiedName')
}
onChange={this.handleEditableFieldChange}
onSubmit={this.handleSubmitProfileName}
onChange={handleEditableFieldChange}
onSubmit={handleSubmitProfileName}
/>
{verifiedName
&& (
<EditableField
name="verified_name"
type="text"
value={this.props.formValues.verified_name}
value={props.formValues.verified_name}
label={
(
<div className="d-flex">
{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)
}
</div>
)
}
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}
/>
)}
<EmailField
name="email"
label={this.props.intl.formatMessage(messages['account.settings.field.email'])}
label={intl.formatMessage(messages['account.settings.field.email'])}
emptyLabel={
this.isEditable('email')
? this.props.intl.formatMessage(messages['account.settings.field.email.empty'])
: this.renderEmptyStaticFieldMessage()
isEditable('email')
? intl.formatMessage(messages['account.settings.field.email.empty'])
: renderEmptyStaticFieldMessage()
}
value={this.props.formValues.email}
value={props.formValues.email}
confirmationMessageDefinition={messages['account.settings.field.email.confirmation']}
helpText={this.props.intl.formatMessage(
helpText={intl.formatMessage(
messages['account.settings.field.email.help.text'],
{ siteName: getConfig().SITE_NAME },
)}
isEditable={this.isEditable('email')}
isEditable={isEditable('email')}
{...editableFieldProps}
/>
{this.renderSecondaryEmailField(editableFieldProps)}
<ResetPassword email={this.props.formValues.email} />
{renderSecondaryEmailField(editableFieldProps)}
<ResetPassword email={props.formValues.email} />
{(!getConfig().ENABLE_COPPA_COMPLIANCE)
&& (
<EditableSelectField
name="year_of_birth"
type="select"
label={this.props.intl.formatMessage(messages['account.settings.field.dob'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.dob.empty'])}
value={this.props.formValues.year_of_birth}
label={intl.formatMessage(messages['account.settings.field.dob'])}
emptyLabel={intl.formatMessage(messages['account.settings.field.dob.empty'])}
value={props.formValues.year_of_birth}
options={yearOfBirthOptions}
{...editableFieldProps}
/>
@@ -656,15 +660,15 @@ class AccountSettingsPage extends React.Component {
<EditableSelectField
name="country"
type="select"
value={this.props.formValues.country}
value={props.formValues.country}
options={countryOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.country'])}
label={intl.formatMessage(messages['account.settings.field.country'])}
emptyLabel={
this.isEditable('country')
? this.props.intl.formatMessage(messages['account.settings.field.country.empty'])
: this.renderEmptyStaticFieldMessage()
isEditable('country')
? intl.formatMessage(messages['account.settings.field.country.empty'])
: renderEmptyStaticFieldMessage()
}
isEditable={this.isEditable('country')}
isEditable={isEditable('country')}
{...editableFieldProps}
/>
{showState
@@ -672,43 +676,43 @@ class AccountSettingsPage extends React.Component {
<EditableSelectField
name="state"
type="select"
value={this.props.formValues.state}
value={props.formValues.state}
options={stateOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.state'])}
label={intl.formatMessage(messages['account.settings.field.state'])}
emptyLabel={
this.isEditable('state')
? this.props.intl.formatMessage(messages['account.settings.field.state.empty'])
: this.renderEmptyStaticFieldMessage()
isEditable('state')
? intl.formatMessage(messages['account.settings.field.state.empty'])
: renderEmptyStaticFieldMessage()
}
isEditable={this.isEditable('state')}
isEditable={isEditable('state')}
{...editableFieldProps}
/>
)}
</div>
<div className="account-section pt-3 mb-5" id="profile-information" ref={this.navLinkRefs['#profile-information']}>
<div className="account-section pt-3 mb-5" id="profile-information" ref={navLinkRefs['#profile-information']}>
<h2 className="section-heading h4 mb-3">
{this.props.intl.formatMessage(messages['account.settings.section.profile.information'])}
{intl.formatMessage(messages['account.settings.section.profile.information'])}
</h2>
<EditableSelectField
name="level_of_education"
type="select"
value={this.props.formValues.level_of_education}
value={props.formValues.level_of_education}
options={getConfig().ENABLE_COPPA_COMPLIANCE
? educationLevelOptions.filter(option => 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}
/>
<EditableSelectField
name="gender"
type="select"
value={this.props.formValues.gender}
value={props.formValues.gender}
options={genderOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.gender'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.gender.empty'])}
label={intl.formatMessage(messages['account.settings.field.gender'])}
emptyLabel={intl.formatMessage(messages['account.settings.field.gender.empty'])}
{...editableFieldProps}
/>
{hasWorkExperience
@@ -716,29 +720,29 @@ class AccountSettingsPage extends React.Component {
<EditableSelectField
name="work_experience"
type="select"
value={this.props.formValues?.extended_profile?.find(field => 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}
/>
)}
<EditableSelectField
name="language_proficiencies"
type="select"
value={this.props.formValues.language_proficiencies}
value={props.formValues.language_proficiencies}
options={languageProficiencyOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
label={intl.formatMessage(messages['account.settings.field.language.proficiencies'])}
emptyLabel={intl.formatMessage(messages['account.settings.field.language.proficiencies.empty'])}
{...editableFieldProps}
/>
</div>
<div className="account-section pt-3 mb-6" id="social-media">
<h2 className="section-heading h4 mb-3">
{this.props.intl.formatMessage(messages['account.settings.section.social.media'])}
{intl.formatMessage(messages['account.settings.section.social.media'])}
</h2>
<p>
{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 {
<EditableField
name="social_link_linkedin"
type="text"
value={this.props.formValues.social_link_linkedin}
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.linkedin'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.linkedin.empty'])}
value={props.formValues.social_link_linkedin}
label={intl.formatMessage(messages['account.settings.field.social.platform.name.linkedin'])}
emptyLabel={intl.formatMessage(messages['account.settings.field.social.platform.name.linkedin.empty'])}
{...editableFieldProps}
/>
<EditableField
name="social_link_facebook"
type="text"
value={this.props.formValues.social_link_facebook}
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.facebook'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.facebook.empty'])}
value={props.formValues.social_link_facebook}
label={intl.formatMessage(messages['account.settings.field.social.platform.name.facebook'])}
emptyLabel={intl.formatMessage(messages['account.settings.field.social.platform.name.facebook.empty'])}
{...editableFieldProps}
/>
<EditableField
name="social_link_twitter"
type="text"
value={this.props.formValues.social_link_twitter}
label={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.social.platform.name.twitter.empty'])}
value={props.formValues.social_link_twitter}
label={intl.formatMessage(messages['account.settings.field.social.platform.name.twitter'])}
emptyLabel={intl.formatMessage(messages['account.settings.field.social.platform.name.twitter.empty'])}
{...editableFieldProps}
/>
</div>
<div className="border border-light-700" />
<div className="mt-6" id="notifications" ref={this.navLinkRefs['#notifications']}>
<div className="mt-6" id="notifications" ref={navLinkRefs['#notifications']}>
<NotificationSettings />
</div>
<div className="account-section mb-5" id="site-preferences" ref={this.navLinkRefs['#site-preferences']}>
<div className="account-section mb-5" id="site-preferences" ref={navLinkRefs['#site-preferences']}>
<h2 className="section-heading h4 mb-3">
{this.props.intl.formatMessage(messages['account.settings.section.site.preferences'])}
{intl.formatMessage(messages['account.settings.section.site.preferences'])}
</h2>
<BetaLanguageBanner />
<EditableSelectField
name="siteLanguage"
type="select"
options={this.props.siteLanguageOptions}
value={this.props.siteLanguage.draft !== undefined ? this.props.siteLanguage.draft : this.context.locale}
label={this.props.intl.formatMessage(messages['account.settings.field.site.language'])}
helpText={this.props.intl.formatMessage(messages['account.settings.field.site.language.help.text'])}
options={props.siteLanguageOptions}
value={props.siteLanguage.draft !== undefined ? props.siteLanguage.draft : appContext.locale}
label={intl.formatMessage(messages['account.settings.field.site.language'])}
helpText={intl.formatMessage(messages['account.settings.field.site.language.help.text'])}
{...editableFieldProps}
/>
<EditableSelectField
name="time_zone"
type="select"
value={this.props.formValues.time_zone}
value={props.formValues.time_zone}
options={timeZoneOptions}
label={this.props.intl.formatMessage(messages['account.settings.field.time.zone'])}
emptyLabel={this.props.intl.formatMessage(messages['account.settings.field.time.zone.empty'])}
helpText={this.props.intl.formatMessage(messages['account.settings.field.time.zone.description'])}
label={intl.formatMessage(messages['account.settings.field.time.zone'])}
emptyLabel={intl.formatMessage(messages['account.settings.field.time.zone.empty'])}
helpText={intl.formatMessage(messages['account.settings.field.time.zone.description'])}
{...editableFieldProps}
onSubmit={(formId, value) => {
// the endpoint will not accept an empty string. it must be null
this.handleSubmit(formId, value || null);
handleSubmit(formId, value || null);
}}
/>
</div>
<div className="account-section pt-3 mb-5" id="linked-accounts" ref={this.navLinkRefs['#linked-accounts']}>
<h2 className="section-heading h4 mb-3">{this.props.intl.formatMessage(messages['account.settings.section.linked.accounts'])}</h2>
<div className="account-section pt-3 mb-5" id="linked-accounts" ref={navLinkRefs['#linked-accounts']}>
<h2 className="section-heading h4 mb-3">{intl.formatMessage(messages['account.settings.section.linked.accounts'])}</h2>
<p>
{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 {
</div>
{getConfig().ENABLE_ACCOUNT_DELETION && (
<div className="account-section pt-3 mb-5" id="delete-account" ref={this.navLinkRefs['#delete-account']}>
<div className="account-section pt-3 mb-5" id="delete-account" ref={navLinkRefs['#delete-account']}>
<DeleteAccount
isVerifiedAccount={this.props.isActive}
isVerifiedAccount={props.isActive}
hasLinkedTPA={hasLinkedTPA}
canDeleteAccount={this.canDeleteAccount()}
canDeleteAccount={canDeleteAccount()}
/>
</div>
)}
</>
);
}
};
renderError() {
return (
const renderError = () => (
<div>
{intl.formatMessage(messages['account.settings.loading.error'], {
error: loadingError,
})}
</div>
);
const renderLoading = () => (
<PageLoading srMessage={intl.formatMessage(messages['account.settings.loading.message'])} />
);
return (
<Container className="page__account-settings py-5" size="xl">
{renderDuplicateTpaProviderMessage()}
<h1 className="mb-4">
{intl.formatMessage(messages['account.settings.page.heading'])}
</h1>
<div>
{this.props.intl.formatMessage(messages['account.settings.loading.error'], {
error: this.props.loadingError,
})}
</div>
);
}
renderLoading() {
return (
<PageLoading srMessage={this.props.intl.formatMessage(messages['account.settings.loading.message'])} />
);
}
render() {
const {
loading,
loaded,
loadingError,
} = this.props;
return (
<Container className="page__account-settings py-5" size="xl">
{this.renderDuplicateTpaProviderMessage()}
<h1 className="mb-4">
{this.props.intl.formatMessage(messages['account.settings.page.heading'])}
</h1>
<div>
<div className="row">
<div className="col-md-3">
<JumpNav />
</div>
<div className="col-md-9">
{loading ? this.renderLoading() : null}
{loaded ? this.renderContent() : null}
{loadingError ? this.renderError() : null}
</div>
<div className="row">
<div className="col-md-3">
<JumpNav />
</div>
<div className="col-md-9">
{loading ? renderLoading() : null}
{loaded ? renderContent() : null}
{loadingError ? renderError() : null}
</div>
</div>
</Container>
);
}
}
AccountSettingsPage.contextType = AppContext;
</div>
</Container>
);
};
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)));

View File

@@ -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(<IntlAccountSettingsPage {...props} />));
const { getByText, rerender, getByLabelText } = render(reduxWrapper(<AccountSettingsPage {...props} />));
const workExperienceText = getByText('Work Experience');
const workExperienceEditButton = workExperienceText.parentElement.querySelector('button');
@@ -96,7 +94,7 @@ describe('AccountSettingsPage', () => {
openFormId: 'work_experience',
},
});
rerender(reduxWrapper(<IntlAccountSettingsPage {...props} />));
rerender(reduxWrapper(<AccountSettingsPage {...props} />));
const submitButton = screen.getByText('Save');
expect(submitButton).toBeInTheDocument();

View File

@@ -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 (
<div className="camera-outer-wrapper shadow">
<Form.Group style={{ textAlign: 'left', padding: '0.5rem', marginBottom: '0.5rem' }}>
<Form.Check
id="videoDetection"
name="videoDetection"
label={this.props.intl.formatMessage(messages['id.verification.photo.enable.detection'])}
aria-describedby="videoDetectionHelpText"
checked={this.state.shouldDetect}
onChange={this.setDetection}
style={{ padding: '0rem', marginLeft: '1.25rem', float: this.state.isFinishedLoadingDetection ? 'none' : 'left' }}
/>
{!this.state.isFinishedLoadingDetection && <Spinner animation="border" variant="primary" style={{ marginLeft: '0.5rem' }} data-testid="spinner" />}
<Form.Text id="videoDetectionHelpText" data-testid="videoDetectionHelpText">
{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'])}
</Form.Text>
</Form.Group>
<div className="camera-wrapper">
<div className={cameraFlashClass} />
<video
ref={this.videoRef}
data-testid="video"
autoPlay
className="camera-video"
onLoadedData={() => { this.setVideoHasLoaded(); }}
style={{
display: this.state.dataUri ? 'none' : 'block',
WebkitTransform: 'scaleX(-1)',
transform: 'scaleX(-1)',
}}
playsInline
/>
<canvas
ref={this.canvasRef}
data-testid="detection-canvas"
className="canvas-video"
style={{
display: !this.state.shouldDetect || this.state.dataUri ? 'none' : 'block',
WebkitTransform: 'scaleX(-1)',
transform: 'scaleX(-1)',
}}
width="640"
height="480"
/>
<img
data-hj-suppress
alt="imgCamera"
src={this.state.dataUri}
className="camera-video"
style={{ display: this.state.dataUri ? 'block' : 'none' }}
/>
<div role="status" className="sr-only">{this.state.feedback}</div>
</div>
<button
type="button"
className={`btn camera-btn ${
this.state.dataUri
? 'btn-outline-primary'
: 'btn-primary'
}`}
accessKey="c"
onClick={() => {
this.takePhoto();
// Send event after state update
setTimeout(() => sendEvent(newShouldDetect), 0);
return newShouldDetect;
});
}, [startDetection, sendEvent]);
const cameraFlashClass = dataUri
? 'do-transition camera-flash'
: 'camera-flash';
return (
<div className="camera-outer-wrapper shadow">
<Form.Group style={{ textAlign: 'left', padding: '0.5rem', marginBottom: '0.5rem' }}>
<Form.Check
id="videoDetection"
name="videoDetection"
label={intl.formatMessage(messages['id.verification.photo.enable.detection'])}
aria-describedby="videoDetectionHelpText"
checked={shouldDetect}
onChange={setDetection}
style={{ padding: '0rem', marginLeft: '1.25rem', float: isFinishedLoadingDetection ? 'none' : 'left' }}
/>
{!isFinishedLoadingDetection && <Spinner animation="border" variant="primary" style={{ marginLeft: '0.5rem' }} data-testid="spinner" />}
<Form.Text id="videoDetectionHelpText" data-testid="videoDetectionHelpText">
{isPortrait
? intl.formatMessage(messages['id.verification.photo.enable.detection.portrait.help.text'])
: intl.formatMessage(messages['id.verification.photo.enable.detection.id.help.text'])}
</Form.Text>
</Form.Group>
<div className="camera-wrapper">
<div className={cameraFlashClass} />
<video
ref={videoRef}
data-testid="video"
autoPlay
className="camera-video"
onLoadedData={handleVideoLoad}
style={{
display: dataUri ? 'none' : 'block',
WebkitTransform: 'scaleX(-1)',
transform: 'scaleX(-1)',
}}
>
{this.state.dataUri
? this.props.intl.formatMessage(messages['id.verification.photo.retake'])
: this.props.intl.formatMessage(messages['id.verification.photo.take'])}
</button>
playsInline
/>
<canvas
ref={canvasRef}
data-testid="detection-canvas"
className="canvas-video"
style={{
display: !shouldDetect || dataUri ? 'none' : 'block',
WebkitTransform: 'scaleX(-1)',
transform: 'scaleX(-1)',
}}
width="640"
height="480"
/>
<img
data-hj-suppress
alt="imgCamera"
src={dataUri}
className="camera-video"
style={{ display: dataUri ? 'block' : 'none' }}
/>
<div role="status" className="sr-only">{feedback}</div>
</div>
);
}
}
<button
type="button"
className={`btn camera-btn ${
dataUri
? 'btn-outline-primary'
: 'btn-primary'
}`}
accessKey="c"
onClick={takePhoto}
>
{dataUri
? intl.formatMessage(messages['id.verification.photo.retake'])
: intl.formatMessage(messages['id.verification.photo.take'])}
</button>
</div>
);
};
Camera.propTypes = {
intl: intlShape.isRequired,
onImageCapture: PropTypes.func.isRequired,
isPortrait: PropTypes.bool.isRequired,
};
export default injectIntl(Camera);
export default Camera;

View File

@@ -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', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCamera {...defaultProps} />
<Camera {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -61,7 +59,7 @@ describe('SubmittedPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCamera {...defaultProps} />
<Camera {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -75,7 +73,7 @@ describe('SubmittedPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCamera {...idProps} />
<Camera {...idProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -90,7 +88,7 @@ describe('SubmittedPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCamera {...defaultProps} />
<Camera {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -108,7 +106,7 @@ describe('SubmittedPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCamera {...defaultProps} />
<Camera {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -128,7 +126,7 @@ describe('SubmittedPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCamera {...defaultProps} />
<Camera {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -147,18 +145,22 @@ describe('SubmittedPanel', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCamera {...defaultProps} />
<Camera {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
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', () => {
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<IntlCamera {...idProps} />
<Camera {...idProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
@@ -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((
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<Camera {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
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((
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<Camera {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
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((
<Router>
<IntlProvider locale="en">
<IdVerificationContext.Provider value={contextValue}>
<Camera {...defaultProps} />
</IdVerificationContext.Provider>
</IntlProvider>
</Router>
)));
const button = await screen.findByRole('button', { name: /take photo/i });
fireEvent.click(button);
expect(mockGetDataUri).toHaveBeenCalledWith(expect.objectContaining({
sizeFactor: 1,
}));
});
});
});