Skip to content

refactor: conditionally render Replace video button & fix redirection URLs #1915

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 29 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
70a53bc
fix: update navigation paths to include 'authoring' prefix
bra-i-am May 6, 2025
60fb036
feat: add waffleFlags state and actions for managing feature flags
bra-i-am May 7, 2025
6fb295f
feat: integrate waffle flags to conditionally render video upload button
bra-i-am May 7, 2025
fafc4da
fix: adjust order of return handlers in VideoEditorModal and VideoSet…
bra-i-am May 7, 2025
1f96592
feat: add waffle flag integration for new video uploads in VideoEdito…
bra-i-am May 7, 2025
1523a37
feat: update initialize actions to include fetchWaffleFlags and courseId
bra-i-am May 7, 2025
9095e88
fix: adjust formatting of JSX elements in VideoSettingsModal
bra-i-am May 7, 2025
9ede52d
fix: remove unused useSelector import in VideoSettingsModal
bra-i-am May 7, 2025
d0692b6
fix: reorder fetchWaffleFlags calls in initialize function for correc…
bra-i-am May 8, 2025
6e21b59
fix: update initialize test to fetch block and unit with correct test…
bra-i-am May 8, 2025
ff741ff
fix: remove redundant comments and streamline fetchWaffleFlags mock i…
bra-i-am May 8, 2025
582b10a
fix: remove unnecessary blank lines in app thunkActions tests for cle…
bra-i-am May 8, 2025
552fde4
fix: add mock for getApiWaffleFlagsUrl in CourseUnit tests
bra-i-am May 8, 2025
5265617
fix: correct import statement for getApiWaffleFlagsUrl and format waf…
bra-i-am May 8, 2025
62b84f9
test: add unit tests for VideoSettingsModal component
bra-i-am May 8, 2025
15daa7f
test: add unit tests for hooks module in VideoGallery
bra-i-am May 8, 2025
64c5f9d
test: add unit tests for hooks module in VideoGallery
bra-i-am May 8, 2025
fe88fa4
test: add unit tests for hooks module in VideoUploadEditor
bra-i-am May 8, 2025
76b1283
fix: remove mfe prefix from navigation path
bra-i-am Jun 3, 2025
bac374a
fix: update navigation paths in hooks tests to remove 'authoring' prefix
bra-i-am Jun 3, 2025
4fb2223
refactor: remove waffleFlags from state management and related selectors
bra-i-am Jun 16, 2025
d0bd3c2
test: integrate QueryClientProvider in VideoEditorModal tests
bra-i-am Jun 16, 2025
8f863b7
refactor: update VideoSettingsModal tests to use new rendering method…
bra-i-am Jun 18, 2025
f77c9dd
refactor: update onReturn prop type in VideoSettingsModal to remove n…
bra-i-am Jun 18, 2025
aac0e17
refactor: update import paths and enhance createStore mock in tests
bra-i-am Jun 18, 2025
6406f8f
refactor: adjust onSettingsReturn assignment order for clarity
bra-i-am Jun 18, 2025
785b306
test: enhance VideoEditorModal mock initialization
bra-i-am Jun 24, 2025
16ca603
refactor: change editorRender export to a const declaration
bra-i-am Jun 24, 2025
7fbd878
refactor: replace render with editorRender in VideoSettingsModal tests
bra-i-am Jun 24, 2025
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
8 changes: 8 additions & 0 deletions src/course-unit/CourseUnit.test.jsx
Original file line number Diff line number Diff line change
@@ -64,6 +64,8 @@ import sidebarMessages from './sidebar/messages';
import messages from './messages';
import { mockWaffleFlags } from '../data/apiHooks.mock';

import { getApiWaffleFlagsUrl } from '../data/api';

