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>
This commit is contained in:
@@ -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 (
|
||||
<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={!data?.isValid ?? true}>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
className="answer-option-textarea text-gray-500 small"
|
||||
autoResize
|
||||
rows={1}
|
||||
value={answer.title}
|
||||
onChange={(e) => {
|
||||
setAnswerTitle(e);
|
||||
if (problemType === ProblemTypeKeys.NUMERIC) {
|
||||
mutate(e.target.value);
|
||||
}
|
||||
}}
|
||||
placeholder={intl.formatMessage(messages.answerTextboxPlaceholder)}
|
||||
|
||||
/>
|
||||
{(!data?.isValid ?? true) && (
|
||||
<Form.Control.Feedback type="invalid">
|
||||
<FormattedMessage {...messages.answerNumericErrorText} />
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
// Return Answer Range View
|
||||
|
||||
@@ -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(<AnswerOption {...myProps} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
69
src/editors/containers/ProblemEditor/data/apiHooks.test.tsx
Normal file
69
src/editors/containers/ProblemEditor/data/apiHooks.test.tsx
Normal file
@@ -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 }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
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' });
|
||||
});
|
||||
});
|
||||
19
src/editors/containers/ProblemEditor/data/apiHooks.ts
Normal file
19
src/editors/containers/ProblemEditor/data/apiHooks.ts
Normal file
@@ -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',
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 2.6 KiB |
@@ -390,6 +390,13 @@ export const apiMethods = {
|
||||
}) => get(
|
||||
urls.handlerUrl({ studioEndpointUrl, blockId, handlerName }),
|
||||
),
|
||||
validateBlockNumericInput: ({
|
||||
studioEndpointUrl,
|
||||
data,
|
||||
}) => post(
|
||||
urls.validateNumericInputUrl({ studioEndpointUrl }),
|
||||
data,
|
||||
),
|
||||
};
|
||||
|
||||
export default apiMethods;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user