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;