diff --git a/src/field-renderer/FieldRenderer.jsx b/src/field-renderer/FieldRenderer.jsx
index b1d77c95b..cc5cd3cc7 100644
--- a/src/field-renderer/FieldRenderer.jsx
+++ b/src/field-renderer/FieldRenderer.jsx
@@ -1,26 +1,51 @@
-import { Form, Icon } from '@openedx/paragon';
+import React, { useState } from 'react';
+
+import { Form, Icon, TransitionReplace } from '@openedx/paragon';
import { ExpandMore } from '@openedx/paragon/icons';
import PropTypes from 'prop-types';
const FormFieldRenderer = (props) => {
+ const [hasFocus, setHasFocus] = useState(false);
let formField = null;
const {
className, errorMessage, fieldData, onChangeHandler, isRequired, value,
} = props;
const handleFocus = (e) => {
+ setHasFocus(true);
if (props.handleFocus) { props.handleFocus(e); }
};
const handleOnBlur = (e) => {
+ setHasFocus(false);
if (props.handleBlur) { props.handleBlur(e); }
};
+ const helpTextFeedback = (
+
+ {hasFocus && fieldData.instructions ? (
+
+ {fieldData.instructions}
+
+ ) : }
+
+ );
+
+ const errorFeedback = isRequired && errorMessage ? (
+
+ {errorMessage}
+
+ ) : null;
+
switch (fieldData.type) {
case 'select': {
if (!fieldData.options) {
return null;
}
+ const optionsArray = Array.isArray(fieldData.options)
+ ? fieldData.options
+ : Object.entries(fieldData.options);
+
formField = (
{
onFocus={handleFocus}
>
- {fieldData.options.map(option => (
+ {optionsArray.map(option => (
))}
- {isRequired && errorMessage && (
-
- {errorMessage}
-
- )}
+ {helpTextFeedback}
+ {errorFeedback}
);
break;
@@ -57,17 +79,17 @@ const FormFieldRenderer = (props) => {
as="textarea"
name={fieldData.name}
value={value}
+ placeholder={fieldData.placeholder}
+ minLength={fieldData.restrictions?.min_length}
+ maxLength={fieldData.restrictions?.max_length}
aria-invalid={isRequired && Boolean(errorMessage)}
onChange={(e) => onChangeHandler(e)}
floatingLabel={fieldData.label}
onBlur={handleOnBlur}
onFocus={handleFocus}
/>
- {isRequired && errorMessage && (
-
- {errorMessage}
-
- )}
+ {helpTextFeedback}
+ {errorFeedback}
);
break;
@@ -79,17 +101,17 @@ const FormFieldRenderer = (props) => {
className={className}
name={fieldData.name}
value={value}
+ placeholder={fieldData.placeholder}
+ minLength={fieldData.restrictions?.min_length}
+ maxLength={fieldData.restrictions?.max_length}
aria-invalid={isRequired && Boolean(errorMessage)}
onChange={(e) => onChangeHandler(e)}
floatingLabel={fieldData.label}
onBlur={handleOnBlur}
onFocus={handleFocus}
/>
- {isRequired && errorMessage && (
-
- {errorMessage}
-
- )}
+ {helpTextFeedback}
+ {errorFeedback}
);
break;
@@ -110,11 +132,8 @@ const FormFieldRenderer = (props) => {
>
{fieldData.label}
- {isRequired && errorMessage && (
-
- {errorMessage}
-
- )}
+ {helpTextFeedback}
+ {errorFeedback}
);
break;
@@ -140,7 +159,16 @@ FormFieldRenderer.propTypes = {
type: PropTypes.string,
label: PropTypes.string,
name: PropTypes.string,
- options: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
+ placeholder: PropTypes.string,
+ instructions: PropTypes.string,
+ restrictions: PropTypes.shape({
+ min_length: PropTypes.number,
+ max_length: PropTypes.number,
+ }),
+ options: PropTypes.oneOfType([
+ PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)),
+ PropTypes.objectOf(PropTypes.string),
+ ]),
}).isRequired,
onChangeHandler: PropTypes.func.isRequired,
handleBlur: PropTypes.func,
diff --git a/src/field-renderer/tests/FieldRenderer.test.jsx b/src/field-renderer/tests/FieldRenderer.test.jsx
index 3b38b8336..26ab2aaa7 100644
--- a/src/field-renderer/tests/FieldRenderer.test.jsx
+++ b/src/field-renderer/tests/FieldRenderer.test.jsx
@@ -1,5 +1,5 @@
import { getConfig } from '@edx/frontend-platform';
-import { fireEvent, render } from '@testing-library/react';
+import { fireEvent, render, waitFor } from '@testing-library/react';
import FieldRenderer from '../FieldRenderer';
@@ -43,7 +43,7 @@ describe('FieldRendererTests', () => {
name: 'yob-field',
};
- const { container } = render( {}} />);
+ const { container } = render( { }} />);
expect(container.innerHTML).toEqual('');
});
@@ -103,7 +103,7 @@ describe('FieldRendererTests', () => {
type: 'unknown',
};
- const { container } = render( {}} />);
+ const { container } = render( { }} />);
expect(container.innerHTML).toContain('');
});
@@ -198,4 +198,366 @@ describe('FieldRendererTests', () => {
expect(container.querySelector(`#${fieldData.name}-error`).textContent).toEqual('You must agree to our Honor Code');
});
+
+ it('should render placeholder for text field', () => {
+ const fieldData = {
+ type: 'text',
+ label: 'Company',
+ name: 'company-field',
+ placeholder: 'Enter your company name',
+ };
+
+ const { container } = render();
+ const input = container.querySelector('input#company-field');
+
+ expect(input.placeholder).toEqual('Enter your company name');
+ });
+
+ it('should render placeholder for textarea field', () => {
+ const fieldData = {
+ type: 'textarea',
+ label: 'Goals',
+ name: 'goals-field',
+ placeholder: 'Share your learning goals',
+ };
+
+ const { container } = render();
+ const input = container.querySelector('#goals-field');
+
+ expect(input.placeholder).toEqual('Share your learning goals');
+ });
+
+ it('should apply maxLength restriction to text field', () => {
+ const fieldData = {
+ type: 'text',
+ label: 'Company',
+ name: 'company-field',
+ restrictions: {
+ max_length: 50,
+ },
+ };
+
+ const { container } = render();
+ const input = container.querySelector('input#company-field');
+
+ expect(input.maxLength).toEqual(50);
+ });
+
+ it('should apply maxLength restriction to textarea field', () => {
+ const fieldData = {
+ type: 'textarea',
+ label: 'Goals',
+ name: 'goals-field',
+ restrictions: {
+ max_length: 200,
+ },
+ };
+
+ const { container } = render();
+ const input = container.querySelector('#goals-field');
+
+ expect(input.maxLength).toEqual(200);
+ });
+
+ it('should apply minLength restriction to text field', () => {
+ const fieldData = {
+ type: 'text',
+ label: 'Username',
+ name: 'username-field',
+ restrictions: {
+ min_length: 3,
+ },
+ };
+
+ const { container } = render();
+ const input = container.querySelector('input#username-field');
+
+ expect(input.minLength).toEqual(3);
+ });
+
+ it('should apply minLength restriction to textarea field', () => {
+ const fieldData = {
+ type: 'textarea',
+ label: 'Goals',
+ name: 'goals-field',
+ restrictions: {
+ min_length: 10,
+ },
+ };
+
+ const { container } = render();
+ const input = container.querySelector('#goals-field');
+
+ expect(input.minLength).toEqual(10);
+ });
+
+ it('should apply both minLength and maxLength restrictions to text field', () => {
+ const fieldData = {
+ type: 'text',
+ label: 'Username',
+ name: 'username-field',
+ restrictions: {
+ min_length: 3,
+ max_length: 30,
+ },
+ };
+
+ const { container } = render();
+ const input = container.querySelector('input#username-field');
+
+ expect(input.minLength).toEqual(3);
+ expect(input.maxLength).toEqual(30);
+ });
+
+ it('should apply both minLength and maxLength restrictions to textarea field', () => {
+ const fieldData = {
+ type: 'textarea',
+ label: 'Goals',
+ name: 'goals-field',
+ restrictions: {
+ min_length: 10,
+ max_length: 200,
+ },
+ };
+
+ const { container } = render();
+ const input = container.querySelector('#goals-field');
+
+ expect(input.minLength).toEqual(10);
+ expect(input.maxLength).toEqual(200);
+ });
+
+ it('should show help text when field has focus and instructions are provided', async () => {
+ const fieldData = {
+ type: 'text',
+ label: 'Username',
+ name: 'username-field',
+ instructions: 'Username must be between 2-30 characters',
+ };
+
+ const { container } = render();
+ const input = container.querySelector('input#username-field');
+
+ // Help text should not be visible initially
+ expect(container.textContent).not.toContain('Username must be between 2-30 characters');
+
+ // Focus the field
+ fireEvent.focus(input);
+
+ // Help text should now be visible
+ expect(container.textContent).toContain('Username must be between 2-30 characters');
+
+ // Blur the field
+ fireEvent.blur(input);
+
+ // Help text should be hidden again after transition
+ await waitFor(() => {
+ expect(container.textContent).not.toContain('Username must be between 2-30 characters');
+ });
+ });
+
+ it('should show help text for textarea when focused', () => {
+ const fieldData = {
+ type: 'textarea',
+ label: 'Goals',
+ name: 'goals-field',
+ instructions: 'Please describe your learning goals in detail',
+ };
+
+ const { container } = render();
+ const input = container.querySelector('#goals-field');
+
+ // Help text should not be visible initially
+ expect(container.textContent).not.toContain('Please describe your learning goals in detail');
+
+ // Focus the field
+ fireEvent.focus(input);
+
+ // Help text should now be visible
+ expect(container.textContent).toContain('Please describe your learning goals in detail');
+ });
+
+ it('should not show help text if instructions are not provided', () => {
+ const fieldData = {
+ type: 'text',
+ label: 'Company',
+ name: 'company-field',
+ };
+
+ const { container } = render();
+ const input = container.querySelector('input#company-field');
+
+ // Focus the field
+ fireEvent.focus(input);
+
+ // No help text should be rendered since instructions are not provided
+ const feedbackElement = container.querySelector('.form-control-feedback');
+ expect(feedbackElement).toBeNull();
+ });
+
+ it('should show help text for select field when focused', () => {
+ const fieldData = {
+ type: 'select',
+ label: 'Country',
+ name: 'country-field',
+ options: [['us', 'United States'], ['ca', 'Canada']],
+ instructions: 'Select your country of residence',
+ };
+
+ const { container } = render();
+ const select = container.querySelector('select#country-field');
+
+ // Help text should not be visible initially
+ expect(container.textContent).not.toContain('Select your country of residence');
+
+ // Focus the field
+ fireEvent.focus(select);
+
+ // Help text should now be visible
+ expect(container.textContent).toContain('Select your country of residence');
+ });
+
+ it('should render select field with options as object format', () => {
+ const fieldData = {
+ type: 'select',
+ label: 'Favorite Language',
+ name: 'favorite-language-field',
+ options: {
+ '': '---------',
+ python: 'Python',
+ javascript: 'JavaScript',
+ java: 'Java',
+ go: 'Go',
+ },
+ };
+
+ const { container } = render();
+ const select = container.querySelector('select#favorite-language-field');
+ const label = container.querySelector('label');
+
+ expect(select.type).toEqual('select-one');
+ expect(label.textContent).toContain(fieldData.label);
+
+ // Verify all options are rendered
+ const options = select.querySelectorAll('option');
+ expect(options.length).toBeGreaterThan(0);
+
+ // Check if specific options exist
+ const pythonOption = Array.from(options).find((opt) => opt.value === 'python');
+ expect(pythonOption).toBeTruthy();
+ expect(pythonOption.textContent).toEqual('Python');
+
+ const javascriptOption = Array.from(options).find((opt) => opt.value === 'javascript');
+ expect(javascriptOption).toBeTruthy();
+ expect(javascriptOption.textContent).toEqual('JavaScript');
+ });
+
+ it('should handle selection change with object format options', () => {
+ const fieldData = {
+ type: 'select',
+ label: 'Favorite Language',
+ name: 'favorite-language-field',
+ options: {
+ '': '---------',
+ python: 'Python',
+ javascript: 'JavaScript',
+ java: 'Java',
+ go: 'Go',
+ },
+ };
+
+ let capturedValue = '';
+
+ const mockChangeHandler = jest.fn((event) => {
+ capturedValue = event.target.value;
+ });
+
+ const { container } = render(
+ ,
+ );
+
+ const select = container.querySelector('select#favorite-language-field');
+
+ fireEvent.change(select, { target: { value: 'python' } });
+
+ expect(mockChangeHandler).toHaveBeenCalled();
+ expect(capturedValue).toBe('python');
+
+ fireEvent.change(select, { target: { value: 'javascript' } });
+
+ expect(mockChangeHandler).toHaveBeenCalledTimes(2);
+ expect(capturedValue).toBe('javascript');
+ });
+
+ it('should render all options correctly from object format', () => {
+ const fieldData = {
+ type: 'select',
+ label: 'Programming Language',
+ name: 'language-field',
+ options: {
+ '': '---------',
+ python: 'Python',
+ javascript: 'JavaScript',
+ java: 'Java',
+ go: 'Go',
+ },
+ };
+
+ const { container } = render();
+ const select = container.querySelector('select#language-field');
+ const options = select.querySelectorAll('option');
+
+ // Should have default option + 5 from object (including empty string)
+ const optionValues = Array.from(options).map((opt) => opt.value);
+ const optionLabels = Array.from(options).map((opt) => opt.textContent);
+
+ // Check that all expected values are present
+ expect(optionValues).toContain('python');
+ expect(optionValues).toContain('javascript');
+ expect(optionValues).toContain('java');
+ expect(optionValues).toContain('go');
+
+ // Check that all expected labels are present
+ expect(optionLabels).toContain('Python');
+ expect(optionLabels).toContain('JavaScript');
+ expect(optionLabels).toContain('Java');
+ expect(optionLabels).toContain('Go');
+ });
+
+ it('should handle select field with array format options (backwards compatibility)', () => {
+ const fieldData = {
+ type: 'select',
+ label: 'Year',
+ name: 'year-field',
+ options: [
+ ['2020', '2020'],
+ ['2021', '2021'],
+ ['2022', '2022'],
+ ],
+ };
+
+ const { container } = render();
+ const select = container.querySelector('select#year-field');
+
+ fireEvent.change(select, { target: { value: '2021' } });
+ expect(value).toEqual('2021');
+
+ // Verify options are rendered correctly
+ const options = select.querySelectorAll('option');
+ const yearOption = Array.from(options).find((opt) => opt.value === '2021');
+ expect(yearOption).toBeTruthy();
+ expect(yearOption.textContent).toEqual('2021');
+ });
+
+ it('should return null if options is not an array or object', () => {
+ const fieldData = {
+ type: 'select',
+ label: 'Invalid Options',
+ name: 'invalid-field',
+ options: null,
+ };
+
+ const { container } = render( { }} />);
+ expect(container.innerHTML).toEqual('');
+ });
});
diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx
index d132ed8b3..f5cf74ee5 100644
--- a/src/register/components/ConfigurableRegistrationForm.jsx
+++ b/src/register/components/ConfigurableRegistrationForm.jsx
@@ -6,6 +6,7 @@ import PropTypes from 'prop-types';
import { FormFieldRenderer } from '../../field-renderer';
import { FIELDS } from '../data/constants';
+import { normalizeErrorMessage } from '../data/utils';
import messages from '../messages';
import { CountryField, HonorCode, TermsOfService } from '../RegistrationFields';
@@ -97,7 +98,7 @@ const ConfigurableRegistrationForm = (props) => {
const { name, value } = event.target;
let error = '';
if ((!value || !value.trim()) && fieldDescriptions[name]?.error_message) {
- error = fieldDescriptions[name].error_message;
+ error = normalizeErrorMessage(fieldDescriptions[name].error_message);
} else if (name === 'confirm_email' && value !== email) {
error = formatMessage(messages['email.do.not.match']);
}
diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx
index 54d5288b5..db343b0fe 100644
--- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx
+++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx
@@ -607,5 +607,55 @@ describe('ConfigurableRegistrationForm', () => {
expect(professionErrorElement.textContent).toEqual(professionError);
});
+
+ it('should normalize object-style error messages from backend', () => {
+ const professionErrorMessage = { required: 'Enter your profession' };
+ useThirdPartyAuthContext.mockReturnValue({
+ currentProvider: null,
+ platformName: '',
+ providers: [],
+ secondaryProviders: [],
+ handleInstitutionLogin: jest.fn(),
+ handleInstitutionLogout: jest.fn(),
+ isInstitutionAuthActive: false,
+ institutionLogin: false,
+ pipelineDetails: {},
+ thirdPartyAuthContext: {
+ autoSubmitRegForm: false,
+ currentProvider: null,
+ finishAuthUrl: null,
+ pipelineUserDetails: null,
+ providers: [],
+ secondaryProviders: [],
+ errorMessage: null,
+ },
+ fieldDescriptions: {
+ profession: {
+ name: 'profession', type: 'text', label: 'Profession', error_message: professionErrorMessage,
+ },
+ },
+ optionalFields: [],
+ setThirdPartyAuthContextBegin: jest.fn(),
+ setThirdPartyAuthContextSuccess: jest.fn(),
+ setThirdPartyAuthContextFailure: jest.fn(),
+ setEmailSuggestionContext: jest.fn(),
+ clearThirdPartyAuthErrorMessage: jest.fn(),
+ });
+
+ const { getByLabelText, container } = render(
+ routerWrapper(renderWrapper()),
+ );
+
+ const professionInput = getByLabelText('Profession');
+ fireEvent.focus(professionInput);
+ fireEvent.blur(professionInput);
+
+ const submitButton = container.querySelector('button.btn-brand');
+ fireEvent.click(submitButton);
+
+ const professionErrorElement = container.querySelector('#profession-error');
+
+ expect(professionErrorElement.textContent).toEqual('Enter your profession');
+ });
});
});
diff --git a/src/register/data/tests/utils.test.js b/src/register/data/tests/utils.test.js
index 56ef582ec..70a6d7763 100644
--- a/src/register/data/tests/utils.test.js
+++ b/src/register/data/tests/utils.test.js
@@ -1,4 +1,38 @@
-import { isFormValid } from '../utils';
+import { isFormValid, normalizeErrorMessage } from '../utils';
+
+describe('normalizeErrorMessage', () => {
+ it('should return the error message if it is a string', () => {
+ const errorMessage = 'This field is required';
+ expect(normalizeErrorMessage(errorMessage)).toBe('This field is required');
+ });
+
+ it('should extract the first value from an object error message', () => {
+ const errorMessage = { required: 'This field is required' };
+ expect(normalizeErrorMessage(errorMessage)).toBe('This field is required');
+ });
+
+ it('should handle multiple keys in object and return first value', () => {
+ const errorMessage = { required: 'Required field', min_length: 'Too short' };
+ const result = normalizeErrorMessage(errorMessage);
+ expect(['Required field', 'Too short']).toContain(result);
+ });
+
+ it('should return empty string if error message is null', () => {
+ expect(normalizeErrorMessage(null)).toBe('');
+ });
+
+ it('should return empty string if error message is undefined', () => {
+ expect(normalizeErrorMessage(undefined)).toBe('');
+ });
+
+ it('should return empty string if error message is an empty object', () => {
+ expect(normalizeErrorMessage({})).toBe('');
+ });
+
+ it('should return empty string if error message is an empty string', () => {
+ expect(normalizeErrorMessage('')).toBe('');
+ });
+});
describe('Payload validation', () => {
let formatMessage;
diff --git a/src/register/data/utils.js b/src/register/data/utils.js
index 4411eb69b..2c568bd06 100644
--- a/src/register/data/utils.js
+++ b/src/register/data/utils.js
@@ -6,6 +6,19 @@ import validateEmail from '../RegistrationFields/EmailField/validator';
import validateName from '../RegistrationFields/NameField/validator';
import validateUsername from '../RegistrationFields/UsernameField/validator';
+/**
+ * Normalizes a field error_message that may come from the backend as either
+ * a plain string or an object (e.g. { required: "Error text" }).
+ * @param {string|object} errorMessage
+ * @returns {string}
+ */
+export const normalizeErrorMessage = (errorMessage) => {
+ if (typeof errorMessage === 'object' && errorMessage !== null) {
+ return Object.values(errorMessage)[0] || '';
+ }
+ return errorMessage || '';
+};
+
/**
* It validates the password field value
* @param value
@@ -96,7 +109,7 @@ export const isFormValid = (
if (key === 'country' && !configurableFormFields?.country?.displayValue) {
fieldErrors[key] = formatMessage(messages['empty.country.field.error']);
} else if (!configurableFormFields[key]) {
- fieldErrors[key] = fieldDescriptions[key].error_message;
+ fieldErrors[key] = normalizeErrorMessage(fieldDescriptions[key].error_message);
}
if (fieldErrors[key]) { isValid = false; }
});