Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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({
Expand All @@ -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}/`
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
});
});
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;
69 changes: 69 additions & 0 deletions src/editors/containers/ProblemEditor/data/apiHooks.test.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 19 additions & 0 deletions src/editors/containers/ProblemEditor/data/apiHooks.ts
Original file line number Diff line number Diff line change
@@ -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({
Comment thread
bradenmacdonald marked this conversation as resolved.
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 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.
7 changes: 7 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,13 @@ export const apiMethods = {
}) => get(
urls.handlerUrl({ studioEndpointUrl, blockId, handlerName }),
),
validateBlockNumericInput: ({
studioEndpointUrl,
data,
}) => post(
urls.validateNumericInputUrl({ studioEndpointUrl }),
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/contentstore/v2/validate/numerical-input/`
)) satisfies UrlFunction;