From cefff0b1e8bf7eac84e645a10eef123843636c73 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 24 Apr 2026 12:55:08 -0500 Subject: [PATCH 01/14] feat: add error message normalization utility --- .../components/ConfigurableRegistrationForm.jsx | 3 ++- src/register/data/utils.js | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index d132ed8b3..9d9db5d9f 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -7,6 +7,7 @@ import PropTypes from 'prop-types'; import { FormFieldRenderer } from '../../field-renderer'; import { FIELDS } from '../data/constants'; import messages from '../messages'; +import { normalizeErrorMessage } from '../data/utils'; 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/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; } }); From cd665bbd4f0f81e2054a916d24e15cec74bf3734 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 24 Apr 2026 13:00:21 -0500 Subject: [PATCH 02/14] feat: add placeholder support to FormFieldRenderer component --- src/field-renderer/FieldRenderer.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/field-renderer/FieldRenderer.jsx b/src/field-renderer/FieldRenderer.jsx index b1d77c95b..ac0fb63ad 100644 --- a/src/field-renderer/FieldRenderer.jsx +++ b/src/field-renderer/FieldRenderer.jsx @@ -57,6 +57,7 @@ const FormFieldRenderer = (props) => { as="textarea" name={fieldData.name} value={value} + placeholder={fieldData.placeholder} aria-invalid={isRequired && Boolean(errorMessage)} onChange={(e) => onChangeHandler(e)} floatingLabel={fieldData.label} @@ -79,6 +80,7 @@ const FormFieldRenderer = (props) => { className={className} name={fieldData.name} value={value} + placeholder={fieldData.placeholder} aria-invalid={isRequired && Boolean(errorMessage)} onChange={(e) => onChangeHandler(e)} floatingLabel={fieldData.label} @@ -140,6 +142,7 @@ FormFieldRenderer.propTypes = { type: PropTypes.string, label: PropTypes.string, name: PropTypes.string, + placeholder: PropTypes.string, options: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), }).isRequired, onChangeHandler: PropTypes.func.isRequired, From c00f19d830231e36d3690501d8123205ef540a25 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 24 Apr 2026 13:09:26 -0500 Subject: [PATCH 03/14] feat: enhance FormFieldRenderer with focus handling and help text feedback --- src/field-renderer/FieldRenderer.jsx | 50 ++++++++++++++++------------ 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/field-renderer/FieldRenderer.jsx b/src/field-renderer/FieldRenderer.jsx index ac0fb63ad..fa15c5120 100644 --- a/src/field-renderer/FieldRenderer.jsx +++ b/src/field-renderer/FieldRenderer.jsx @@ -1,21 +1,42 @@ -import { Form, Icon } from '@openedx/paragon'; +import { 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) { @@ -40,11 +61,7 @@ const FormFieldRenderer = (props) => { ))} - {isRequired && errorMessage && ( - - {errorMessage} - - )} + {errorFeedback} ); break; @@ -64,11 +81,8 @@ const FormFieldRenderer = (props) => { onBlur={handleOnBlur} onFocus={handleFocus} /> - {isRequired && errorMessage && ( - - {errorMessage} - - )} + {helpTextFeedback} + {errorFeedback} ); break; @@ -87,11 +101,8 @@ const FormFieldRenderer = (props) => { onBlur={handleOnBlur} onFocus={handleFocus} /> - {isRequired && errorMessage && ( - - {errorMessage} - - )} + {helpTextFeedback} + {errorFeedback} ); break; @@ -112,11 +123,7 @@ const FormFieldRenderer = (props) => { > {fieldData.label} - {isRequired && errorMessage && ( - - {errorMessage} - - )} + {errorFeedback} ); break; @@ -143,6 +150,7 @@ FormFieldRenderer.propTypes = { label: PropTypes.string, name: PropTypes.string, placeholder: PropTypes.string, + instructions: PropTypes.string, options: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), }).isRequired, onChangeHandler: PropTypes.func.isRequired, From e8b4d8b21b155ec009c864d9d3b05b27d51c211f Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Fri, 24 Apr 2026 13:12:06 -0500 Subject: [PATCH 04/14] feat: add maxLength restriction to FormFieldRenderer for improved input validation --- src/field-renderer/FieldRenderer.jsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/field-renderer/FieldRenderer.jsx b/src/field-renderer/FieldRenderer.jsx index fa15c5120..c44e9723f 100644 --- a/src/field-renderer/FieldRenderer.jsx +++ b/src/field-renderer/FieldRenderer.jsx @@ -75,6 +75,7 @@ const FormFieldRenderer = (props) => { name={fieldData.name} value={value} placeholder={fieldData.placeholder} + maxLength={fieldData.restrictions?.max_length} aria-invalid={isRequired && Boolean(errorMessage)} onChange={(e) => onChangeHandler(e)} floatingLabel={fieldData.label} @@ -95,6 +96,7 @@ const FormFieldRenderer = (props) => { name={fieldData.name} value={value} placeholder={fieldData.placeholder} + maxLength={fieldData.restrictions?.max_length} aria-invalid={isRequired && Boolean(errorMessage)} onChange={(e) => onChangeHandler(e)} floatingLabel={fieldData.label} @@ -151,6 +153,10 @@ FormFieldRenderer.propTypes = { name: PropTypes.string, placeholder: PropTypes.string, instructions: PropTypes.string, + restrictions: PropTypes.shape({ + max_length: PropTypes.number, + min_length: PropTypes.number, + }), options: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), }).isRequired, onChangeHandler: PropTypes.func.isRequired, From 33518ec21ec902409683c3da83cb6b371e8d0772 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 28 Apr 2026 16:44:28 -0500 Subject: [PATCH 05/14] fix: correct import statement --- src/register/components/ConfigurableRegistrationForm.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/register/components/ConfigurableRegistrationForm.jsx b/src/register/components/ConfigurableRegistrationForm.jsx index 9d9db5d9f..f5cf74ee5 100644 --- a/src/register/components/ConfigurableRegistrationForm.jsx +++ b/src/register/components/ConfigurableRegistrationForm.jsx @@ -6,8 +6,8 @@ import PropTypes from 'prop-types'; import { FormFieldRenderer } from '../../field-renderer'; import { FIELDS } from '../data/constants'; -import messages from '../messages'; import { normalizeErrorMessage } from '../data/utils'; +import messages from '../messages'; import { CountryField, HonorCode, TermsOfService } from '../RegistrationFields'; /** From 8e2ce98a2c261a01a15e8e394861315606ac1ef0 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 28 Apr 2026 16:22:32 -0500 Subject: [PATCH 06/14] test: add unit tests according changes --- .../tests/FieldRenderer.test.jsx | 148 ++++++++++++++++++ .../ConfigurableRegistrationForm.test.jsx | 31 ++++ src/register/data/tests/utils.test.js | 36 ++++- 3 files changed, 214 insertions(+), 1 deletion(-) diff --git a/src/field-renderer/tests/FieldRenderer.test.jsx b/src/field-renderer/tests/FieldRenderer.test.jsx index 3b38b8336..632838dcc 100644 --- a/src/field-renderer/tests/FieldRenderer.test.jsx +++ b/src/field-renderer/tests/FieldRenderer.test.jsx @@ -198,4 +198,152 @@ 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 show help text when field has focus and instructions are provided', () => { + 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 + 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); + + // Note: Select fields don't show help text in the current implementation + // This test documents the current behavior + }); }); diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 54d5288b5..538976bab 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -607,5 +607,36 @@ describe('ConfigurableRegistrationForm', () => { expect(professionErrorElement.textContent).toEqual(professionError); }); + + it('should normalize object-style error messages from backend', () => { + const professionErrorMessage = { required: 'Enter your profession' }; + + store = mockStore({ + ...initialState, + commonComponents: { + ...initialState.commonComponents, + fieldDescriptions: { + profession: { + name: 'profession', type: 'text', label: 'Profession', error_message: professionErrorMessage, + }, + }, + }, + }); + + const { getByLabelText, container } = render( + routerWrapper(reduxWrapper()), + ); + + 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; From dec7e87899fba1050b0999db5cf2f6c92e31e57f Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 28 Apr 2026 18:32:07 -0500 Subject: [PATCH 07/14] test: enhance ConfigurableRegistrationForm tests with third-party auth context setup --- .../ConfigurableRegistrationForm.test.jsx | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx index 538976bab..db343b0fe 100644 --- a/src/register/components/tests/ConfigurableRegistrationForm.test.jsx +++ b/src/register/components/tests/ConfigurableRegistrationForm.test.jsx @@ -610,21 +610,40 @@ describe('ConfigurableRegistrationForm', () => { it('should normalize object-style error messages from backend', () => { const professionErrorMessage = { required: 'Enter your profession' }; - - store = mockStore({ - ...initialState, - commonComponents: { - ...initialState.commonComponents, - fieldDescriptions: { - profession: { - name: 'profession', type: 'text', label: 'Profession', error_message: professionErrorMessage, - }, + 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(reduxWrapper()), + routerWrapper(renderWrapper()), ); const professionInput = getByLabelText('Profession'); From 225081f97c60dbce1517a6e7992c4358e0b41556 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 28 Apr 2026 18:38:35 -0500 Subject: [PATCH 08/14] test: update FieldRenderer tests to include async handling for help text visibility --- src/field-renderer/tests/FieldRenderer.test.jsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/field-renderer/tests/FieldRenderer.test.jsx b/src/field-renderer/tests/FieldRenderer.test.jsx index 632838dcc..b89e7c9e2 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'; @@ -259,7 +259,7 @@ describe('FieldRendererTests', () => { expect(input.maxLength).toEqual(200); }); - it('should show help text when field has focus and instructions are provided', () => { + it('should show help text when field has focus and instructions are provided', async () => { const fieldData = { type: 'text', label: 'Username', @@ -282,8 +282,10 @@ describe('FieldRendererTests', () => { // Blur the field fireEvent.blur(input); - // Help text should be hidden again - expect(container.textContent).not.toContain('Username must be between 2-30 characters'); + // 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', () => { From 8201d85ab625b1866a04f9d39b126dd38d770a4b Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 28 Apr 2026 18:46:12 -0500 Subject: [PATCH 09/14] refactor: update FieldRenderer to include help text feedback in form fields --- src/field-renderer/FieldRenderer.jsx | 46 +++++++++++++++------------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/field-renderer/FieldRenderer.jsx b/src/field-renderer/FieldRenderer.jsx index c44e9723f..d5580e062 100644 --- a/src/field-renderer/FieldRenderer.jsx +++ b/src/field-renderer/FieldRenderer.jsx @@ -56,13 +56,14 @@ const FormFieldRenderer = (props) => { onBlur={handleOnBlur} onFocus={handleFocus} > - - {fieldData.options.map(option => ( - - ))} - - {errorFeedback} - + + {fieldData.options.map(option => ( + + ))} + + {helpTextFeedback} + {errorFeedback} + ); break; } @@ -112,21 +113,22 @@ const FormFieldRenderer = (props) => { case 'checkbox': { formField = ( - onChangeHandler(e)} - onBlur={handleOnBlur} - onFocus={handleFocus} - > - {fieldData.label} - - {errorFeedback} - + onChangeHandler(e)} + onBlur={handleOnBlur} + onFocus={handleFocus} + > + {fieldData.label} + + {helpTextFeedback} + {errorFeedback} + ); break; } From 6c115cc13a98f15512d91304efcd16222c80ecb8 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 29 Apr 2026 10:41:28 -0500 Subject: [PATCH 10/14] refactor: enhance FieldRenderer to support options as both array and object formats --- src/field-renderer/FieldRenderer.jsx | 57 ++++++++++++++++------------ 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/src/field-renderer/FieldRenderer.jsx b/src/field-renderer/FieldRenderer.jsx index d5580e062..315ce0b8c 100644 --- a/src/field-renderer/FieldRenderer.jsx +++ b/src/field-renderer/FieldRenderer.jsx @@ -42,6 +42,10 @@ const FormFieldRenderer = (props) => { if (!fieldData.options) { return null; } + const optionsArray = Array.isArray(fieldData.options) + ? fieldData.options + : Object.entries(fieldData.options); + formField = ( { onBlur={handleOnBlur} onFocus={handleFocus} > - - {fieldData.options.map(option => ( - - ))} - - {helpTextFeedback} - {errorFeedback} - + + {optionsArray.map(option => ( + + ))} + + {helpTextFeedback} + {errorFeedback} + ); break; } @@ -113,22 +117,22 @@ const FormFieldRenderer = (props) => { case 'checkbox': { formField = ( - onChangeHandler(e)} - onBlur={handleOnBlur} - onFocus={handleFocus} - > - {fieldData.label} - - {helpTextFeedback} - {errorFeedback} - + onChangeHandler(e)} + onBlur={handleOnBlur} + onFocus={handleFocus} + > + {fieldData.label} + + {helpTextFeedback} + {errorFeedback} + ); break; } @@ -159,7 +163,10 @@ FormFieldRenderer.propTypes = { max_length: PropTypes.number, min_length: PropTypes.number, }), - options: PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), + options: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), + PropTypes.objectOf(PropTypes.string), + ]), }).isRequired, onChangeHandler: PropTypes.func.isRequired, handleBlur: PropTypes.func, From 7c81a22a14988b22b327a8d96e1827c90f4c51f4 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Wed, 29 Apr 2026 11:36:56 -0500 Subject: [PATCH 11/14] test: add tests for FieldRenderer to validate select field rendering and option handling --- .../tests/FieldRenderer.test.jsx | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/src/field-renderer/tests/FieldRenderer.test.jsx b/src/field-renderer/tests/FieldRenderer.test.jsx index b89e7c9e2..94a091e15 100644 --- a/src/field-renderer/tests/FieldRenderer.test.jsx +++ b/src/field-renderer/tests/FieldRenderer.test.jsx @@ -348,4 +348,135 @@ describe('FieldRendererTests', () => { // Note: Select fields don't show help text in the current implementation // This test documents the current behavior }); + + 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', + }, + }; + + const { container } = render(); + const select = container.querySelector('select#favorite-language-field'); + + fireEvent.change(select, { target: { value: 'python' } }); + expect(value).toEqual('python'); + + fireEvent.change(select, { target: { value: 'javascript' } }); + expect(value).toEqual('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(''); + }); }); From c6afbf4762d2be2a1159aefcb1cc81c00e6a964a Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Mon, 11 May 2026 09:39:48 -0500 Subject: [PATCH 12/14] chore: address pr review --- src/field-renderer/FieldRenderer.jsx | 5 ++-- .../tests/FieldRenderer.test.jsx | 29 ++++++++++++++----- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/field-renderer/FieldRenderer.jsx b/src/field-renderer/FieldRenderer.jsx index 315ce0b8c..3f0c357b6 100644 --- a/src/field-renderer/FieldRenderer.jsx +++ b/src/field-renderer/FieldRenderer.jsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import React, { useState } from 'react'; import { Form, Icon, TransitionReplace } from '@openedx/paragon'; import { ExpandMore } from '@openedx/paragon/icons'; @@ -27,7 +27,7 @@ const FormFieldRenderer = (props) => { {fieldData.instructions} - ) :
} + ) : } ); @@ -161,7 +161,6 @@ FormFieldRenderer.propTypes = { instructions: PropTypes.string, restrictions: PropTypes.shape({ max_length: PropTypes.number, - min_length: PropTypes.number, }), options: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.arrayOf(PropTypes.string)), diff --git a/src/field-renderer/tests/FieldRenderer.test.jsx b/src/field-renderer/tests/FieldRenderer.test.jsx index 94a091e15..9dd0aa4b3 100644 --- a/src/field-renderer/tests/FieldRenderer.test.jsx +++ b/src/field-renderer/tests/FieldRenderer.test.jsx @@ -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(''); }); @@ -345,8 +345,8 @@ describe('FieldRendererTests', () => { // Focus the field fireEvent.focus(select); - // Note: Select fields don't show help text in the current implementation - // This test documents the current behavior + // 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', () => { @@ -398,14 +398,27 @@ describe('FieldRendererTests', () => { }, }; - const { container } = render(); + 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(value).toEqual('python'); + + expect(mockChangeHandler).toHaveBeenCalled(); + expect(capturedValue).toBe('python'); fireEvent.change(select, { target: { value: 'javascript' } }); - expect(value).toEqual('javascript'); + + expect(mockChangeHandler).toHaveBeenCalledTimes(2); + expect(capturedValue).toBe('javascript'); }); it('should render all options correctly from object format', () => { @@ -476,7 +489,7 @@ describe('FieldRendererTests', () => { options: null, }; - const { container } = render( {}} />); + const { container } = render( { }} />); expect(container.innerHTML).toEqual(''); }); }); From 8cc7a24367e0192c774db6c7bb7b831fc59936d2 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 12 May 2026 13:52:49 -0500 Subject: [PATCH 13/14] feat: add minLength restriction to FormFieldRenderer for enhanced input validation --- src/field-renderer/FieldRenderer.jsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/field-renderer/FieldRenderer.jsx b/src/field-renderer/FieldRenderer.jsx index 3f0c357b6..cc5cd3cc7 100644 --- a/src/field-renderer/FieldRenderer.jsx +++ b/src/field-renderer/FieldRenderer.jsx @@ -80,6 +80,7 @@ const FormFieldRenderer = (props) => { 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)} @@ -101,6 +102,7 @@ const FormFieldRenderer = (props) => { 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)} @@ -160,6 +162,7 @@ FormFieldRenderer.propTypes = { placeholder: PropTypes.string, instructions: PropTypes.string, restrictions: PropTypes.shape({ + min_length: PropTypes.number, max_length: PropTypes.number, }), options: PropTypes.oneOfType([ From 74a266d550d4583c533cd8fa847b2e87fe26fe95 Mon Sep 17 00:00:00 2001 From: Bryann Valderrama Date: Tue, 12 May 2026 13:53:02 -0500 Subject: [PATCH 14/14] test: add tests for minLength restrictions in FieldRenderer --- .../tests/FieldRenderer.test.jsx | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/field-renderer/tests/FieldRenderer.test.jsx b/src/field-renderer/tests/FieldRenderer.test.jsx index 9dd0aa4b3..26ab2aaa7 100644 --- a/src/field-renderer/tests/FieldRenderer.test.jsx +++ b/src/field-renderer/tests/FieldRenderer.test.jsx @@ -259,6 +259,74 @@ describe('FieldRendererTests', () => { 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',