diff --git a/src/editors/containers/InVideoQuizEditor/index.jsx b/src/editors/containers/InVideoQuizEditor/index.jsx index fb918e1681..a842f499e5 100644 --- a/src/editors/containers/InVideoQuizEditor/index.jsx +++ b/src/editors/containers/InVideoQuizEditor/index.jsx @@ -62,9 +62,13 @@ export const InVideoQuizEditor = ({ const intl = useIntl(); const [settingsLoaded, setSettingsLoaded] = useState(false); const [saveError, setSaveError] = useState(null); + const [contentAlertDismissed, setContentAlertDismissed] = useState(false); const returnUrl = useSelector(selectors.app.returnUrl); const analytics = useSelector(selectors.app.analytics); + const hasNoVideos = blockFinished && settingsLoaded && videos.length === 0; + const hasNoProblems = blockFinished && settingsLoaded && problems.length === 0; + const isValidTimeFormat = useCallback((value) => /^\d+:[0-5]\d$/.test(value), []); const getValidationState = useCallback((item) => { @@ -207,6 +211,13 @@ export const InVideoQuizEditor = ({ const page = (
+ {(hasNoVideos || hasNoProblems) && !contentAlertDismissed && ( + setContentAlertDismissed(true)}> + {intl.formatMessage(messages.contentNotFoundTitle)} + {hasNoVideos &&
{intl.formatMessage(messages.noVideoFoundInUnit)}
} + {hasNoProblems &&
{intl.formatMessage(messages.noProblemFoundInUnit)}
} +
+ )} {saveError && ( setSaveError(null)}> {intl.formatMessage(messages.saveErrorTitle)} diff --git a/src/editors/containers/InVideoQuizEditor/index.test.jsx b/src/editors/containers/InVideoQuizEditor/index.test.jsx new file mode 100644 index 0000000000..371c695d4a --- /dev/null +++ b/src/editors/containers/InVideoQuizEditor/index.test.jsx @@ -0,0 +1,340 @@ +import React from 'react'; +import { screen, fireEvent, initializeMocks } from '@src/testUtils'; +import { editorRender } from '@src/editors/editorTestRender'; +import { thunkActions } from '@src/editors/data/redux'; +import ConnectedInVideoQuizEditor, { hooks } from './index'; + +// Mock thunks that make API calls. Must use jest.mock (hoisted) rather than +// jest.spyOn because mapDispatchToProps captures function references at module +// load time, before jest.spyOn would run. +jest.mock('../../data/redux/thunkActions/inVideoQuiz', () => { + const load = jest.fn(() => () => Promise.resolve()); + const save = jest.fn(() => () => Promise.resolve()); + return { + __esModule: true, + default: { loadInVideoQuizSettings: load, saveInVideoQuizSettings: save }, + loadInVideoQuizSettings: load, + saveInVideoQuizSettings: save, + }; +}); + +jest.mock('../EditorContainer', () => ({ + __esModule: true, + default: ({ children, onSave }) => ( +
+ + {children} +
+ ), +})); + +jest.mock('../../sharedComponents/Button', () => ({ + __esModule: true, + default: ({ + children, onClick, className, + }) => ( + + ), +})); + +jest.mock('../../hooks', () => ({ + navigateCallback: jest.fn(() => jest.fn()), +})); + +jest.mock('../../data/constants/analyticsEvt', () => ({ + editorSaveClick: 'editor_save_click', +})); + +const baseState = { + app: { + blockId: 'test-block-id', + blockValue: { + data: { + id: 'test-block-id', display_name: 'Test', data: '', metadata: {}, + }, + }, + }, + requests: { + fetchBlock: { status: 'completed' }, + }, + inVideoQuiz: { + selectedVideo: null, + videos: [], + problems: [], + quizItems: [ + { + id: 'quiz-1', problemId: '', time: '', jumpBack: '', + }, + ], + isDirty: false, + }, +}; + +describe('InVideoQuizEditor', () => { + beforeEach(() => { + initializeMocks(); + }); + + describe('Content not found alerts', () => { + it('shows both alerts when no videos and no problems exist in the unit', () => { + editorRender( + , + { initialState: baseState }, + ); + + expect(screen.getByText('Content not found')).toBeInTheDocument(); + expect(screen.getByText('No video found for this unit')).toBeInTheDocument(); + expect(screen.getByText('No problem found for this unit')).toBeInTheDocument(); + }); + + it('shows only video alert when no videos exist but problems do', () => { + editorRender( + , + { + initialState: { + ...baseState, + inVideoQuiz: { + ...baseState.inVideoQuiz, + problems: [{ id: 'problem-1', display_name: 'Problem 1' }], + }, + }, + }, + ); + + expect(screen.getByText('Content not found')).toBeInTheDocument(); + expect(screen.getByText('No video found for this unit')).toBeInTheDocument(); + expect(screen.queryByText('No problem found for this unit')).not.toBeInTheDocument(); + }); + + it('shows only problem alert when no problems exist but videos do', () => { + editorRender( + , + { + initialState: { + ...baseState, + inVideoQuiz: { + ...baseState.inVideoQuiz, + videos: [{ id: 'video-1', display_name: 'Video 1' }], + }, + }, + }, + ); + + expect(screen.getByText('Content not found')).toBeInTheDocument(); + expect(screen.queryByText('No video found for this unit')).not.toBeInTheDocument(); + expect(screen.getByText('No problem found for this unit')).toBeInTheDocument(); + }); + + it('does not show alert when both videos and problems exist', () => { + editorRender( + , + { + initialState: { + ...baseState, + inVideoQuiz: { + ...baseState.inVideoQuiz, + videos: [{ id: 'video-1', display_name: 'Video 1' }], + problems: [{ id: 'problem-1', display_name: 'Problem 1' }], + }, + }, + }, + ); + + expect(screen.queryByText('Content not found')).not.toBeInTheDocument(); + expect(screen.queryByText('No video found for this unit')).not.toBeInTheDocument(); + expect(screen.queryByText('No problem found for this unit')).not.toBeInTheDocument(); + }); + + it('does not show alert while still loading', () => { + editorRender( + , + { + initialState: { + ...baseState, + requests: { + fetchBlock: { status: 'pending' }, + }, + }, + }, + ); + + expect(screen.queryByText('Content not found')).not.toBeInTheDocument(); + expect(screen.queryByText('No video found for this unit')).not.toBeInTheDocument(); + expect(screen.queryByText('No problem found for this unit')).not.toBeInTheDocument(); + }); + + it('dismisses the alert when close button is clicked', () => { + editorRender( + , + { initialState: baseState }, + ); + + expect(screen.getByText('Content not found')).toBeInTheDocument(); + + const closeButton = screen.getByRole('button', { name: /dismiss/i }); + fireEvent.click(closeButton); + + expect(screen.queryByText('Content not found')).not.toBeInTheDocument(); + expect(screen.queryByText('No video found for this unit')).not.toBeInTheDocument(); + expect(screen.queryByText('No problem found for this unit')).not.toBeInTheDocument(); + }); + }); + + describe('Component Rendering', () => { + it('renders loading spinner when block not finished', () => { + const { container } = editorRender( + , + { + initialState: { + ...baseState, + requests: { + fetchBlock: { status: 'pending' }, + }, + }, + }, + ); + + expect(screen.getByTestId('editor-container')).toBeInTheDocument(); + expect(container.querySelector('.pgn__spinner')).toBeInTheDocument(); + }); + + it('renders editor form when block is finished', () => { + editorRender( + , + { + initialState: { + ...baseState, + inVideoQuiz: { + ...baseState.inVideoQuiz, + videos: [{ id: 'video-1', display_name: 'Video 1' }], + problems: [{ id: 'problem-1', display_name: 'Problem 1' }], + }, + }, + }, + ); + + expect(screen.getByText('Video')).toBeInTheDocument(); + expect(screen.getByText('Problem')).toBeInTheDocument(); + expect(screen.getByText('Time')).toBeInTheDocument(); + }); + + it('renders video options in the dropdown', () => { + editorRender( + , + { + initialState: { + ...baseState, + inVideoQuiz: { + ...baseState.inVideoQuiz, + videos: [ + { id: 'video-1', display_name: 'Intro Video' }, + { id: 'video-2', display_name: 'Lecture Video' }, + ], + problems: [{ id: 'problem-1', display_name: 'Problem 1' }], + }, + }, + }, + ); + + expect(screen.getByText('Intro Video')).toBeInTheDocument(); + expect(screen.getByText('Lecture Video')).toBeInTheDocument(); + }); + + it('renders problem options in the dropdown', () => { + editorRender( + , + { + initialState: { + ...baseState, + inVideoQuiz: { + ...baseState.inVideoQuiz, + videos: [{ id: 'video-1', display_name: 'Video 1' }], + problems: [ + { id: 'problem-1', display_name: 'Quiz Question 1' }, + { id: 'problem-2', display_name: 'Quiz Question 2' }, + ], + }, + }, + }, + ); + + expect(screen.getByText('Quiz Question 1')).toBeInTheDocument(); + expect(screen.getByText('Quiz Question 2')).toBeInTheDocument(); + }); + + it('adds a quiz item when Add problem button is clicked', () => { + const { container } = editorRender( + , + { initialState: baseState }, + ); + + const initialRows = container.querySelectorAll('.quiz-item-row').length; + fireEvent.click(screen.getByText('Add problem')); + const updatedRows = container.querySelectorAll('.quiz-item-row').length; + expect(updatedRows).toBe(initialRows + 1); + }); + + it('removes a quiz item when delete button is clicked', () => { + const { container } = editorRender( + , + { initialState: baseState }, + ); + + expect(container.querySelectorAll('.quiz-item-row').length).toBe(1); + const deleteButtons = screen.getAllByRole('button', { name: 'Delete problem' }); + fireEvent.click(deleteButtons[0]); + expect(container.querySelectorAll('.quiz-item-row').length).toBe(0); + }); + + it('calls loadInVideoQuizSettings on mount when block is finished', () => { + editorRender( + , + { initialState: baseState }, + ); + + expect(thunkActions.inVideoQuiz.loadInVideoQuizSettings).toHaveBeenCalled(); + }); + }); + + describe('hooks.getContent', () => { + it('filters out quiz items without problemId', () => { + const result = hooks.getContent({ + selectedVideo: 'video-1', + quizItems: [ + { id: '1', problemId: 'p1', time: '1:00' }, + { id: '2', problemId: '', time: '' }, + { id: '3', problemId: 'p3', time: '3:00' }, + ], + }); + + expect(result.selectedVideo).toBe('video-1'); + expect(result.quizItems).toHaveLength(2); + expect(result.quizItems[0].problemId).toBe('p1'); + expect(result.quizItems[1].problemId).toBe('p3'); + }); + + it('returns empty quizItems when none have problemId', () => { + const result = hooks.getContent({ + selectedVideo: null, + quizItems: [ + { id: '1', problemId: '', time: '' }, + ], + }); + + expect(result.quizItems).toHaveLength(0); + }); + }); +}); diff --git a/src/editors/containers/InVideoQuizEditor/messages.ts b/src/editors/containers/InVideoQuizEditor/messages.ts index d48b5ccb35..badd44bb02 100644 --- a/src/editors/containers/InVideoQuizEditor/messages.ts +++ b/src/editors/containers/InVideoQuizEditor/messages.ts @@ -121,6 +121,21 @@ const messages = defineMessages({ defaultMessage: 'Please select a problem for the entered time.', description: 'Validation error when timer is entered but problem is missing.', }, + contentNotFoundTitle: { + id: 'InVideoQuizEditor.contentNotFoundTitle', + defaultMessage: 'Content not found', + description: 'Alert heading when required content is missing from the unit.', + }, + noVideoFoundInUnit: { + id: 'InVideoQuizEditor.noVideoFoundInUnit', + defaultMessage: 'No video found for this unit', + description: 'Alert shown when no video components exist in the unit.', + }, + noProblemFoundInUnit: { + id: 'InVideoQuizEditor.noProblemFoundInUnit', + defaultMessage: 'No problem found for this unit', + description: 'Alert shown when no problem components exist in the unit.', + }, }); export default messages;