Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const AnswerOption = ({
const dispatch = useDispatch();

const problemType = useSelector(selectors.problem.problemType);
const isNumericInputValid = useSelector(selectors.problem.isNumericInputValid);
const images = useSelector(selectors.app.images);
const isLibrary = useSelector(selectors.app.isLibrary);
const learningContextId = useSelector(selectors.app.learningContextId);
Expand Down Expand Up @@ -71,15 +72,23 @@ const AnswerOption = ({
}
if (problemType !== ProblemTypeKeys.NUMERIC || !answer.isAnswerRange) {
return (
<Form.Control
as="textarea"
className="answer-option-textarea text-gray-500 small"
autoResize
rows={1}
value={answer.title}
onChange={setAnswerTitle}
placeholder={intl.formatMessage(messages.answerTextboxPlaceholder)}
/>
<Form.Group isInvalid={!isNumericInputValid}>
<Form.Control
as="textarea"
className="answer-option-textarea text-gray-500 small"
autoResize
rows={1}
value={answer.title}
onChange={setAnswerTitle}
placeholder={intl.formatMessage(messages.answerTextboxPlaceholder)}

/>
{!isNumericInputValid && (
<Form.Control.Feedback type="invalid">
<FormattedMessage {...messages.answerNumericErrorText} />
</Form.Control.Feedback>
)}
</Form.Group>
);
}
// Return Answer Range View
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { StrictDict } from '../../../../../utils';
// should be re-thought and cleaned up to avoid this pattern.
// eslint-disable-next-line import/no-self-import
import * as module from './hooks';
import { actions } from '../../../../../data/redux';
import { actions, thunkActions } from '../../../../../data/redux';
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
import { fetchEditorContent } from '../hooks';

Expand All @@ -29,6 +29,17 @@ export const setAnswer = ({ answer, hasSingleAnswer, dispatch }) => (payload) =>
dispatch(actions.problem.updateAnswer({ id: answer.id, hasSingleAnswer, ...payload }));
};

export const validateInputBlock = ({
title, dispatch,
}) => {
if (!title) {
return;
}
dispatch(thunkActions.problem.validateBlockNumericInput({
title,
}));
};

export const setAnswerTitle = ({
answer,
hasSingleAnswer,
Expand All @@ -43,6 +54,11 @@ export const setAnswerTitle = ({
if (isDirty !== undefined) {
dispatch(actions.problem.setDirty(isDirty));
}

// For numeric problems, validate input on title change
if (problemType === ProblemTypeKeys.NUMERIC) {
validateInputBlock({ title, dispatch });
}
};

export const setSelectedFeedback = ({ answer, hasSingleAnswer, dispatch }) => (value) => {
Expand Down Expand Up @@ -106,5 +122,12 @@ export const useAnswerContainer = ({ answers, updateField }) => {
};

export default {
state, removeAnswer, setAnswer, setAnswerTitle, useFeedback, isSingleAnswerProblem, useAnswerContainer,
state,
removeAnswer,
setAnswer,
setAnswerTitle,
useFeedback,
isSingleAnswerProblem,
useAnswerContainer,
validateInputBlock,
};
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ const messages = defineMessages({
defaultMessage: 'Error: Invalid range format. Use brackets or parentheses with values separated by a comma.',
description: 'Error text describing wrong format of answer ranges',
},
answerNumericErrorText: {
id: 'authoring.answerwidget.answer.answerNumericErrorText',
defaultMessage: 'Error: This input type only supports numeric answers. Did you mean to make a Text input or Math expression input problem?',
description: 'Error message when user provides wrong format',
},
});

export default messages;
1 change: 0 additions & 1 deletion src/editors/containers/ProblemEditor/data/OLXParser.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ export const responseKeys = [
* []
*/
export const answerRangeFormatRegex = /^[([]\s*-?(?:\d+(?:\.\d+)?|\d+\/\d+)\s*,\s*-?(?:\d+(?:\.\d+)?|\d+\/\d+)\s*[)\]]$/m;

export const stripNonTextTags = ({ input, tag }) => {
const stripedTags = {};
Object.entries(input).forEach(([key, value]) => {
Expand Down
1 change: 1 addition & 0 deletions src/editors/data/constants/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ export const RequestKeys = StrictDict({
fetchAdvancedSettings: 'fetchAdvancedSettings',
fetchVideoFeatures: 'fetchVideoFeatures',
getHandlerUrl: 'getHandlerUrl',
validateBlockNumericInput: 'validateBlockNumericInput',
} as const);
Binary file modified src/editors/data/images/numericalInput.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/editors/data/redux/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export interface EditorState {
rawOLX: string;
rawMarkdown: string;
problemType: null | ProblemType | AdvancedProblemType;
isNumericInputValid: boolean;
/**
* Is the "markdown" editor currently active (as opposed to visual or advanced editors)
* This is confusingly named, and different from `isMarkdownEditorEnabledForContext`
Expand Down
1 change: 1 addition & 0 deletions src/editors/data/redux/problem/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const initialState: EditorState['problem'] = {
rawMarkdown: '',
isMarkdownEditorEnabled: false,
problemType: null,
isNumericInputValid: true,
question: '',
answers: [],
correctAnswerCount: 0,
Expand Down
1 change: 1 addition & 0 deletions src/editors/data/redux/problem/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export const simpleSelectors = {
defaultSettings: mkSimpleSelector(problemData => problemData.defaultSettings),
completeState: mkSimpleSelector(problemData => problemData),
isDirty: mkSimpleSelector(problemData => problemData.isDirty),
isNumericInputValid: mkSimpleSelector(problemData => problemData.isNumericInputValid),
};

export default simpleSelectors;
15 changes: 14 additions & 1 deletion src/editors/data/redux/thunkActions/problem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,19 @@ export const initializeProblem = (blockValue) => (dispatch, getState) => {
}
};

export const validateBlockNumericInput = ({ title, ...rest }) => (dispatch) => {
dispatch(requests.validateNumericInput({
title,
...rest,
onSuccess: (response) => {
dispatch(actions.problem.updateField({ isNumericInputValid: response.data.is_valid }));
},
onFailure: () => {
dispatch(actions.problem.updateField({ isNumericInputValid: false }));
},
}));
};

export default {
initializeProblem, switchEditor, switchToAdvancedEditor, fetchAdvancedSettings,
initializeProblem, switchEditor, switchToAdvancedEditor, fetchAdvancedSettings, validateBlockNumericInput,
};
16 changes: 16 additions & 0 deletions src/editors/data/redux/thunkActions/requests.js
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,21 @@ export const uploadVideo = ({ data, ...rest }) => (dispatch, getState) => {
}));
};

export const validateNumericInput = ({ title, ...rest }) => (dispatch, getState) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i remember here in authoring in some places they are now using react-query, if its a complete new implementation/endpoint/etc, maybe will be best to use react query from start to avoid the re-work when we move from redux to react-query 🤔

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for your comment, I did it this way because this page is really linked to redux and didn't wanna make a mix.
should I change this then to react-query?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not an expert here, but I think it could get complex to have a mix of Redux and RQ on the same page, so my inclination is to stay consistent and use Redux when we're just making small additions to existing things. Do you agree @diana-villalvazo-wgu or do you think it'd be better to put his part in RQ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It really depends on the separation of concerns. If this numeric input validation is a fairly isolated piece of code that checks some input value via an API and then displays a message, it's probably pretty easy to implement using React Query and mix-and-match is with redux. But if there are interactions between this validation state and other parts of the existing redux state, then it's probably more trouble than it's worth. Either way is fine with me.

Copy link
Contributor

@diana-villalvazo-wgu diana-villalvazo-wgu Nov 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 Same as Braden, i suggested it because i saw that it wasn't really interacting with other things, was a new endpoint call and just appended the result, and just to avoid future re-work, but if it causes any trouble im ok with it too

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks everyone for your comments. I've updated the PR to use react query instead of redux given that this validation is isolated.
I've made a file called "apiHooks.ts" inside ProblemEditors/Data folder given that the ProblemEditor wasn't using react query and to follow the same nomenclature as the other folders with react query.
the only thing that is left is the change in the API with Kyle's suggestion. I'll check that tomorrow.
thanks for your feedback

Copy link
Member

@kdmccormick kdmccormick Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jesusbalderramawgu I realized that my validation endpoint suggestion is flawed because it requires that a ProblemBlock be saved :)

I like your approach. I have some thoughts on the details--I'll leave a review tomorrow.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

regarding the new endpoint, I've left a review on: openedx/edx-platform#37677

dispatch(module.networkRequest({
requestKey: RequestKeys.validateBlockNumericInput,
promise: api.validateBlockNumericInput({
blockId: selectors.app.blockId(getState()),
blockType: selectors.app.blockType(getState()),
learningContextId: selectors.app.learningContextId(getState()),
data: { formula: title },
studioEndpointUrl: selectors.app.studioEndpointUrl(getState()),
title: selectors.app.blockTitle(getState()),
}),
...rest,
}));
};

export default StrictDict({
fetchBlock,
fetchStudioView,
Expand All @@ -507,4 +522,5 @@ export default StrictDict({
fetchVideoFeatures,
uploadVideo,
getHandlerlUrl,
validateNumericInput,
});
8 changes: 8 additions & 0 deletions src/editors/data/services/cms/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,14 @@ export const apiMethods = {
}) => get(
urls.handlerUrl({ studioEndpointUrl, blockId, handlerName }),
),
validateBlockNumericInput: ({
studioEndpointUrl,
blockId,
data,
}) => post(
urls.validateNumericInputUrl({ studioEndpointUrl, blockId }),
data,
),
};

export default apiMethods;
4 changes: 4 additions & 0 deletions src/editors/data/services/cms/urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,7 @@ export const courseVideos = (({ studioEndpointUrl, learningContextId }) => (
export const handlerUrl = (({ studioEndpointUrl, blockId, handlerName }) => (
`${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}/handler_url/${handlerName}/`
)) satisfies UrlFunction;

export const validateNumericInputUrl = (({ studioEndpointUrl }) => (
`${studioEndpointUrl}/api/courses/v1/validate/numerical-input/`
)) satisfies UrlFunction;
Loading