diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx
index 897ff07b0..1420125ea 100644
--- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx
+++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx
@@ -19,6 +19,7 @@ import * as hooks from './hooks';
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
import ExpandableTextArea from '../../../../../sharedComponents/ExpandableTextArea';
import { answerRangeFormatRegex } from '../../../data/OLXParser';
+import { useValidateInputBlock } from '../../../data/apiHooks';
const AnswerOption = ({
answer,
@@ -32,7 +33,6 @@ const AnswerOption = ({
const isLibrary = useSelector(selectors.app.isLibrary);
const learningContextId = useSelector(selectors.app.learningContextId);
const blockId = useSelector(selectors.app.blockId);
-
const removeAnswer = hooks.removeAnswer({ answer, dispatch });
const setAnswer = hooks.setAnswer({ answer, hasSingleAnswer, dispatch });
const setAnswerTitle = hooks.setAnswerTitle({
@@ -44,6 +44,7 @@ const AnswerOption = ({
const setSelectedFeedback = hooks.setSelectedFeedback({ answer, hasSingleAnswer, dispatch });
const setUnselectedFeedback = hooks.setUnselectedFeedback({ answer, hasSingleAnswer, dispatch });
const { isFeedbackVisible, toggleFeedback } = hooks.useFeedback(answer);
+ const { data = { isValid: true }, mutate } = useValidateInputBlock();
const staticRootUrl = isLibrary
? `${getConfig().STUDIO_BASE_URL}/library_assets/blocks/${blockId}/`
@@ -69,17 +70,31 @@ const AnswerOption = ({
/>
);
}
+
if (problemType !== ProblemTypeKeys.NUMERIC || !answer.isAnswerRange) {
return (
-
+
+ {
+ setAnswerTitle(e);
+ if (problemType === ProblemTypeKeys.NUMERIC) {
+ mutate(e.target.value);
+ }
+ }}
+ placeholder={intl.formatMessage(messages.answerTextboxPlaceholder)}
+
+ />
+ {(!data?.isValid ?? true) && (
+
+
+
+ )}
+
);
}
// Return Answer Range View
diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.tsx b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.tsx
index a97a6998c..8afbea7a5 100644
--- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.tsx
+++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.tsx
@@ -3,6 +3,7 @@ import { render, screen, initializeMocks } from '@src/testUtils';
import { selectors } from '@src/editors/data/redux';
import AnswerOption from './AnswerOption';
import * as hooks from './hooks';
+import * as reactQueryHooks from '../../../data/apiHooks';
const { problem } = selectors;
@@ -101,4 +102,16 @@ describe('AnswerOption', () => {
expect(screen.getByText(answerRange.title)).toBeInTheDocument();
expect(screen.getByRole('textbox')).toBeInTheDocument();
});
+
+ test('shows numeric error feedback when data.isValid is false', () => {
+ // Mock useValidateInputBlock to simulate invalid state
+ // @ts-ignore-next-line
+ jest.spyOn(reactQueryHooks, 'useValidateInputBlock').mockReturnValue({ data: { isValid: false } });
+ jest.spyOn(problem, 'problemType').mockReturnValue('numericalresponse');
+ const myProps = { ...props, answer: { ...answerWithOnlyFeedback, isAnswerRange: false } };
+ render();
+ expect(
+ screen.getByText('Error: This input type only supports numeric answers. Did you mean to make a Text input or Math expression input problem?'),
+ ).toBeInTheDocument();
+ });
});
diff --git a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js
index d69bc876d..c0dcec252 100644
--- a/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js
+++ b/src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js
@@ -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;
diff --git a/src/editors/containers/ProblemEditor/data/apiHooks.test.tsx b/src/editors/containers/ProblemEditor/data/apiHooks.test.tsx
new file mode 100644
index 000000000..bc72c1f26
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/data/apiHooks.test.tsx
@@ -0,0 +1,69 @@
+import { renderHook, waitFor } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { camelCaseObject, getConfig } from '@edx/frontend-platform';
+import api from '@src/editors/data/services/cms/api';
+import { useValidateInputBlock } from './apiHooks';
+
+// Mock external dependencies
+jest.mock('@edx/frontend-platform');
+jest.mock('@src/editors/data/services/cms/api', () => ({
+ validateBlockNumericInput: jest.fn(),
+}));
+
+const mockedCamelCaseObject = jest.mocked(camelCaseObject);
+const mockedGetConfig = jest.mocked(getConfig);
+const mockedValidateBlockNumericInput = jest.mocked(api.validateBlockNumericInput);
+
+// Test wrapper component
+const createWrapper = () => {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false },
+ mutations: { retry: false },
+ },
+ });
+
+ const wrapper = ({ children }) => (
+
+ {children}
+
+ );
+ return wrapper;
+};
+
+describe('useValidateInputBlock', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockedGetConfig.mockReturnValue({
+ STUDIO_BASE_URL: 'http://studio.local.openedx.io:8001',
+ });
+ });
+
+ test('should return camelCase data on successful API call', async () => {
+ const mockResponse = {
+ data: { is_valid: true, result: 'success' },
+ } as any;
+ const mockCamelCaseResult = { isValid: true, result: 'success' };
+
+ mockedValidateBlockNumericInput.mockResolvedValue(Promise.resolve(mockResponse));
+ mockedCamelCaseObject.mockReturnValue(mockCamelCaseResult);
+
+ const { result } = renderHook(() => useValidateInputBlock(), {
+ wrapper: createWrapper(),
+ });
+
+ const testFormula = 'x + 1';
+ result.current.mutate(testFormula);
+
+ await waitFor(() => {
+ expect(result.current.isSuccess).toBe(true);
+ });
+
+ expect(mockedValidateBlockNumericInput).toHaveBeenCalledWith({
+ studioEndpointUrl: 'http://studio.local.openedx.io:8001',
+ data: { formula: testFormula },
+ });
+ expect(mockedCamelCaseObject).toHaveBeenCalledWith(mockResponse.data);
+ expect(result.current.data).toEqual({ isValid: true, result: 'success' });
+ });
+});
diff --git a/src/editors/containers/ProblemEditor/data/apiHooks.ts b/src/editors/containers/ProblemEditor/data/apiHooks.ts
new file mode 100644
index 000000000..610ffa847
--- /dev/null
+++ b/src/editors/containers/ProblemEditor/data/apiHooks.ts
@@ -0,0 +1,19 @@
+import { useMutation } from '@tanstack/react-query';
+import { camelCaseObject, getConfig } from '@edx/frontend-platform';
+import api from '@src/editors/data/services/cms/api';
+
+const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
+
+export const useValidateInputBlock = () => useMutation({
+ mutationFn: async (title : string) => {
+ try {
+ const res = await api.validateBlockNumericInput({ studioEndpointUrl: `${getApiBaseUrl()}`, data: { formula: title } });
+ return camelCaseObject(res.data);
+ } catch (err: any) {
+ return {
+ isValid: false,
+ error: err.response?.data?.error ?? 'Unknown error',
+ };
+ }
+ },
+});
diff --git a/src/editors/data/images/numericalInput.png b/src/editors/data/images/numericalInput.png
index 535631d3b..b4a285a5f 100644
Binary files a/src/editors/data/images/numericalInput.png and b/src/editors/data/images/numericalInput.png differ
diff --git a/src/editors/data/services/cms/api.ts b/src/editors/data/services/cms/api.ts
index ec4cd7d76..aceb01c76 100644
--- a/src/editors/data/services/cms/api.ts
+++ b/src/editors/data/services/cms/api.ts
@@ -390,6 +390,13 @@ export const apiMethods = {
}) => get(
urls.handlerUrl({ studioEndpointUrl, blockId, handlerName }),
),
+ validateBlockNumericInput: ({
+ studioEndpointUrl,
+ data,
+ }) => post(
+ urls.validateNumericInputUrl({ studioEndpointUrl }),
+ data,
+ ),
};
export default apiMethods;
diff --git a/src/editors/data/services/cms/urls.ts b/src/editors/data/services/cms/urls.ts
index 6da1e2203..28398055f 100644
--- a/src/editors/data/services/cms/urls.ts
+++ b/src/editors/data/services/cms/urls.ts
@@ -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/contentstore/v2/validate/numerical-input/`
+)) satisfies UrlFunction;