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; } });