Skip to content

Commit 9072bb6

Browse files
feat: better validation for NumericalInput problem editor (#2615)
* feat(form): add validation to NumericalInput to accept only numeric values * style(format): fix spaces and update message to camelCase * fix(content): update text for clarity Co-authored-by: Kyle McCormick <kyle@kylemccormick.me> * feat(validation): validation added to numeric input with new endpoint to see if is a valid math expression * fix(content): change in input validation to use react query instead of redux * fix(content): change in types to avoid ci errors * fix(content): remove unnecessary code after changing to react query * fix(content): change numeric input validation path to new url and loader added * feat: returning data in camelcase, improve UI in validation * feat: tests added to problem editor --------- Co-authored-by: Kyle McCormick <kyle@kylemccormick.me>
1 parent 8b9f156 commit 9072bb6

8 files changed

Lines changed: 142 additions & 10 deletions

File tree

src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.jsx

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import * as hooks from './hooks';
1919
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
2020
import ExpandableTextArea from '../../../../../sharedComponents/ExpandableTextArea';
2121
import { answerRangeFormatRegex } from '../../../data/OLXParser';
22+
import { useValidateInputBlock } from '../../../data/apiHooks';
2223

2324
const AnswerOption = ({
2425
answer,
@@ -32,7 +33,6 @@ const AnswerOption = ({
3233
const isLibrary = useSelector(selectors.app.isLibrary);
3334
const learningContextId = useSelector(selectors.app.learningContextId);
3435
const blockId = useSelector(selectors.app.blockId);
35-
3636
const removeAnswer = hooks.removeAnswer({ answer, dispatch });
3737
const setAnswer = hooks.setAnswer({ answer, hasSingleAnswer, dispatch });
3838
const setAnswerTitle = hooks.setAnswerTitle({
@@ -44,6 +44,7 @@ const AnswerOption = ({
4444
const setSelectedFeedback = hooks.setSelectedFeedback({ answer, hasSingleAnswer, dispatch });
4545
const setUnselectedFeedback = hooks.setUnselectedFeedback({ answer, hasSingleAnswer, dispatch });
4646
const { isFeedbackVisible, toggleFeedback } = hooks.useFeedback(answer);
47+
const { data = { isValid: true }, mutate } = useValidateInputBlock();
4748

4849
const staticRootUrl = isLibrary
4950
? `${getConfig().STUDIO_BASE_URL}/library_assets/blocks/${blockId}/`
@@ -69,17 +70,31 @@ const AnswerOption = ({
6970
/>
7071
);
7172
}
73+
7274
if (problemType !== ProblemTypeKeys.NUMERIC || !answer.isAnswerRange) {
7375
return (
74-
<Form.Control
75-
as="textarea"
76-
className="answer-option-textarea text-gray-500 small"
77-
autoResize
78-
rows={1}
79-
value={answer.title}
80-
onChange={setAnswerTitle}
81-
placeholder={intl.formatMessage(messages.answerTextboxPlaceholder)}
82-
/>
76+
<Form.Group isInvalid={!data?.isValid ?? true}>
77+
<Form.Control
78+
as="textarea"
79+
className="answer-option-textarea text-gray-500 small"
80+
autoResize
81+
rows={1}
82+
value={answer.title}
83+
onChange={(e) => {
84+
setAnswerTitle(e);
85+
if (problemType === ProblemTypeKeys.NUMERIC) {
86+
mutate(e.target.value);
87+
}
88+
}}
89+
placeholder={intl.formatMessage(messages.answerTextboxPlaceholder)}
90+
91+
/>
92+
{(!data?.isValid ?? true) && (
93+
<Form.Control.Feedback type="invalid">
94+
<FormattedMessage {...messages.answerNumericErrorText} />
95+
</Form.Control.Feedback>
96+
)}
97+
</Form.Group>
8398
);
8499
}
85100
// Return Answer Range View

src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/AnswerOption.test.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { render, screen, initializeMocks } from '@src/testUtils';
33
import { selectors } from '@src/editors/data/redux';
44
import AnswerOption from './AnswerOption';
55
import * as hooks from './hooks';
6+
import * as reactQueryHooks from '../../../data/apiHooks';
67

78
const { problem } = selectors;
89

@@ -101,4 +102,16 @@ describe('AnswerOption', () => {
101102
expect(screen.getByText(answerRange.title)).toBeInTheDocument();
102103
expect(screen.getByRole('textbox')).toBeInTheDocument();
103104
});
105+
106+
test('shows numeric error feedback when data.isValid is false', () => {
107+
// Mock useValidateInputBlock to simulate invalid state
108+
// @ts-ignore-next-line
109+
jest.spyOn(reactQueryHooks, 'useValidateInputBlock').mockReturnValue({ data: { isValid: false } });
110+
jest.spyOn(problem, 'problemType').mockReturnValue('numericalresponse');
111+
const myProps = { ...props, answer: { ...answerWithOnlyFeedback, isAnswerRange: false } };
112+
render(<AnswerOption {...myProps} />);
113+
expect(
114+
screen.getByText('Error: This input type only supports numeric answers. Did you mean to make a Text input or Math expression input problem?'),
115+
).toBeInTheDocument();
116+
});
104117
});

src/editors/containers/ProblemEditor/components/EditProblemView/AnswerWidget/messages.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@ const messages = defineMessages({
8282
defaultMessage: 'Error: Invalid range format. Use brackets or parentheses with values separated by a comma.',
8383
description: 'Error text describing wrong format of answer ranges',
8484
},
85+
answerNumericErrorText: {
86+
id: 'authoring.answerwidget.answer.answerNumericErrorText',
87+
defaultMessage: 'Error: This input type only supports numeric answers. Did you mean to make a Text input or Math expression input problem?',
88+
description: 'Error message when user provides wrong format',
89+
},
8590
});
8691

8792
export default messages;
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { renderHook, waitFor } from '@testing-library/react';
2+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
3+
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
4+
import api from '@src/editors/data/services/cms/api';
5+
import { useValidateInputBlock } from './apiHooks';
6+
7+
// Mock external dependencies
8+
jest.mock('@edx/frontend-platform');
9+
jest.mock('@src/editors/data/services/cms/api', () => ({
10+
validateBlockNumericInput: jest.fn(),
11+
}));
12+
13+
const mockedCamelCaseObject = jest.mocked(camelCaseObject);
14+
const mockedGetConfig = jest.mocked(getConfig);
15+
const mockedValidateBlockNumericInput = jest.mocked(api.validateBlockNumericInput);
16+
17+
// Test wrapper component
18+
const createWrapper = () => {
19+
const queryClient = new QueryClient({
20+
defaultOptions: {
21+
queries: { retry: false },
22+
mutations: { retry: false },
23+
},
24+
});
25+
26+
const wrapper = ({ children }) => (
27+
<QueryClientProvider client={queryClient}>
28+
{children}
29+
</QueryClientProvider>
30+
);
31+
return wrapper;
32+
};
33+
34+
describe('useValidateInputBlock', () => {
35+
beforeEach(() => {
36+
jest.clearAllMocks();
37+
mockedGetConfig.mockReturnValue({
38+
STUDIO_BASE_URL: 'http://studio.local.openedx.io:8001',
39+
});
40+
});
41+
42+
test('should return camelCase data on successful API call', async () => {
43+
const mockResponse = {
44+
data: { is_valid: true, result: 'success' },
45+
} as any;
46+
const mockCamelCaseResult = { isValid: true, result: 'success' };
47+
48+
mockedValidateBlockNumericInput.mockResolvedValue(Promise.resolve(mockResponse));
49+
mockedCamelCaseObject.mockReturnValue(mockCamelCaseResult);
50+
51+
const { result } = renderHook(() => useValidateInputBlock(), {
52+
wrapper: createWrapper(),
53+
});
54+
55+
const testFormula = 'x + 1';
56+
result.current.mutate(testFormula);
57+
58+
await waitFor(() => {
59+
expect(result.current.isSuccess).toBe(true);
60+
});
61+
62+
expect(mockedValidateBlockNumericInput).toHaveBeenCalledWith({
63+
studioEndpointUrl: 'http://studio.local.openedx.io:8001',
64+
data: { formula: testFormula },
65+
});
66+
expect(mockedCamelCaseObject).toHaveBeenCalledWith(mockResponse.data);
67+
expect(result.current.data).toEqual({ isValid: true, result: 'success' });
68+
});
69+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { useMutation } from '@tanstack/react-query';
2+
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
3+
import api from '@src/editors/data/services/cms/api';
4+
5+
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
6+
7+
export const useValidateInputBlock = () => useMutation({
8+
mutationFn: async (title : string) => {
9+
try {
10+
const res = await api.validateBlockNumericInput({ studioEndpointUrl: `${getApiBaseUrl()}`, data: { formula: title } });
11+
return camelCaseObject(res.data);
12+
} catch (err: any) {
13+
return {
14+
isValid: false,
15+
error: err.response?.data?.error ?? 'Unknown error',
16+
};
17+
}
18+
},
19+
});
-4.5 KB
Loading

src/editors/data/services/cms/api.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,13 @@ export const apiMethods = {
390390
}) => get(
391391
urls.handlerUrl({ studioEndpointUrl, blockId, handlerName }),
392392
),
393+
validateBlockNumericInput: ({
394+
studioEndpointUrl,
395+
data,
396+
}) => post(
397+
urls.validateNumericInputUrl({ studioEndpointUrl }),
398+
data,
399+
),
393400
};
394401

395402
export default apiMethods;

src/editors/data/services/cms/urls.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,7 @@ export const courseVideos = (({ studioEndpointUrl, learningContextId }) => (
123123
export const handlerUrl = (({ studioEndpointUrl, blockId, handlerName }) => (
124124
`${studioEndpointUrl}/api/xblock/v2/xblocks/${blockId}/handler_url/${handlerName}/`
125125
)) satisfies UrlFunction;
126+
127+
export const validateNumericInputUrl = (({ studioEndpointUrl }) => (
128+
`${studioEndpointUrl}/api/contentstore/v2/validate/numerical-input/`
129+
)) satisfies UrlFunction;

0 commit comments

Comments
 (0)