let axiosMock;
let store;
let queryClient;
@@ -158,6 +160,12 @@ describe('<CourseUnit />', () => {
axiosMock
.onGet(getContentTaxonomyTagsCountApiUrl(blockId))
.reply(200, 17);
axiosMock.onGet(getApiWaffleFlagsUrl()).reply(200, {
waffle_flags: {
'studio.enable_new_video_uploads_page': true,
'studio.enable_new_text_editor': false,
},
});
});

it('render CourseUnit component correctly', async () => {
Original file line number Diff line number Diff line change
@@ -1,87 +1,58 @@
import { render, waitFor, act } from '@testing-library/react';
import { configureStore } from '@reduxjs/toolkit';
import { MemoryRouter } from 'react-router-dom';
import { AppProvider } from '@edx/frontend-platform/react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { initializeMockApp } from '@edx/frontend-platform';
import { thunkActions } from '@src/editors/data/redux';
import { initializeMocks, waitFor, act } from '@src/testUtils';
import editorRender, { getEditorStore, PartialEditorState } from '@src/editors/editorTestRender';
import VideoEditorModal from './VideoEditorModal';
import { thunkActions } from '../../../data/redux';

jest.mock('../../../data/redux', () => ({
...jest.requireActual('../../../data/redux'),
thunkActions: {
video: {
loadVideoData: jest
.fn()
.mockImplementation(() => ({ type: 'MOCK_ACTION' })),
thunkActions.video.loadVideoData = jest.fn().mockImplementation(() => ({ type: 'MOCK_ACTION' }));

const initialState: PartialEditorState = {
app: {
videos: [],
learningContextId: 'course-v1:test+test+test',
blockId: 'some-block-id',
courseDetails: {},
},
requests: {
uploadAsset: { status: 'inactive', response: {} as any },
uploadTranscript: { status: 'inactive', response: {} as any },
deleteTranscript: { status: 'inactive', response: {} as any },
fetchVideos: { status: 'inactive', response: {} as any },
},
video: {
videoSource: '',
videoId: '',
fallbackVideos: ['', ''],
allowVideoDownloads: false,
allowVideoSharing: { level: 'block', value: false },
thumbnail: null,
transcripts: [],
selectedVideoTranscriptUrls: {},
allowTranscriptDownloads: false,
duration: {
startTime: '00:00:00',
stopTime: '00:00:00',
total: '00:00:00',
},
},
}));
};

describe('VideoUploader', () => {
let store;

beforeEach(async () => {
store = configureStore({
reducer: (state, action) => (action && action.newState ? action.newState : state),
preloadedState: {
app: {
videos: [],
learningContextId: 'course-v1:test+test+test',
blockId: 'some-block-id',
courseDetails: {},
},
requests: {
uploadAsset: { status: 'inactive' },
uploadTranscript: { status: 'inactive' },
deleteTranscript: { status: 'inactive' },
fetchVideos: { status: 'inactive' },
},
video: {
videoSource: '',
videoId: '',
fallbackVideos: ['', ''],
allowVideoDownloads: false,
allowVideoSharing: { level: 'block', value: false },
thumbnail: null,
transcripts: [],
transcriptHandlerUrl: '',
selectedVideoTranscriptUrls: {},
allowTranscriptDownloads: false,
duration: {
startTime: '00:00:00',
stopTime: '00:00:00',
total: '00:00:00',
},
},
},
});
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'test-user',
administrator: true,
roles: [],
},
});
initializeMocks();
});

const renderComponent = async () => render(
<AppProvider store={store} wrapWithRouter={false}>
<IntlProvider locale="en">
<MemoryRouter
initialEntries={[
'/some/path?selectedVideoId=id_1&selectedVideoUrl=https://video.com',
]}
>
<VideoEditorModal isLibrary={false} />
</MemoryRouter>
</IntlProvider>
</AppProvider>,
const renderComponent = () => editorRender(
<VideoEditorModal isLibrary={false} />,
{
routerProps: {
initialEntries: ['/some/path?selectedVideoId=id_1&selectedVideoUrl=https://video.com'],
},
initialState,
},
);

it('should render the component and call loadVideoData with correct parameters', async () => {
await renderComponent();
renderComponent();
await waitFor(() => {
expect(thunkActions.video.loadVideoData).toHaveBeenCalledWith(
'id_1',
@@ -91,10 +62,11 @@ describe('VideoUploader', () => {
});

it('should call loadVideoData again when isLoaded state changes', async () => {
await renderComponent();
renderComponent();
await waitFor(() => {
expect(thunkActions.video.loadVideoData).toHaveBeenCalledTimes(2);
expect(thunkActions.video.loadVideoData).toHaveBeenCalledTimes(1);
});
const store = getEditorStore();

act(() => {
store.dispatch({
@@ -110,7 +82,7 @@ describe('VideoUploader', () => {
});

await waitFor(() => {
expect(thunkActions.video.loadVideoData).toHaveBeenCalledTimes(3);
expect(thunkActions.video.loadVideoData).toHaveBeenCalledTimes(2);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { useWaffleFlags } from '@src/data/apiHooks';
import * as appHooks from '../../../hooks';
import { thunkActions, selectors } from '../../../data/redux';
import VideoSettingsModal from './VideoSettingsModal';
@@ -41,6 +42,7 @@ const VideoEditorModal: React.FC<Props> = ({
const isLoaded = useSelector(
(state) => selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchVideos }),
);
const { useNewVideoUploadsPage } = useWaffleFlags();

useEffect(() => {
hooks.initialize(dispatch, selectedVideoId, selectedVideoUrl);
@@ -51,6 +53,7 @@ const VideoEditorModal: React.FC<Props> = ({
onReturn: onSettingsReturn,
isLibrary,
onClose,
useNewVideoUploadsPage,
}}
/>
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import {
screen, fireEvent, initializeMocks,
} from '@src/testUtils';
import editorRender from '@src/editors/editorTestRender';
import VideoSettingsModal from '.';

const defaultProps = {
onReturn: jest.fn(),
isLibrary: false,
onClose: jest.fn(),
useNewVideoUploadsPage: true,
};

const renderComponent = (overrideProps = {}) => {
const customInitialState = {
app: {
videos: [],
learningContextId: 'course-v1:test+test+test',
blockId: 'some-block-id',
courseDetails: {},
},
};

initializeMocks();

return {
...editorRender(
<VideoSettingsModal {...defaultProps} {...overrideProps} />,
{ initialState: customInitialState },
),
};
};

describe('<VideoSettingsModal />', () => {
beforeEach(async () => {
window.scrollTo = jest.fn();
});

it('renders back button when useNewVideoUploadsPage is true and isLibrary is false', () => {
renderComponent();
expect(screen.getByRole('button', { name: /replace video/i })).toBeInTheDocument();
});

it('does not render back button when isLibrary is true', () => {
renderComponent({ isLibrary: true });
expect(screen.queryByRole('button', { name: /replace video/i })).not.toBeInTheDocument();
});

it('calls onReturn when back button is clicked', () => {
renderComponent();
fireEvent.click(screen.getByRole('button', { name: /replace video/i }));
expect(defaultProps.onReturn).toHaveBeenCalled();
});

it('calls onClose if onReturn is not provided', () => {
renderComponent({ onReturn: null });
fireEvent.click(screen.getByRole('button', { name: /replace video/i }));
expect(defaultProps.onClose).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -21,20 +21,22 @@ interface Props {
onReturn: () => void;
isLibrary: boolean;
onClose?: (() => void) | null;
useNewVideoUploadsPage?: boolean;
}

const VideoSettingsModal: React.FC<Props> = ({
onReturn,
isLibrary,
onClose,
useNewVideoUploadsPage,
}) => (
<>
{!isLibrary && (
{!isLibrary && useNewVideoUploadsPage && (
<Button
variant="link"
className="text-primary-500"
size="sm"
onClick={onClose || onReturn}
onClick={onReturn || onClose}
style={{
textDecoration: 'none',
marginLeft: '3px',
169 changes: 169 additions & 0 deletions src/editors/containers/VideoGallery/hooks.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { renderHook, act } from '@testing-library/react';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import thunk from 'redux-thunk';
import * as hooks from './hooks';
import * as appHooks from '../../hooks';
import { filterKeys } from './utils';

jest.mock('react-redux', () => {
const actual = jest.requireActual('react-redux');
return {
...actual,
useSelector: jest.fn((selectorFn) => selectorFn({
app: {
learningContextId: 'course-v1:id',
blockId: 'block-v1:id',
},
})),
};
});

jest.mock('../../hooks', () => ({
navigateTo: jest.fn(),
}));

const createStore = (customState = {}) => configureStore({
reducer: () => customState,
middleware: [thunk],
});

describe('hooks module', () => {
describe('useSearchAndSortProps', () => {
it('should update search string', () => {
const { result } = renderHook(() => hooks.useSearchAndSortProps());
act(() => {
result.current.onSearchChange({ target: { value: 'test' } });
});
expect(result.current.searchString).toBe('test');
});

it('should toggle hideSelectedVideos', () => {
const { result } = renderHook(() => hooks.useSearchAndSortProps());
expect(result.current.hideSelectedVideos).toBe(false);
act(() => {
result.current.onSwitchClick();
});
expect(result.current.hideSelectedVideos).toBe(true);
});
});

describe('filterListBySearch', () => {
it('filters videoList based on searchString', () => {
const filtered = hooks.filterListBySearch({
searchString: 'video',
videoList: [{ displayName: 'video 1' }, { displayName: 'other' }],
});
expect(filtered).toHaveLength(1);
});
});

describe('filterListByStatus', () => {
it('returns full list for anyStatus', () => {
const list = [{ status: 'uploading' }];
const result = hooks.filterListByStatus({ statusFilter: filterKeys.anyStatus, videoList: list });
expect(result).toEqual(list);
});

it('filters list by matching status', () => {
const list = [
{ status: filterKeys.uploading },
{ status: filterKeys.failed },
];
const result = hooks.filterListByStatus({ statusFilter: 'uploading', videoList: list });
expect(result).toHaveLength(1);
expect(result[0].status).toBe(filterKeys.uploading);
});
});

describe('getstatusBadgeVariant', () => {
it('returns correct variant for status', () => {
expect(hooks.getstatusBadgeVariant({ status: filterKeys.failed })).toBe('danger');
expect(hooks.getstatusBadgeVariant({ status: filterKeys.uploading })).toBe('light');
expect(hooks.getstatusBadgeVariant({ status: 'unknown' })).toBe(null);
});
});

describe('buildVideos', () => {
it('converts rawVideos into display format', () => {
jest.spyOn(hooks, 'getstatusBadgeVariant').mockReturnValue('light');
jest.spyOn(hooks, 'getStatusMessage').mockReturnValue('Uploading');

const rawVideos = {
one: {
edx_video_id: 'vid1',
client_video_id: 'Video 1',
course_video_image_url: 'img1.jpg',
created: '2024-01-01T00:00:00Z',
status_nontranslated: 'uploading',
duration: '10:00',
transcripts: [],
},
};
const result = hooks.buildVideos({ rawVideos });
expect(result[0].id).toBe('vid1');
expect(result[0].statusBadgeVariant).toBe('light');
expect(result[0].statusMessage).toBe('Uploading');
});
});

describe('useVideoListProps - selectBtnProps', () => {
const videos = [{ displayName: 'Test Video', status: 'ready' }];

beforeEach(() => {
jest.clearAllMocks();
});

const wrapper = ({ children }) => (
<Provider store={createStore()}>{children}</Provider>
);

it('navigates to video editor when a video is selected', async () => {
const { result } = renderHook(
() => hooks.useVideoListProps({
searchSortProps: {
searchString: '',
sortBy: 'dateNewest',
filterBy: filterKeys.anyStatus,
hideSelectedVideos: false,
},
videos,
}),
{ wrapper },
);

act(() => {
result.current.galleryProps.onHighlightChange({ target: { value: 'video123' } });
});

await act(async () => {
await result.current.selectBtnProps.onClick();
});

expect(appHooks.navigateTo).toHaveBeenCalledWith(
'/course/course-v1:id/editor/video/block-v1:id?selectedVideoId=video123',
);
});

it('sets showSelectVideoError to true if no video is selected', () => {
const { result } = renderHook(
() => hooks.useVideoListProps({
searchSortProps: {
searchString: '',
sortBy: 'dateNewest',
filterBy: filterKeys.anyStatus,
hideSelectedVideos: false,
},
videos,
}),
{ wrapper },
);

act(() => {
result.current.selectBtnProps.onClick();
});

expect(result.current.galleryError.show).toBe(true);
});
});
});
108 changes: 108 additions & 0 deletions src/editors/containers/VideoUploadEditor/hooks.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import * as hooks from './hooks';
import * as appHooks from '../../hooks';
import { thunkActions } from '../../data/redux';

jest.mock('../../data/store', () => ({
__esModule: true,
default: {},
}));

const mockState = {
app: {
learningContextId: 'course-v1:id',
blockId: 'block-v1:id',
},
};

jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: jest.fn(selector => selector(mockState)),
}));

jest.mock('../../hooks', () => ({
navigateTo: jest.fn(),
}));

jest.mock('../../data/redux', () => ({
thunkActions: {
video: {
uploadVideo: jest.fn(),
},
},
selectors: {
app: {
learningContextId: jest.fn(state => state.app.learningContextId),
blockId: jest.fn(state => state.app.blockId),
},
},
}));

describe('hooks module', () => {
beforeEach(() => {
jest.clearAllMocks();
});

describe('postUploadRedirect', () => {
it('returns a function that navigates to the correct URL', () => {
const storeState = {
app: {
learningContextId: 'course-v1:test',
blockId: 'block-123',
},
};
const redirectFn = hooks.postUploadRedirect(storeState);
redirectFn('test-video-url');

expect(appHooks.navigateTo).toHaveBeenCalledWith(
'/course/course-v1:test/editor/video/block-123?selectedVideoUrl=test-video-url',
);
});

it('uses custom uploadType in URL if provided', () => {
const storeState = {
app: {
learningContextId: 'course-v1:test',
blockId: 'block-123',
},
};
const redirectFn = hooks.postUploadRedirect(storeState, 'customType');
redirectFn('test-video-url');

expect(appHooks.navigateTo).toHaveBeenCalledWith(
'/course/course-v1:test/editor/video/block-123?customType=test-video-url',
);
});
});

describe('useUploadVideo', () => {
it('dispatches uploadVideo thunk with correct parameters', async () => {
const dispatch = jest.fn();
const supportedFiles = ['file1.mp4'];
const setLoadSpinner = jest.fn();
const postUploadRedirectFunction = jest.fn();

await hooks.useUploadVideo({
dispatch,
supportedFiles,
setLoadSpinner,
postUploadRedirectFunction,
});

expect(thunkActions.video.uploadVideo).toHaveBeenCalledWith({
supportedFiles,
setLoadSpinner,
postUploadRedirectFunction,
});
expect(dispatch).toHaveBeenCalled();
});
});

describe('useHistoryGoBack', () => {
it('returns a function that calls window.history.back', () => {
window.history.back = jest.fn();
const goBack = hooks.useHistoryGoBack();
goBack();
expect(window.history.back).toHaveBeenCalled();
});
});
});
5 changes: 5 additions & 0 deletions src/editors/data/redux/index.ts
Original file line number Diff line number Diff line change
@@ -25,6 +25,11 @@ const rootReducer = (state: any, action: any) => {
return editorReducer(undefined, action);
}

// For test purposes only:
if (action.type === 'UPDATE_STATE') {
return action.newState;
}

return editorReducer(state, action);
};

1 change: 1 addition & 0 deletions src/editors/data/redux/thunkActions/app.test.js
Original file line number Diff line number Diff line change
@@ -315,6 +315,7 @@ describe('app thunkActions', () => {
blockType: 'video',
blockId: 'block-v1:UniversityX+PHYS+1+type@problem+block@123',
learningContextId: 'course-v1:UniversityX+PHYS+1',
courseId: 'test-course-id',
};
thunkActions.initialize(data)(dispatch);
expect(dispatch.mock.calls).toEqual([
2 changes: 1 addition & 1 deletion src/editors/data/store.test.js
Original file line number Diff line number Diff line change
@@ -20,7 +20,7 @@ jest.mock('redux-logger', () => ({
jest.mock('redux-thunk', () => 'thunkMiddleware');
jest.mock('redux', () => ({
applyMiddleware: (...middleware) => ({ applied: middleware }),
createStore: (reducer, middleware) => ({ reducer, middleware }),
createStore: (reducer, preloadedState, middleware) => ({ reducer, preloadedState, middleware }),
}));
jest.mock('@redux-devtools/extension', () => ({
composeWithDevToolsLogOnlyInProduction: (middleware) => ({ withDevTools: middleware }),
3 changes: 2 additions & 1 deletion src/editors/data/store.ts
Original file line number Diff line number Diff line change
@@ -5,13 +5,14 @@ import { createLogger } from 'redux-logger';

import reducer, { actions, selectors, type EditorState } from './redux';

export const createStore = () => {
export const createStore = (preloadedState: EditorState | any = undefined) => {
const loggerMiddleware = createLogger();

const middleware = [thunkMiddleware, loggerMiddleware];

const store = redux.createStore<EditorState, any, any, any>(
reducer as any,
preloadedState,
composeWithDevToolsLogOnlyInProduction(redux.applyMiddleware(...middleware)),
);

28 changes: 0 additions & 28 deletions src/editors/editorTestRender.jsx

This file was deleted.

56 changes: 56 additions & 0 deletions src/editors/editorTestRender.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React from 'react';
import { Provider } from 'react-redux';
import { type Store } from 'redux';
import { render as baseRender, type RouteOptions } from '../testUtils';
import { EditorContextProvider } from './EditorContext';
import { createStore } from './data/store';
import { EditorState } from './data/redux';

/**
* Partial<EditorState> only allows top-level keys to be missing. This is an
* even more partial state that allows sub-keys to be missing.
*/
export type PartialEditorState = {
[P in keyof EditorState]?: Partial<EditorState[P]> | undefined;
};

interface Options {
learningContextId?: string;
initialState?: PartialEditorState;
}

let editorStore: Store<EditorState>;

/**
* Custom render function for testing React components with the editor context and Redux store.
*
* Wraps the provided UI in both the EditorContextProvider and Redux Provider,
* ensuring that components under test have access to the necessary context and store.
*
* @param {React.ReactElement} ui - The React element to render.
* @param options - Options
* @returns {RenderResult} The result of the render, as returned by RTL render.
*/
const editorRender = (ui, {
learningContextId = 'course-v1:Org+COURSE+RUN',
initialState = undefined,
...routerOptions
}: Options & RouteOptions = {}) => {
editorStore = createStore(initialState);
return baseRender(ui, {
extraWrapper: ({ children }) => (
<EditorContextProvider learningContextId={learningContextId}>
<Provider store={editorStore}>
{children}
</Provider>
</EditorContextProvider>
),
...routerOptions,
});
};

export function getEditorStore() {
return editorStore;
}

export default editorRender;