Compare commits

...

31 Commits

Author SHA1 Message Date
José Ignacio Palma
92c59cbf0c fix: advanced-settings api should not camel-case return value (backport) (#2087)
* fix: advanced-settings api should not camel-case return value (#1581)

* fix: update advanced module list not working (#2189)

Backend was still expecting `{'advanced_modules', {'value': ['poll', 'problem-builder', 'h5pxblock']}}` but without this change, it was receiving `{'advancedModules', ['poll', 'problem-builder', 'h5pxblock']}`

Follow up to https://github.com/openedx/frontend-app-authoring/pull/1581

---------

Co-authored-by: Muhammad Faraz Maqsood <fmaqsood@2u.com>
2025-06-19 09:06:31 -07:00
Arunmozhi
b6bd94c114 feat: add v2 CourseAuthoringUnitSidebarSlot (#2000) 2025-06-18 12:17:13 +05:30
Chris Chávez
c9896a8fe5 [Teak] fix: published name in unit sidebar in container picker & Issues on Inplace Editor (#2140)
Backport of fix: show unit published name in sidebar on content picker [FC-0090] #2100 
Backport of fix: Issue on the Inplace editor [FC-0090] #2101
2025-06-17 19:58:57 -05:00
bydawen
4ba8cde587 fix: (backport) text truncate issue in the search modal (#2151) 2025-06-16 14:43:02 -07:00
Diana Villalvazo
86d0a7e7db fix: remove icon and empty breadcrumb from libraries (#2129) (#2133) 2025-06-12 14:43:18 -07:00
Braden MacDonald
1968d146cd fix: (backport) enable markdown editor in libraries (#2098)
* fix: enable markdown editor for problems in libraries too

This fix is also achieved on master via 5991fd3997 / https://github.com/openedx/frontend-app-authoring/pull/2068 but this is a simpler fix, not a direct backport of that refactor.

* fix: remove duplicate markdown_edited save request (#2127)

Removes the unnecessary duplicate save  request of markdown_edited
value to the backend.

Part of: https://github.com/openedx/frontend-app-authoring/issues/2099
Backports: 62589aea50

---------

Co-authored-by: Muhammad Anas <88967643+Anas12091101@users.noreply.github.com>
2025-06-12 09:16:53 -07:00
Ihor Romaniuk
3e737b5b0d fix: (backport) remove an extra editing xblock modal on unit page (#2111) (#2130) 2025-06-11 13:25:47 -07:00
diana-villalvazo-wgu
fcdf1fdecb fix: files & uploads menu was truncated due to overflow-x (#2071) (#2077) 2025-06-05 19:41:21 +05:30
Victor Navarro
efb1a28b4d fix: Expand all now expands subsections (#2085) 2025-06-05 09:35:41 -03:00
Muhammad Anas
1ff5e5bdae fix: markdown editor issues in modal (#2076)
This PR resolves rendering issues with the Markdown editor inside the modal.
The problem began after a PR [1] introduced the use of modals for the editor.
The EditorPage [2] component expects a `isMarkdownEditorEnabledForCourse` prop,
which was missing in that implementation.

[1] https://github.com/openedx/frontend-app-authoring/pull/1838 
[2] https://github.com/openedx/frontend-app-authoring/pull/1838/files#diff-147218ef88726880178ea895988a5d3feaf2c0c4459086a8de7a4080cbe37de7R226

Backports https://github.com/openedx/frontend-app-authoring/pull/2074
2025-06-04 12:59:24 -04:00
Tony Busa
19ef80553a fix: backport changes for html button in text component markdown editor (#2065) 2025-06-04 17:51:05 +05:30
Rômulo Penido
2beb91c63b fix: set unit preview readonly on sidebar (#2008) (#2059)
Make the unit preview on the sidebar read-only and add `Truncate` to the `InplaceTextEditor`
2025-06-02 12:11:58 -05:00
Rômulo Penido
d325a92204 fix: selection card wiggle (#2047) 2025-05-29 14:06:35 -05:00
Jillian
7dfd93d4f1 fix: upstreamInfo is not always provided (#2041) (#2042)
(cherry picked from commit 3fc0f27d67)
2025-05-29 13:15:01 -05:00
Jillian
e34df7f270 fix: set maxHeight on TextEditor TinyMce widget [FC-0090] (#2024) (#2030)
Sets a max_height=500px for the TinyMCE editor when editing a Text/Html component.
This prevents the autoresize plugin from expanding the editor textarea beyond the bounds of the editor modal.

⚠️ Because the max height can only be a numeric pixel value, we can't use clever settings like vh or %, and so we're forced to limit the height of the editor to a fixed size for all screen sizes in order to address this issue.

(cherry picked from commit c5f7d0cf3b)
2025-05-26 13:05:48 -05:00
Jillian
317bc757cf fix: refresh xblock inline after accepting/rejecting library sync (#2022) (#2028)
Instead of reloading the entire Unit after syncing changes from the
library, just reload the xblock that was changed.

(cherry picked from commit ac5574d2c4)
2025-05-23 14:03:57 -05:00
Chris Chávez
212a54f76e [Teak] fix: Inconsistent publish status filter menu placement & fix: Remove never published filter from component picker (#2021)
* fix: Inconsistent publish status filter menu placement (#1966)

* fix: Remove never published filter from component picker (#1947)

Removes the never-published filter option from the component picker and unit picker.
2025-05-22 10:22:53 -05:00
Daniel Valenzuela
944d1316ad fix: do open editor of new xblock when duplicating (#2017)
* feat: display editors as modals  (#1838)

* fix: do open editor of new xblock when duplicating (#1887)

Fixes bug where after duplicating an xblock, the editor modal of the old xblock is being open instead of the new copied xblock.
2025-05-22 10:04:35 -05:00
Rômulo Penido
dd731a0d19 fix: rename library publish button (#2015) 2025-05-21 18:18:26 -05:00
Rômulo Penido
976dfcaab7 fix: change InplaceTextEditor style and add optimistic update (#1953) (#2014)
* Optimistic update for renaming Components, Collections and Containers
* Change the InplaceTextEditor to show the new text until the onSave promise resolves
* Change the InplaceTextEditor style to: Always show the rename button
2025-05-21 17:33:23 -05:00
Navin Karkera
403dfa1e6b [Teak] backport #1949, #1999 and #2002 (#2006)
* feat: select component and show sidebar on edit  (#1949)

Select component that is being edited in library and show its sidebar. Also fixes issue with children component listing in library unit page

(cherry picked from commit 08ac1c0c4d)

* fix: search text flickering (#1999)

Fix flickering issue in search field.

(cherry picked from commit 6f3b7ab962)

* feat: open collection or unit page on double click only (#2002)

Opens collection or unit page only on double click.

(cherry picked from commit 503642be8c)
2025-05-21 17:20:16 -05:00
Navin Karkera
1919eb4845 fix: search modal refresh on typing (#1938) (#1948) 2025-05-14 13:15:24 -05:00
Chris Chávez
3d6e221f99 fix: Issue with read-only units in libraries & published version of units in library units picker (#1940)
Fixes the issues from https://github.com/openedx/frontend-app-authoring/issues/1633#issuecomment-2828953801

* In successfully added units, the "add new component" widget appears sometimes
* In the "add existing unit" modal, the preview shows draft versions of units
2025-05-13 12:53:32 -05:00
Rômulo Penido
fab786a6c6 fix: review/sync bugs [FC-0083] (#1905) (#1941)
Fixes issues related to component libraries' review/sync flow

* Inconsistent sync pane title versions
* Library content shown in preview warning only appears in review changes modal when that modal is opened from the review tab
* Some new changes only appear within library review tab on scroll at top of list
* Vertically misaligned sync icon in review changes message on course outline
* Show available updates whenever content is updated, regardless of number of updates available
2025-05-12 14:42:34 -05:00
Rômulo Penido
a162929fd7 fix: improve focus/selected style on library authoring (#1918) (#1930)
Improves the focus and selected styles from the LibraryPage and UnitPage.
2025-05-12 12:05:28 -05:00
Jillian
6c4634ebbe fix: invalidate search results when publishing all changes in library (#1925) (#1927)
(cherry picked from commit cdb8016657)

Co-authored-by: Braden MacDonald <braden@opencraft.com>
2025-05-09 11:03:58 -05:00
Navin Karkera
79f865b328 fix: UX issues in unit page (#1913) (#1923)
Fixes the following issues:

* Selection behavior
* Component selection is by header click only
* Newly created blocks within a unit should be selected on creation/save, appear selected, and have their sidebar open
* Some long text components seem to display at the default height rather than a longer height
* Within the full-page unit view, the "add to collection" overflow menu item on components does not seem to work/only opens the sidebar.
* Draft status indicator text is not vertically centered with icon
* When reordering, dragging a short component past a long component often causes a strange stutter effect.
* When dragging to reorder a component, moving quickly or scrolling often causes the drag handle to be lost / causes the block to jump somewhere else
* Reordering may not consistently support a keyboard-accessible option to change order, like in course authoring
* Tag button on component header opens the old tag side pane

(cherry picked from commit 8c3fab3792)
2025-05-08 10:15:44 -05:00
Rômulo Penido
d5e36cf2b8 fix: unit pages ux bugs [FC-0083] (#1884) (#1916)
This PR fixes some UX bugs related to the unit pages:

* Sort for "recently modified" on unit tab does not update after adding new components to units
* Change component delete warning message

It's a backport of https://github.com/openedx/frontend-app-authoring/pull/1884
2025-05-07 17:39:55 -05:00
Ihor Romaniuk
8ffafc094f fix: manage access modal on duplicated xblock (#1874) 2025-05-07 15:40:34 -03:00
Jillian
b375806fd2 perf: use Library search results to populate container card preview [FC-0083] [TEAK] (#1889)
* fix: several library unit page UX bugs (#1868)

* fix: rename "Organize" tab to "Manage"

* fix: duplicate key warnings

* fix: uniform messages while adding to collection

* fix: do not allow units be added to a unit

(cherry picked from commit 0fdc460c5b)

* perf: use Library search results to populate container card preview (#1820)

* fix: use Library search results to populate container card preview

* feat: show published children when showing only published Unit content

* fix: nits

(cherry picked from commit 24e469542d)

---------

Co-authored-by: Rômulo Penido <romulo.penido@gmail.com>
2025-05-02 10:18:20 -07:00
Navin Karkera
ab0e0d71c1 refactor: remove custom order function from course libraries list (#1865) (#1888)
(cherry picked from commit bc18fffedf)
2025-05-01 15:28:09 -07:00
148 changed files with 2922 additions and 1617 deletions

View File

@@ -10,4 +10,5 @@ coverage:
threshold: 0% threshold: 0%
ignore: ignore:
- "src/grading-settings/grading-scale/react-ranger.js" - "src/grading-settings/grading-scale/react-ranger.js"
- "src/generic/DraggableList/verticalSortableList.ts"
- "src/index.js" - "src/index.js"

View File

@@ -1,5 +1,10 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform'; /* eslint-disable import/prefer-default-export */
import {
camelCaseObject,
getConfig,
} from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { camelCase } from 'lodash';
import { convertObjectToSnakeCase } from '../../utils'; import { convertObjectToSnakeCase } from '../../utils';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
@@ -14,7 +19,19 @@ const getProctoringErrorsApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v1/
export async function getCourseAdvancedSettings(courseId) { export async function getCourseAdvancedSettings(courseId) {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`); .get(`${getCourseAdvancedSettingsApiUrl(courseId)}?fetch_all=0`);
return camelCaseObject(data); const keepValues = {};
Object.keys(data).forEach((key) => {
keepValues[camelCase(key)] = { value: data[key].value };
});
const formattedData = {};
const formattedCamelCaseData = camelCaseObject(data);
Object.keys(formattedCamelCaseData).forEach((key) => {
formattedData[key] = {
...formattedCamelCaseData[key],
value: keepValues[key]?.value,
};
});
return formattedData;
} }
/** /**
@@ -26,7 +43,19 @@ export async function getCourseAdvancedSettings(courseId) {
export async function updateCourseAdvancedSettings(courseId, settings) { export async function updateCourseAdvancedSettings(courseId, settings) {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings)); .patch(`${getCourseAdvancedSettingsApiUrl(courseId)}`, convertObjectToSnakeCase(settings));
return camelCaseObject(data); const keepValues = {};
Object.keys(data).forEach((key) => {
keepValues[camelCase(key)] = { value: data[key].value };
});
const formattedData = {};
const formattedCamelCaseData = camelCaseObject(data);
Object.keys(formattedCamelCaseData).forEach((key) => {
formattedData[key] = {
...formattedCamelCaseData[key],
value: keepValues[key]?.value,
};
});
return formattedData;
} }
/** /**
@@ -36,5 +65,17 @@ export async function updateCourseAdvancedSettings(courseId, settings) {
*/ */
export async function getProctoringExamErrors(courseId) { export async function getProctoringExamErrors(courseId) {
const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`); const { data } = await getAuthenticatedHttpClient().get(`${getProctoringErrorsApiUrl()}${courseId}`);
return camelCaseObject(data); const keepValues = {};
Object.keys(data).forEach((key) => {
keepValues[camelCase(key)] = { value: data[key].value };
});
const formattedData = {};
const formattedCamelCaseData = camelCaseObject(data);
Object.keys(formattedCamelCaseData).forEach((key) => {
formattedData[key] = {
...formattedCamelCaseData[key],
value: keepValues[key]?.value,
};
});
return formattedData;
} }

View File

@@ -0,0 +1,236 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
getCourseAdvancedSettings,
updateCourseAdvancedSettings,
getProctoringExamErrors,
} from './api';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
describe('courseSettings API', () => {
const mockHttpClient = {
get: jest.fn(),
patch: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
getAuthenticatedHttpClient.mockReturnValue(mockHttpClient);
});
describe('getCourseAdvancedSettings', () => {
it('should fetch and unformat course advanced settings', async () => {
const fakeData = {
key_snake_case: {
display_name: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
PascalCase: 'To come camelCase',
'kebab-case': 'To come camelCase',
UPPER_CASE: 'To come camelCase',
lowercase: 'This key must not be formatted',
UPPERCASE: 'To come lowercase',
'Title Case': 'To come camelCase',
'dot.case': 'To come camelCase',
SCREAMING_SNAKE_CASE: 'To come camelCase',
MixedCase: 'To come camelCase',
'Train-Case': 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
// value is an object with various cases
// this contain must not be formatted to camelCase
value: {
snake_case: 'snake_case',
camelCase: 'camelCase',
PascalCase: 'PascalCase',
'kebab-case': 'kebab-case',
UPPER_CASE: 'UPPER_CASE',
lowercase: 'lowercase',
UPPERCASE: 'UPPERCASE',
'Title Case': 'Title Case',
'dot.case': 'dot.case',
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
MixedCase: 'MixedCase',
'Train-Case': 'Train-Case',
nestedOption: {
anotherOption: 'nestedContent',
},
},
},
};
const expected = {
keySnakeCase: {
displayName: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
pascalCase: 'To come camelCase',
kebabCase: 'To come camelCase',
upperCase: 'To come camelCase',
lowercase: 'This key must not be formatted',
uppercase: 'To come lowercase',
titleCase: 'To come camelCase',
dotCase: 'To come camelCase',
screamingSnakeCase: 'To come camelCase',
mixedCase: 'To come camelCase',
trainCase: 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
value: fakeData.key_snake_case.value,
},
};
mockHttpClient.get.mockResolvedValue({ data: fakeData });
const result = await getCourseAdvancedSettings('course-v1:Test+T101+2024');
expect(mockHttpClient.get).toHaveBeenCalledWith(
`${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024?fetch_all=0`,
);
expect(result).toEqual(expected);
});
});
describe('updateCourseAdvancedSettings', () => {
it('should update and unformat course advanced settings', async () => {
const fakeData = {
key_snake_case: {
display_name: 'To come camelCase',
testCamelCase: 'This key must not be formatted', // because already be camelCase
PascalCase: 'To come camelCase',
'kebab-case': 'To come camelCase',
UPPER_CASE: 'To come camelCase',
lowercase: 'This key must not be formatted', // because camelCase in lowercase not formatted
UPPERCASE: 'To come lowercase', // because camelCase in UPPERCASE format to lowercase
'Title Case': 'To come camelCase',
'dot.case': 'To come camelCase',
SCREAMING_SNAKE_CASE: 'To come camelCase',
MixedCase: 'To come camelCase',
'Train-Case': 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
// value is an object with various cases
// this contain must not be formatted to camelCase
value: {
snake_case: 'snake_case',
camelCase: 'camelCase',
PascalCase: 'PascalCase',
'kebab-case': 'kebab-case',
UPPER_CASE: 'UPPER_CASE',
lowercase: 'lowercase',
UPPERCASE: 'UPPERCASE',
'Title Case': 'Title Case',
'dot.case': 'dot.case',
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
MixedCase: 'MixedCase',
'Train-Case': 'Train-Case',
nestedOption: {
anotherOption: 'nestedContent',
},
},
},
};
const expected = {
keySnakeCase: {
displayName: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
pascalCase: 'To come camelCase',
kebabCase: 'To come camelCase',
upperCase: 'To come camelCase',
lowercase: 'This key must not be formatted',
uppercase: 'To come lowercase',
titleCase: 'To come camelCase',
dotCase: 'To come camelCase',
screamingSnakeCase: 'To come camelCase',
mixedCase: 'To come camelCase',
trainCase: 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
value: fakeData.key_snake_case.value,
},
};
mockHttpClient.patch.mockResolvedValue({ data: fakeData });
const result = await updateCourseAdvancedSettings('course-v1:Test+T101+2024', {});
expect(mockHttpClient.patch).toHaveBeenCalledWith(
`${process.env.STUDIO_BASE_URL}/api/contentstore/v0/advanced_settings/course-v1:Test+T101+2024`,
{},
);
expect(result).toEqual(expected);
});
});
describe('getProctoringExamErrors', () => {
it('should fetch proctoring errors and return unformat object', async () => {
const fakeData = {
key_snake_case: {
display_name: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
PascalCase: 'To come camelCase',
'kebab-case': 'To come camelCase',
UPPER_CASE: 'To come camelCase',
lowercase: 'This key must not be formatted',
UPPERCASE: 'To come lowercase',
'Title Case': 'To come camelCase',
'dot.case': 'To come camelCase',
SCREAMING_SNAKE_CASE: 'To come camelCase',
MixedCase: 'To come camelCase',
'Train-Case': 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
// value is an object with various cases
// this contain must not be formatted to camelCase
value: {
snake_case: 'snake_case',
camelCase: 'camelCase',
PascalCase: 'PascalCase',
'kebab-case': 'kebab-case',
UPPER_CASE: 'UPPER_CASE',
lowercase: 'lowercase',
UPPERCASE: 'UPPERCASE',
'Title Case': 'Title Case',
'dot.case': 'dot.case',
SCREAMING_SNAKE_CASE: 'SCREAMING_SNAKE_CASE',
MixedCase: 'MixedCase',
'Train-Case': 'Train-Case',
nestedOption: {
anotherOption: 'nestedContent',
},
},
},
};
const expected = {
keySnakeCase: {
displayName: 'To come camelCase',
testCamelCase: 'This key must not be formatted',
pascalCase: 'To come camelCase',
kebabCase: 'To come camelCase',
upperCase: 'To come camelCase',
lowercase: 'This key must not be formatted',
uppercase: 'To come lowercase',
titleCase: 'To come camelCase',
dotCase: 'To come camelCase',
screamingSnakeCase: 'To come camelCase',
mixedCase: 'To come camelCase',
trainCase: 'To come camelCase',
nestedOption: {
anotherOption: 'To come camelCase',
},
value: fakeData.key_snake_case.value,
},
};
mockHttpClient.get.mockResolvedValue({ data: fakeData });
const result = await getProctoringExamErrors('course-v1:Test+T101+2024');
expect(mockHttpClient.get).toHaveBeenCalledWith(
`${process.env.STUDIO_BASE_URL}/api/contentstore/v1/proctoring_errors/course-v1:Test+T101+2024`,
);
expect(result).toEqual(expected);
});
});
});

View File

@@ -21,6 +21,7 @@ const path = '/content/:contentId?/*';
const mockOnClose = jest.fn(); const mockOnClose = jest.fn();
const mockSetBlockingSheet = jest.fn(); const mockSetBlockingSheet = jest.fn();
const mockNavigate = jest.fn(); const mockNavigate = jest.fn();
const mockSidebarAction = jest.fn();
mockContentTaxonomyTagsData.applyMock(); mockContentTaxonomyTagsData.applyMock();
mockTaxonomyListData.applyMock(); mockTaxonomyListData.applyMock();
mockTaxonomyTagsData.applyMock(); mockTaxonomyTagsData.applyMock();
@@ -40,6 +41,11 @@ jest.mock('react-router-dom', () => ({
useNavigate: () => mockNavigate, useNavigate: () => mockNavigate,
})); }));
jest.mock('../library-authoring/common/context/SidebarContext', () => ({
...jest.requireActual('../library-authoring/common/context/SidebarContext'),
useSidebarContext: () => ({ sidebarAction: mockSidebarAction() }),
}));
const renderDrawer = (contentId, drawerParams = {}) => ( const renderDrawer = (contentId, drawerParams = {}) => (
render( render(
<ContentTagsDrawerSheetContext.Provider value={drawerParams}> <ContentTagsDrawerSheetContext.Provider value={drawerParams}>
@@ -184,6 +190,26 @@ describe('<ContentTagsDrawer />', () => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
}); });
it('should change to edit mode sidebar action is set to JumpToManageTags', async () => {
mockSidebarAction.mockReturnValueOnce('jump-to-manage-tags');
renderDrawer(stagedTagsId, { variant: 'component' });
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
// Show delete tag buttons
expect(screen.getAllByRole('button', {
name: /delete/i,
}).length).toBe(2);
// Show add a tag select
expect(screen.getByText(/add a tag/i)).toBeInTheDocument();
// Show cancel button
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
// Show save button
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});
it('should change to read mode when click on `Cancel` on drawer variant', async () => { it('should change to read mode when click on `Cancel` on drawer variant', async () => {
renderDrawer(stagedTagsId); renderDrawer(stagedTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();

View File

@@ -14,6 +14,7 @@ import ContentTagsCollapsible from './ContentTagsCollapsible';
import Loading from '../generic/Loading'; import Loading from '../generic/Loading';
import { useCreateContentTagsDrawerContext } from './ContentTagsDrawerHelper'; import { useCreateContentTagsDrawerContext } from './ContentTagsDrawerHelper';
import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context'; import { ContentTagsDrawerContext, ContentTagsDrawerSheetContext } from './common/context';
import { SidebarActions, useSidebarContext } from '../library-authoring/common/context/SidebarContext';
interface TaxonomyListProps { interface TaxonomyListProps {
contentId: string; contentId: string;
@@ -244,6 +245,7 @@ const ContentTagsDrawer = ({
if (contentId === undefined) { if (contentId === undefined) {
throw new Error('Error: contentId cannot be null.'); throw new Error('Error: contentId cannot be null.');
} }
const { sidebarAction } = useSidebarContext();
const context = useCreateContentTagsDrawerContext(contentId, !readOnly, variant === 'drawer'); const context = useCreateContentTagsDrawerContext(contentId, !readOnly, variant === 'drawer');
const { blockingSheet } = useContext(ContentTagsDrawerSheetContext); const { blockingSheet } = useContext(ContentTagsDrawerSheetContext);
@@ -260,6 +262,7 @@ const ContentTagsDrawer = ({
closeToast, closeToast,
setCollapsibleToInitalState, setCollapsibleToInitalState,
otherTaxonomies, otherTaxonomies,
toEditMode,
} = context; } = context;
let onCloseDrawer: () => void; let onCloseDrawer: () => void;
@@ -302,8 +305,13 @@ const ContentTagsDrawer = ({
// First call of the initial collapsible states // First call of the initial collapsible states
React.useEffect(() => { React.useEffect(() => {
setCollapsibleToInitalState(); // Open tag edit mode when sidebarAction is JumpToManageTags
}, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded]); if (sidebarAction === SidebarActions.JumpToManageTags) {
toEditMode();
} else {
setCollapsibleToInitalState();
}
}, [isTaxonomyListLoaded, isContentTaxonomyTagsLoaded, sidebarAction, toEditMode]);
const renderFooter = () => { const renderFooter = () => {
if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) { if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) {

View File

@@ -7,6 +7,7 @@ import {
useMutation, useMutation,
useQueryClient, useQueryClient,
} from '@tanstack/react-query'; } from '@tanstack/react-query';
import { useParams } from 'react-router';
import { import {
getTaxonomyTagsData, getTaxonomyTagsData,
getContentTaxonomyTagsData, getContentTaxonomyTagsData,
@@ -14,7 +15,7 @@ import {
updateContentTaxonomyTags, updateContentTaxonomyTags,
getContentTaxonomyTagsCount, getContentTaxonomyTagsCount,
} from './api'; } from './api';
import { libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks'; import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
import { getLibraryId } from '../../generic/key-utils'; import { getLibraryId } from '../../generic/key-utils';
/** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */ /** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */
@@ -129,6 +130,7 @@ export const useContentData = (contentId, enabled) => (
export const useContentTaxonomyTagsUpdater = (contentId) => { export const useContentTaxonomyTagsUpdater = (contentId) => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const unitIframe = window.frames['xblock-iframe']; const unitIframe = window.frames['xblock-iframe'];
const { unitId } = useParams();
return useMutation({ return useMutation({
/** /**
@@ -158,6 +160,10 @@ export const useContentTaxonomyTagsUpdater = (contentId) => {
queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId)); queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId));
// Invalidate content search to update tags count // Invalidate content search to update tags count
queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) }); queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) });
// If the tags for a compoent were edited from Unit page, invalidate children query to fetch count again.
if (unitId) {
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(unitId));
}
} }
}, },
onSuccess: /* istanbul ignore next */ () => { onSuccess: /* istanbul ignore next */ () => {

View File

@@ -157,7 +157,7 @@ describe('useContentTaxonomyTagsUpdater', () => {
const contentId = 'testerContent'; const contentId = 'testerContent';
const taxonomyId = 123; const taxonomyId = 123;
const mutation = useContentTaxonomyTagsUpdater(contentId); const mutation = renderHook(() => useContentTaxonomyTagsUpdater(contentId)).result.current;
const tagsData = [{ const tagsData = [{
taxonomy: taxonomyId, taxonomy: taxonomyId,
tags: ['tag1', 'tag2'], tags: ['tag1', 'tag2'],

View File

@@ -118,6 +118,46 @@ describe('<CourseLibraries />', () => {
userEvent.click(reviewActionBtn); userEvent.click(reviewActionBtn);
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true'); expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
}); });
it('show alert if max lastPublishedDate is greated than the local storage value', async () => {
const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z');
localStorage.setItem(
`outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`,
String(lastPublishedDate.getTime() - 1000),
);
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
userEvent.click(allTab);
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
});
it('doesnt show alert if max lastPublishedDate is less than the local storage value', async () => {
const lastPublishedDate = new Date('2025-05-01T22:20:44.989042Z');
localStorage.setItem(
`outOfSyncCountAlert-${mockGetEntityLinks.courseKey}`,
String(lastPublishedDate.getTime() + 1000),
);
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
userEvent.click(allTab);
expect(allTab).toHaveAttribute('aria-selected', 'true');
screen.logTestingPlaygroundURL();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
}); });
describe('<CourseLibraries ReviewTab />', () => { describe('<CourseLibraries ReviewTab />', () => {
@@ -160,7 +200,7 @@ describe('<CourseLibraries ReviewTab />', () => {
it('update changes works', async () => { it('update changes works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {}); axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const updateBtns = await screen.findAllByRole('button', { name: 'Update' }); const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
@@ -176,7 +216,7 @@ describe('<CourseLibraries ReviewTab />', () => {
it('update changes works in preview modal', async () => { it('update changes works in preview modal', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {}); axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' }); const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
@@ -195,7 +235,7 @@ describe('<CourseLibraries ReviewTab />', () => {
it('ignore change works', async () => { it('ignore change works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {}); axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' }); const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
@@ -218,7 +258,7 @@ describe('<CourseLibraries ReviewTab />', () => {
it('ignore change works in preview', async () => { it('ignore change works in preview', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries'); const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey; const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {}); axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey); await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' }); const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });

View File

@@ -164,7 +164,7 @@ export const CourseLibraries: React.FC<Props> = ({ courseId }) => {
if (tabKey !== CourseLibraryTabs.review) { if (tabKey !== CourseLibraryTabs.review) {
return null; return null;
} }
if (!outOfSyncCount || outOfSyncCount === 0) { if (!outOfSyncCount) {
return ( return (
<Stack direction="horizontal" gap={2}> <Stack direction="horizontal" gap={2}>
<Icon src={CheckCircle} size="xs" /> <Icon src={CheckCircle} size="xs" />

View File

@@ -18,12 +18,11 @@ interface OutOfSyncAlertProps {
* in course can be updated. Following are the conditions for displaying the alert. * in course can be updated. Following are the conditions for displaying the alert.
* *
* * The alert is displayed if components are out of sync. * * The alert is displayed if components are out of sync.
* * If the user clicks on dismiss button, the state is stored in localstorage of user * * If the user clicks on dismiss button, the state of dismiss is stored in localstorage of user
* in this format: outOfSyncCountAlert-${courseId} = <number of out of sync components>. * in this format: outOfSyncCountAlert-${courseId} = <datetime value in milliseconds>.
* * If the number of sync components don't change for the course and the user opens outline * * If there are not new published components for the course and the user opens outline
* in the same browser, they don't see the alert again. * in the same browser, they don't see the alert again.
* * If the number changes, i.e., if a new component is out of sync or the user updates or ignores * * If there is a new published component upstream, the alert is displayed again.
* a component, the alert is displayed again.
*/ */
export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
showAlert, showAlert,
@@ -35,6 +34,8 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
const intl = useIntl(); const intl = useIntl();
const { data, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId); const { data, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
const outOfSyncCount = data?.reduce((count, lib) => count + (lib.readyToSyncCount || 0), 0); const outOfSyncCount = data?.reduce((count, lib) => count + (lib.readyToSyncCount || 0), 0);
const lastPublishedDate = data?.map(lib => new Date(lib.lastPublishedAt || 0).getTime())
.reduce((acc, lastPublished) => Math.max(lastPublished, acc), 0);
const alertKey = `outOfSyncCountAlert-${courseId}`; const alertKey = `outOfSyncCountAlert-${courseId}`;
useEffect(() => { useEffect(() => {
@@ -46,13 +47,14 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
setShowAlert(false); setShowAlert(false);
return; return;
} }
const dismissedAlert = localStorage.getItem(alertKey); const dismissedAlertDate = parseInt(localStorage.getItem(alertKey) ?? '0', 10);
setShowAlert(parseInt(dismissedAlert || '', 10) !== outOfSyncCount);
}, [outOfSyncCount, isLoading, data]); setShowAlert((lastPublishedDate ?? 0) > dismissedAlertDate);
}, [outOfSyncCount, lastPublishedDate, isLoading, data]);
const dismissAlert = () => { const dismissAlert = () => {
setShowAlert(false); setShowAlert(false);
localStorage.setItem(alertKey, String(outOfSyncCount)); localStorage.setItem(alertKey, Date.now().toString());
onDismiss?.(); onDismiss?.();
}; };

View File

@@ -1,5 +1,5 @@
import React, { import React, {
useCallback, useContext, useEffect, useMemo, useState, useCallback, useContext, useMemo, useState,
} from 'react'; } from 'react';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
@@ -14,11 +14,9 @@ import {
useToggle, useToggle,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { import { tail, keyBy } from 'lodash';
tail, keyBy, orderBy, merge, omitBy,
} from 'lodash';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Loop, Warning } from '@openedx/paragon/icons'; import { Loop } from '@openedx/paragon/icons';
import messages from './messages'; import messages from './messages';
import previewChangesMessages from '../course-unit/preview-changes/messages'; import previewChangesMessages from '../course-unit/preview-changes/messages';
import { courseLibrariesQueryKeys, useEntityLinks } from './data/apiHooks'; import { courseLibrariesQueryKeys, useEntityLinks } from './data/apiHooks';
@@ -37,7 +35,6 @@ import { useLoadOnScroll } from '../hooks';
import DeleteModal from '../generic/delete-modal/DeleteModal'; import DeleteModal from '../generic/delete-modal/DeleteModal';
import { PublishableEntityLink } from './data/api'; import { PublishableEntityLink } from './data/api';
import AlertError from '../generic/alert-error'; import AlertError from '../generic/alert-error';
import AlertMessage from '../generic/alert-message';
interface Props { interface Props {
courseId: string; courseId: string;
@@ -102,10 +99,8 @@ const BlockCard: React.FC<BlockCardProps> = ({ info, actions }) => {
const ComponentReviewList = ({ const ComponentReviewList = ({
outOfSyncComponents, outOfSyncComponents,
onSearchUpdate,
}: { }: {
outOfSyncComponents: PublishableEntityLink[]; outOfSyncComponents: PublishableEntityLink[];
onSearchUpdate: () => void;
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const { showToast } = useContext(ToastContext); const { showToast } = useContext(ToastContext);
@@ -113,24 +108,15 @@ const ComponentReviewList = ({
// ignore changes confirmation modal toggle. // ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const { const {
hits: downstreamInfo, hits,
isLoading: isIndexDataLoading, isLoading: isIndexDataLoading,
searchKeywords,
searchSortOrder,
hasError, hasError,
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
fetchNextPage, fetchNextPage,
} = useSearchContext() as { } = useSearchContext();
hits: ContentHit[];
isLoading: boolean; const downstreamInfo = hits as ContentHit[];
searchKeywords: string;
searchSortOrder: SearchSortOption;
hasError: boolean;
hasNextPage: boolean | undefined,
isFetchingNextPage: boolean;
fetchNextPage: () => void;
};
useLoadOnScroll( useLoadOnScroll(
hasNextPage, hasNextPage,
@@ -143,24 +129,14 @@ const ComponentReviewList = ({
() => keyBy(outOfSyncComponents, 'downstreamUsageKey'), () => keyBy(outOfSyncComponents, 'downstreamUsageKey'),
[outOfSyncComponents], [outOfSyncComponents],
); );
const downstreamInfoByKey = useMemo(
() => keyBy(downstreamInfo, 'usageKey'),
[downstreamInfo],
);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
useEffect(() => {
if (searchKeywords) {
onSearchUpdate();
}
}, [searchKeywords]);
// Toggle preview changes modal // Toggle preview changes modal
const [isModalOpen, openModal, closeModal] = useToggle(false); const [isModalOpen, openModal, closeModal] = useToggle(false);
const acceptChangesMutation = useAcceptLibraryBlockChanges(); const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges(); const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const setSeletecdBlockData = (info: ContentHit) => { const setSelectedBlockData = useCallback((info: ContentHit) => {
setBlockData({ setBlockData({
displayName: info.displayName, displayName: info.displayName,
downstreamBlockId: info.usageKey, downstreamBlockId: info.usageKey,
@@ -168,17 +144,18 @@ const ComponentReviewList = ({
upstreamBlockVersionSynced: outOfSyncComponentsByKey[info.usageKey].versionSynced, upstreamBlockVersionSynced: outOfSyncComponentsByKey[info.usageKey].versionSynced,
isVertical: info.blockType === 'vertical', isVertical: info.blockType === 'vertical',
}); });
}; }, [outOfSyncComponentsByKey]);
// Show preview changes on review // Show preview changes on review
const onReview = useCallback((info: ContentHit) => { const onReview = useCallback((info: ContentHit) => {
setSeletecdBlockData(info); setSelectedBlockData(info);
openModal(); openModal();
}, [setSeletecdBlockData, openModal]); }, [setSelectedBlockData, openModal]);
const onIgnoreClick = useCallback((info: ContentHit) => { const onIgnoreClick = useCallback((info: ContentHit) => {
setSeletecdBlockData(info); setSelectedBlockData(info);
openConfirmModal(); openConfirmModal();
}, [setSeletecdBlockData, openConfirmModal]); }, [setSelectedBlockData, openConfirmModal]);
const reloadLinks = useCallback((usageKey: string) => { const reloadLinks = useCallback((usageKey: string) => {
const courseKey = outOfSyncComponentsByKey[usageKey].downstreamContextKey; const courseKey = outOfSyncComponentsByKey[usageKey].downstreamContextKey;
@@ -236,19 +213,6 @@ const ComponentReviewList = ({
} }
}, [blockData]); }, [blockData]);
const orderInfo = useMemo(() => {
if (searchSortOrder !== SearchSortOption.RECENTLY_MODIFIED) {
return downstreamInfo;
}
if (isIndexDataLoading) {
return [];
}
let merged = merge(downstreamInfoByKey, outOfSyncComponentsByKey);
merged = omitBy(merged, (o) => !o.displayName);
const ordered = orderBy(Object.values(merged), 'updated', 'desc');
return ordered;
}, [downstreamInfoByKey, outOfSyncComponentsByKey]);
if (isIndexDataLoading) { if (isIndexDataLoading) {
return <Loading />; return <Loading />;
} }
@@ -259,7 +223,7 @@ const ComponentReviewList = ({
return ( return (
<> <>
{orderInfo?.map((info) => ( {downstreamInfo?.map((info) => (
<BlockCard <BlockCard
key={info.usageKey} key={info.usageKey}
info={info} info={info}
@@ -293,20 +257,14 @@ const ComponentReviewList = ({
)} )}
/> />
))} ))}
<PreviewLibraryXBlockChanges {blockData && (
blockData={blockData} <PreviewLibraryXBlockChanges
isModalOpen={isModalOpen} blockData={blockData}
closeModal={closeModal} isModalOpen={isModalOpen}
postChange={postChange} closeModal={closeModal}
alertNode={( postChange={postChange}
<AlertMessage />
show )}
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
/>
)}
/>
<DeleteModal <DeleteModal
isOpen={isConfirmModalOpen} isOpen={isConfirmModalOpen}
close={closeConfirmModal} close={closeConfirmModal}
@@ -323,37 +281,17 @@ const ComponentReviewList = ({
const ReviewTabContent = ({ courseId }: Props) => { const ReviewTabContent = ({ courseId }: Props) => {
const intl = useIntl(); const intl = useIntl();
const { const {
data: linkPages, data: outOfSyncComponents,
isLoading: isSyncComponentsLoading, isLoading: isSyncComponentsLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
isError, isError,
error, error,
} = useEntityLinks({ courseId, readyToSync: true }); } = useEntityLinks({ courseId, readyToSync: true });
const outOfSyncComponents = useMemo(
() => linkPages?.pages?.reduce((links, page) => [...links, ...page.results], []) ?? [],
[linkPages],
);
const downstreamKeys = useMemo( const downstreamKeys = useMemo(
() => outOfSyncComponents?.map(link => link.downstreamUsageKey), () => outOfSyncComponents?.map(link => link.downstreamUsageKey),
[outOfSyncComponents], [outOfSyncComponents],
); );
useLoadOnScroll(
hasNextPage,
isFetchingNextPage,
fetchNextPage,
true,
);
const onSearchUpdate = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
const disableSortOptions = [ const disableSortOptions = [
SearchSortOption.RELEVANCE, SearchSortOption.RELEVANCE,
SearchSortOption.OLDEST, SearchSortOption.OLDEST,
@@ -384,7 +322,6 @@ const ReviewTabContent = ({ courseId }: Props) => {
</ActionRow> </ActionRow>
<ComponentReviewList <ComponentReviewList
outOfSyncComponents={outOfSyncComponents} outOfSyncComponents={outOfSyncComponents}
onSearchUpdate={onSearchUpdate}
/> />
</SearchContextProvider> </SearchContextProvider>
); );

View File

@@ -3,17 +3,20 @@
"upstreamContextTitle": "CS problems 3", "upstreamContextTitle": "CS problems 3",
"upstreamContextKey": "lib:OpenedX:CSPROB3", "upstreamContextKey": "lib:OpenedX:CSPROB3",
"readyToSyncCount": 5, "readyToSyncCount": 5,
"totalCount": 14 "totalCount": 14,
"lastPublishedAt": "2025-05-01T20:20:44.989042Z"
}, },
{ {
"upstreamContextTitle": "CS problems 2", "upstreamContextTitle": "CS problems 2",
"upstreamContextKey": "lib:OpenedX:CSPROB2", "upstreamContextKey": "lib:OpenedX:CSPROB2",
"readyToSyncCount": 0, "readyToSyncCount": 0,
"totalCount": 21 "totalCount": 21,
"lastPublishedAt": "2025-05-01T21:20:44.989042Z"
}, },
{ {
"upstreamContextTitle": "CS problems", "upstreamContextTitle": "CS problems",
"upstreamContextKey": "lib:OpenedX:CSPROB", "upstreamContextKey": "lib:OpenedX:CSPROB",
"totalCount": 3 "totalCount": 3,
"lastPublishedAt": "2025-05-01T22:20:44.989042Z"
} }
] ]

View File

@@ -1,79 +1,72 @@
{ [
"count": 7, {
"next": null, "id": 875,
"previous": null, "upstreamContextTitle": "CS problems 3",
"num_pages": 1, "upstreamVersion": 10,
"current_page": 1, "readyToSync": true,
"results": [ "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
{ "upstreamContextKey": "lib:OpenedX:CSPROB3",
"id": 875, "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3",
"upstreamContextTitle": "CS problems 3", "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"upstreamVersion": 10, "versionSynced": 2,
"readyToSync": true, "versionDeclined": null,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", "created": "2025-02-08T14:07:05.588484Z",
"upstreamContextKey": "lib:OpenedX:CSPROB3", "updated": "2025-02-08T14:07:05.588484Z"
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3", },
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", {
"versionSynced": 2, "id": 876,
"versionDeclined": null, "upstreamContextTitle": "CS problems 3",
"created": "2025-02-08T14:07:05.588484Z", "upstreamVersion": 10,
"updated": "2025-02-08T14:07:05.588484Z" "readyToSync": true,
}, "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
{ "upstreamContextKey": "lib:OpenedX:CSPROB3",
"id": 876, "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6",
"upstreamContextTitle": "CS problems 3", "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"upstreamVersion": 10, "versionSynced": 2,
"readyToSync": true, "versionDeclined": null,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", "created": "2025-02-08T14:07:05.588484Z",
"upstreamContextKey": "lib:OpenedX:CSPROB3", "updated": "2025-02-08T14:07:05.588484Z"
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6", },
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", {
"versionSynced": 2, "id": 884,
"versionDeclined": null, "upstreamContextTitle": "CS problems 3",
"created": "2025-02-08T14:07:05.588484Z", "upstreamVersion": 26,
"updated": "2025-02-08T14:07:05.588484Z" "readyToSync": true,
}, "upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477",
{ "upstreamContextKey": "lib:OpenedX:CSPROB3",
"id": 884, "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b",
"upstreamContextTitle": "CS problems 3", "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"upstreamVersion": 26, "versionSynced": 16,
"readyToSync": true, "versionDeclined": null,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477", "created": "2025-02-08T14:07:05.588484Z",
"upstreamContextKey": "lib:OpenedX:CSPROB3", "updated": "2025-02-08T14:07:05.588484Z"
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b", },
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", {
"versionSynced": 16, "id": 889,
"versionDeclined": null, "upstreamContextTitle": "CS problems 3",
"created": "2025-02-08T14:07:05.588484Z", "upstreamVersion": 10,
"updated": "2025-02-08T14:07:05.588484Z" "readyToSync": true,
}, "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
{ "upstreamContextKey": "lib:OpenedX:CSPROB3",
"id": 889, "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37",
"upstreamContextTitle": "CS problems 3", "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"upstreamVersion": 10, "versionSynced": 2,
"readyToSync": true, "versionDeclined": null,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", "created": "2025-02-08T14:07:05.588484Z",
"upstreamContextKey": "lib:OpenedX:CSPROB3", "updated": "2025-02-08T14:07:05.588484Z"
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37", },
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", {
"versionSynced": 2, "id": 890,
"versionDeclined": null, "upstreamContextTitle": "CS problems 3",
"created": "2025-02-08T14:07:05.588484Z", "upstreamVersion": 10,
"updated": "2025-02-08T14:07:05.588484Z" "readyToSync": true,
}, "upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
{ "upstreamContextKey": "lib:OpenedX:CSPROB3",
"id": 890, "downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0",
"upstreamContextTitle": "CS problems 3", "downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"upstreamVersion": 10, "versionSynced": 2,
"readyToSync": true, "versionDeclined": null,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3", "created": "2025-02-08T14:07:05.588484Z",
"upstreamContextKey": "lib:OpenedX:CSPROB3", "updated": "2025-02-08T14:07:05.588484Z"
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0", }
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX", ]
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
}
]
}

View File

@@ -28,27 +28,17 @@ export async function mockGetEntityLinks(
case mockGetEntityLinks.courseKeyLoading: case mockGetEntityLinks.courseKeyLoading:
return new Promise(() => {}); return new Promise(() => {});
case mockGetEntityLinks.courseKeyEmpty: case mockGetEntityLinks.courseKeyEmpty:
return Promise.resolve({ return Promise.resolve([]);
next: null,
previous: null,
nextPageNum: null,
previousPageNum: null,
count: 0,
numPages: 0,
currentPage: 0,
results: [],
});
default: { default: {
const { response } = mockGetEntityLinks; let { response } = mockGetEntityLinks;
if (readyToSync !== undefined) { if (readyToSync !== undefined) {
response.results = response.results.filter((o) => o.readyToSync === readyToSync); response = response.filter((o) => o.readyToSync === readyToSync);
response.count = response.results.length;
} }
return Promise.resolve(response); return Promise.resolve(response);
} }
} }
} }
mockGetEntityLinks.courseKey = mockLinksResult.results[0].downstreamContextKey; mockGetEntityLinks.courseKey = mockLinksResult[0].downstreamContextKey;
mockGetEntityLinks.invalidCourseKey = 'course_key_error'; mockGetEntityLinks.invalidCourseKey = 'course_key_error';
mockGetEntityLinks.courseKeyLoading = 'courseKeyLoading'; mockGetEntityLinks.courseKeyLoading = 'courseKeyLoading';
mockGetEntityLinks.courseKeyEmpty = 'courseKeyEmpty'; mockGetEntityLinks.courseKeyEmpty = 'courseKeyEmpty';
@@ -85,7 +75,7 @@ export async function mockGetEntityLinksSummaryByDownstreamContext(
return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response); return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response);
} }
} }
mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult.results[0].downstreamContextKey; mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult[0].downstreamContextKey;
mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey = 'course_key_error'; mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey = 'course_key_error';
mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading = 'courseKeySummaryLoading'; mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading = 'courseKeySummaryLoading';
mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty'; mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty';

View File

@@ -38,32 +38,13 @@ export interface PublishableEntityLinkSummary {
upstreamContextTitle: string; upstreamContextTitle: string;
readyToSyncCount: number; readyToSyncCount: number;
totalCount: number; totalCount: number;
lastPublishedAt: string;
} }
export const getEntityLinks = async ( export const getEntityLinks = async (
downstreamContextKey?: string, downstreamContextKey?: string,
readyToSync?: boolean, readyToSync?: boolean,
upstreamUsageKey?: string, upstreamUsageKey?: string,
pageParam?: number,
pageSize?: number,
): Promise<PaginatedData<PublishableEntityLink[]>> => {
const { data } = await getAuthenticatedHttpClient()
.get(getEntityLinksByDownstreamContextUrl(), {
params: {
course_id: downstreamContextKey,
ready_to_sync: readyToSync,
upstream_usage_key: upstreamUsageKey,
page_size: pageSize,
page: pageParam,
},
});
return camelCaseObject(data);
};
export const getUnpaginatedEntityLinks = async (
downstreamContextKey?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
): Promise<PublishableEntityLink[]> => { ): Promise<PublishableEntityLink[]> => {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.get(getEntityLinksByDownstreamContextUrl(), { .get(getEntityLinksByDownstreamContextUrl(), {

View File

@@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import { renderHook, waitFor } from '@testing-library/react'; import { renderHook, waitFor } from '@testing-library/react';
import { getEntityLinksByDownstreamContextUrl } from './api'; import { getEntityLinksByDownstreamContextUrl } from './api';
import { useEntityLinks, useUnpaginatedEntityLinks } from './apiHooks'; import { useEntityLinks } from './apiHooks';
let axiosMock: MockAdapter; let axiosMock: MockAdapter;
@@ -39,26 +39,11 @@ describe('course libraries api hooks', () => {
axiosMock.reset(); axiosMock.reset();
}); });
it('should return paginated links for course', async () => {
const courseId = 'course-v1:some+key';
const url = getEntityLinksByDownstreamContextUrl();
const expectedResult = {
next: null, results: [], previous: null, total: 0,
};
axiosMock.onGet(url).reply(200, expectedResult);
const { result } = renderHook(() => useEntityLinks({ courseId }), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(result.current.data?.pages).toEqual([expectedResult]);
expect(axiosMock.history.get[0].url).toEqual(url);
});
it('should return links for course', async () => { it('should return links for course', async () => {
const courseId = 'course-v1:some+key'; const courseId = 'course-v1:some+key';
const url = getEntityLinksByDownstreamContextUrl(); const url = getEntityLinksByDownstreamContextUrl();
axiosMock.onGet(url).reply(200, []); axiosMock.onGet(url).reply(200, []);
const { result } = renderHook(() => useUnpaginatedEntityLinks({ courseId }), { wrapper }); const { result } = renderHook(() => useEntityLinks({ courseId }), { wrapper });
await waitFor(() => { await waitFor(() => {
expect(result.current.isLoading).toBeFalsy(); expect(result.current.isLoading).toBeFalsy();
}); });

View File

@@ -1,8 +1,7 @@
import { import {
useInfiniteQuery,
useQuery, useQuery,
} from '@tanstack/react-query'; } from '@tanstack/react-query';
import { getEntityLinks, getEntityLinksSummaryByDownstreamContext, getUnpaginatedEntityLinks } from './api'; import { getEntityLinks, getEntityLinksSummaryByDownstreamContext } from './api';
export const courseLibrariesQueryKeys = { export const courseLibrariesQueryKeys = {
all: ['courseLibraries'], all: ['courseLibraries'],
@@ -29,39 +28,10 @@ export const courseLibrariesQueryKeys = {
}; };
/** /**
* Hook to fetch publishable entity links by course key. * Hook to fetch list of publishable entity links by course key.
* (That is, get a list of the library components used in the given course.) * (That is, get a list of the library components used in the given course.)
*/ */
export const useEntityLinks = ({ export const useEntityLinks = ({
courseId, readyToSync, upstreamUsageKey, pageSize,
}: {
courseId?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
pageSize?: number
}) => (
useInfiniteQuery({
queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({
courseId,
readyToSync,
upstreamUsageKey,
}),
queryFn: ({ pageParam }) => getEntityLinks(
courseId,
readyToSync,
upstreamUsageKey,
pageParam,
pageSize,
),
getNextPageParam: (lastPage) => lastPage.nextPageNum,
enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined,
})
);
/**
* Hook to fetch unpaginated list of publishable entity links by course key.
*/
export const useUnpaginatedEntityLinks = ({
courseId, readyToSync, upstreamUsageKey, courseId, readyToSync, upstreamUsageKey,
}: { }: {
courseId?: string, courseId?: string,
@@ -74,7 +44,7 @@ export const useUnpaginatedEntityLinks = ({
readyToSync, readyToSync,
upstreamUsageKey, upstreamUsageKey,
}), }),
queryFn: () => getUnpaginatedEntityLinks( queryFn: () => getEntityLinks(
courseId, courseId,
readyToSync, readyToSync,
upstreamUsageKey, upstreamUsageKey,

View File

@@ -116,11 +116,6 @@ const messages = defineMessages({
defaultMessage: 'Something went wrong! Could not fetch results.', defaultMessage: 'Something went wrong! Could not fetch results.',
description: 'Generic error message displayed when fetching link data fails.', description: 'Generic error message displayed when fetching link data fails.',
}, },
olderVersionPreviewAlert: {
id: 'course-authoring.course-libraries.reviw-tab.preview.old-version-alert',
defaultMessage: 'The old version preview is the previous library version',
description: 'Alert message stating that older version in preview is of library block',
},
}); });
export default messages; export default messages;

View File

@@ -375,6 +375,7 @@ const CourseOutline = ({ courseId }) => {
section, section,
section.childInfo.children, section.childInfo.children,
)} )}
isSectionsExpanded={isSectionsExpanded}
isSelfPaced={statusBarData.isSelfPaced} isSelfPaced={statusBarData.isSelfPaced}
isCustomRelativeDatesActive={isCustomRelativeDatesActive} isCustomRelativeDatesActive={isCustomRelativeDatesActive}
savingStatus={savingStatus} savingStatus={savingStatus}

View File

@@ -11,7 +11,6 @@ import { cloneDeep } from 'lodash';
import { closestCorners } from '@dnd-kit/core'; import { closestCorners } from '@dnd-kit/core';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import userEvent from '@testing-library/user-event';
import { import {
getCourseBestPracticesApiUrl, getCourseBestPracticesApiUrl,
getCourseLaunchApiUrl, getCourseLaunchApiUrl,
@@ -97,12 +96,6 @@ jest.mock('./data/api', () => ({
getTagsCount: () => jest.fn().mockResolvedValue({}), getTagsCount: () => jest.fn().mockResolvedValue({}),
})); }));
jest.mock('../studio-home/hooks', () => ({
useStudioHome: () => ({
librariesV2Enabled: true,
}),
}));
// Mock ComponentPicker to call onComponentSelected on click // Mock ComponentPicker to call onComponentSelected on click
jest.mock('../library-authoring/component-picker', () => ({ jest.mock('../library-authoring/component-picker', () => ({
ComponentPicker: (props) => { ComponentPicker: (props) => {
@@ -160,7 +153,9 @@ describe('<CourseOutline />', () => {
pathname: mockPathname, pathname: mockPathname,
}); });
store = initializeStore(); store = initializeStore({
studioHome: { studioHomeData: { librariesV2Enabled: true } },
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock axiosMock
.onGet(getCourseOutlineIndexApiUrl(courseId)) .onGet(getCourseOutlineIndexApiUrl(courseId))
@@ -179,6 +174,10 @@ describe('<CourseOutline />', () => {
await executeThunk(fetchCourseOutlineIndexQuery(courseId), store.dispatch); await executeThunk(fetchCourseOutlineIndexQuery(courseId), store.dispatch);
}); });
afterEach(() => {
jest.restoreAllMocks();
});
it('render CourseOutline component correctly', async () => { it('render CourseOutline component correctly', async () => {
const { getByText } = render(<RootWrapper />); const { getByText } = render(<RootWrapper />);
@@ -289,13 +288,15 @@ describe('<CourseOutline />', () => {
}); });
it('check that new section list is saved when dragged', async () => { it('check that new section list is saved when dragged', async () => {
const { findAllByRole } = render(<RootWrapper />); const { findAllByRole, findByTestId } = render(<RootWrapper />);
const courseBlockId = courseOutlineIndexMock.courseStructure.id; const expandAllButton = await findByTestId('expand-collapse-all-button');
fireEvent.click(expandAllButton);
const [section] = store.getState().courseOutline.sectionsList;
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = sectionsDraggers[6]; const draggableButton = sectionsDraggers[1];
axiosMock axiosMock
.onPut(getCourseBlockApiUrl(courseBlockId)) .onPut(getCourseBlockApiUrl(section.id))
.reply(200, { dummy: 'value' }); .reply(200, { dummy: 'value' });
const section1 = store.getState().courseOutline.sectionsList[0].id; const section1 = store.getState().courseOutline.sectionsList[0].id;
@@ -314,13 +315,15 @@ describe('<CourseOutline />', () => {
}); });
it('check section list is restored to original order when API call fails', async () => { it('check section list is restored to original order when API call fails', async () => {
const { findAllByRole } = render(<RootWrapper />); const { findAllByRole, findByTestId } = render(<RootWrapper />);
const courseBlockId = courseOutlineIndexMock.courseStructure.id; const expandAllButton = await findByTestId('expand-collapse-all-button');
fireEvent.click(expandAllButton);
const [section] = store.getState().courseOutline.sectionsList;
const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' }); const sectionsDraggers = await findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = sectionsDraggers[6]; const draggableButton = sectionsDraggers[1];
axiosMock axiosMock
.onPut(getCourseBlockApiUrl(courseBlockId)) .onPut(getCourseBlockApiUrl(section.id))
.reply(500); .reply(500);
const section1 = store.getState().courseOutline.sectionsList[0].id; const section1 = store.getState().courseOutline.sectionsList[0].id;
@@ -395,8 +398,6 @@ describe('<CourseOutline />', () => {
const { findAllByTestId } = render(<RootWrapper />); const { findAllByTestId } = render(<RootWrapper />);
const [sectionElement] = await findAllByTestId('section-card'); const [sectionElement] = await findAllByTestId('section-card');
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const units = await within(subsectionElement).findAllByTestId('unit-card'); const units = await within(subsectionElement).findAllByTestId('unit-card');
expect(units.length).toBe(1); expect(units.length).toBe(1);
@@ -421,8 +422,6 @@ describe('<CourseOutline />', () => {
render(<RootWrapper />); render(<RootWrapper />);
const [sectionElement] = await screen.findAllByTestId('section-card'); const [sectionElement] = await screen.findAllByTestId('section-card');
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const units = await within(subsectionElement).findAllByTestId('unit-card'); const units = await within(subsectionElement).findAllByTestId('unit-card');
expect(units.length).toBe(1); expect(units.length).toBe(1);
@@ -646,8 +645,6 @@ describe('<CourseOutline />', () => {
await checkEditTitle(section, subsectionElement, subsection, 'New subsection name', 'subsection'); await checkEditTitle(section, subsectionElement, subsection, 'New subsection name', 'subsection');
// check unit // check unit
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [unit] = subsection.childInfo.children; const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
await checkEditTitle(section, unitElement, unit, 'New unit name', 'unit'); await checkEditTitle(section, unitElement, unit, 'New unit name', 'unit');
@@ -660,8 +657,6 @@ describe('<CourseOutline />', () => {
const [sectionElement] = await screen.findAllByTestId('section-card'); const [sectionElement] = await screen.findAllByTestId('section-card');
const [subsection] = section.childInfo.children; const [subsection] = section.childInfo.children;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [unit] = subsection.childInfo.children; const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -700,8 +695,6 @@ describe('<CourseOutline />', () => {
const [sectionElement] = await findAllByTestId('section-card'); const [sectionElement] = await findAllByTestId('section-card');
const [subsection] = section.childInfo.children; const [subsection] = section.childInfo.children;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [unit] = subsection.childInfo.children; const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -771,8 +764,6 @@ describe('<CourseOutline />', () => {
const [sectionElement] = await findAllByTestId('section-card'); const [sectionElement] = await findAllByTestId('section-card');
const [subsection] = section.childInfo.children; const [subsection] = section.childInfo.children;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [unit] = subsection.childInfo.children; const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -1481,8 +1472,6 @@ describe('<CourseOutline />', () => {
const [firstSection] = await findAllByTestId('section-card'); const [firstSection] = await findAllByTestId('section-card');
const [firstSubsection] = await within(firstSection).findAllByTestId('subsection-card'); const [firstSubsection] = await within(firstSection).findAllByTestId('subsection-card');
const subsectionExpandButton = await within(firstSubsection).getByTestId('subsection-card-header__expanded-btn');
fireEvent.click(subsectionExpandButton);
const [firstUnit] = await within(firstSubsection).findAllByTestId('unit-card'); const [firstUnit] = await within(firstSubsection).findAllByTestId('unit-card');
const unitDropdownButton = await within(firstUnit).findByTestId('unit-card-header__menu-button'); const unitDropdownButton = await within(firstUnit).findByTestId('unit-card-header__menu-button');
@@ -1842,8 +1831,6 @@ describe('<CourseOutline />', () => {
const [, sectionElement] = await findAllByTestId('section-card'); const [, sectionElement] = await findAllByTestId('section-card');
const [, subsection] = section.childInfo.children; const [, subsection] = section.childInfo.children;
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const [, secondUnit] = subsection.childInfo.children; const [, secondUnit] = subsection.childInfo.children;
const [, unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); const [, unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -1883,8 +1870,6 @@ describe('<CourseOutline />', () => {
const [, sectionElement] = await findAllByTestId('section-card'); const [, sectionElement] = await findAllByTestId('section-card');
const [firstSubsection, subsection] = section.childInfo.children; const [firstSubsection, subsection] = section.childInfo.children;
const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const [, subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const [unit] = subsection.childInfo.children; const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -1920,8 +1905,6 @@ describe('<CourseOutline />', () => {
const [subsection] = secondSection.childInfo.children; const [subsection] = secondSection.childInfo.children;
const firstSectionLastSubsection = firstSection.childInfo.children[firstSection.childInfo.children.length - 1]; const firstSectionLastSubsection = firstSection.childInfo.children[firstSection.childInfo.children.length - 1];
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const [unit] = subsection.childInfo.children; const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -1966,8 +1949,6 @@ describe('<CourseOutline />', () => {
const [, sectionElement] = await findAllByTestId('section-card'); const [, sectionElement] = await findAllByTestId('section-card');
const [firstSubsection, subsection] = section.childInfo.children; const [firstSubsection, subsection] = section.childInfo.children;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const lastUnitIdx = firstSubsection.childInfo.children.length - 1; const lastUnitIdx = firstSubsection.childInfo.children.length - 1;
const unit = firstSubsection.childInfo.children[lastUnitIdx]; const unit = firstSubsection.childInfo.children[lastUnitIdx];
const unitElement = (await within(subsectionElement).findAllByTestId('unit-card'))[lastUnitIdx]; const unitElement = (await within(subsectionElement).findAllByTestId('unit-card'))[lastUnitIdx];
@@ -2005,8 +1986,6 @@ describe('<CourseOutline />', () => {
const secondSectionLastSubsection = secondSection.childInfo.children[lastSubIndex]; const secondSectionLastSubsection = secondSection.childInfo.children[lastSubIndex];
const thirdSectionFirstSubsection = thirdSection.childInfo.children[0]; const thirdSectionFirstSubsection = thirdSection.childInfo.children[0];
const subsectionElement = (await within(sectionElement).findAllByTestId('subsection-card'))[lastSubIndex]; const subsectionElement = (await within(sectionElement).findAllByTestId('subsection-card'))[lastSubIndex];
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const lastUnitIdx = secondSectionLastSubsection.childInfo.children.length - 1; const lastUnitIdx = secondSectionLastSubsection.childInfo.children.length - 1;
const unit = secondSectionLastSubsection.childInfo.children[lastUnitIdx]; const unit = secondSectionLastSubsection.childInfo.children[lastUnitIdx];
const unitElement = (await within(subsectionElement).findAllByTestId('unit-card'))[lastUnitIdx]; const unitElement = (await within(subsectionElement).findAllByTestId('unit-card'))[lastUnitIdx];
@@ -2051,8 +2030,6 @@ describe('<CourseOutline />', () => {
const sections = await findAllByTestId('section-card'); const sections = await findAllByTestId('section-card');
const [sectionElement] = sections; const [sectionElement] = sections;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
// get first and only unit in the subsection // get first and only unit in the subsection
const [firstUnit] = await within(subsectionElement).findAllByTestId('unit-card'); const [firstUnit] = await within(subsectionElement).findAllByTestId('unit-card');
@@ -2072,8 +2049,6 @@ describe('<CourseOutline />', () => {
const lastSection = sections[sections.length - 1]; const lastSection = sections[sections.length - 1];
// it has only one subsection // it has only one subsection
const [lastSubsectionElement] = await within(lastSection).findAllByTestId('subsection-card'); const [lastSubsectionElement] = await within(lastSection).findAllByTestId('subsection-card');
const lastExpandBtn = await within(lastSubsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(lastExpandBtn));
// get last and the only unit in the subsection // get last and the only unit in the subsection
const [lastUnit] = await within(lastSubsectionElement).findAllByTestId('unit-card'); const [lastUnit] = await within(lastSubsectionElement).findAllByTestId('unit-card');
@@ -2094,6 +2069,9 @@ describe('<CourseOutline />', () => {
const { findAllByTestId } = render(<RootWrapper />); const { findAllByTestId } = render(<RootWrapper />);
const [sectionElement] = await findAllByTestId('section-card'); const [sectionElement] = await findAllByTestId('section-card');
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [section] = store.getState().courseOutline.sectionsList; const [section] = store.getState().courseOutline.sectionsList;
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' }); const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = subsectionsDraggers[1]; const draggableButton = subsectionsDraggers[1];
@@ -2125,6 +2103,9 @@ describe('<CourseOutline />', () => {
const { findAllByTestId } = render(<RootWrapper />); const { findAllByTestId } = render(<RootWrapper />);
const [sectionElement] = await findAllByTestId('section-card'); const [sectionElement] = await findAllByTestId('section-card');
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const [section] = store.getState().courseOutline.sectionsList; const [section] = store.getState().courseOutline.sectionsList;
const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' }); const subsectionsDraggers = within(sectionElement).getAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = subsectionsDraggers[1]; const draggableButton = subsectionsDraggers[1];
@@ -2154,8 +2135,6 @@ describe('<CourseOutline />', () => {
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const section = store.getState().courseOutline.sectionsList[2]; const section = store.getState().courseOutline.sectionsList[2];
const [subsection] = section.childInfo.children; const [subsection] = section.childInfo.children;
const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' }); const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = unitDraggers[1]; const draggableButton = unitDraggers[1];
const sections = courseOutlineIndexMock.courseStructure.childInfo.children; const sections = courseOutlineIndexMock.courseStructure.childInfo.children;
@@ -2190,8 +2169,6 @@ describe('<CourseOutline />', () => {
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const section = store.getState().courseOutline.sectionsList[2]; const section = store.getState().courseOutline.sectionsList[2];
const [subsection] = section.childInfo.children; const [subsection] = section.childInfo.children;
const expandBtn = within(subsectionElement).getByTestId('subsection-card-header__expanded-btn');
fireEvent.click(expandBtn);
const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' }); const unitDraggers = await within(subsectionElement).findAllByRole('button', { name: 'Drag to reorder' });
const draggableButton = unitDraggers[1]; const draggableButton = unitDraggers[1];
const sections = courseOutlineIndexMock.courseStructure.childInfo.children; const sections = courseOutlineIndexMock.courseStructure.childInfo.children;
@@ -2229,8 +2206,6 @@ describe('<CourseOutline />', () => {
.onGet(getXBlockApiUrl(section.id)) .onGet(getXBlockApiUrl(section.id))
.reply(200, courseSectionMock); .reply(200, courseSectionMock);
let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card'); let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await userEvent.click(expandBtn);
const [unit] = subsection.childInfo.children; const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card'); const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');

View File

@@ -66,6 +66,8 @@ const HeaderNavigations = ({
{hasSections && ( {hasSections && (
<Button <Button
variant="outline-primary" variant="outline-primary"
id="expand-collapse-all-button"
data-testid="expand-collapse-all-button"
iconBefore={isSectionsExpanded ? ArrowUpIcon : ArrowDownIcon} iconBefore={isSectionsExpanded ? ArrowUpIcon : ArrowDownIcon}
onClick={handleExpandAll} onClick={handleExpandAll}
> >

View File

@@ -3,7 +3,7 @@ import React, {
useContext, useEffect, useState, useRef, useCallback, useContext, useEffect, useState, useRef, useCallback,
} from 'react'; } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams } from 'react-router-dom';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, StandardModal, useToggle } from '@openedx/paragon'; import { Button, StandardModal, useToggle } from '@openedx/paragon';
@@ -25,12 +25,13 @@ import messages from './messages';
import { ComponentPicker } from '../../library-authoring'; import { ComponentPicker } from '../../library-authoring';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import { ContainerType } from '../../generic/key-utils'; import { ContainerType } from '../../generic/key-utils';
import { useStudioHome } from '../../studio-home/hooks';
import { ContentType } from '../../library-authoring/routes'; import { ContentType } from '../../library-authoring/routes';
import { getStudioHomeData } from '../../studio-home/data/selectors';
const SubsectionCard = ({ const SubsectionCard = ({
section, section,
subsection, subsection,
isSectionsExpanded,
isSelfPaced, isSelfPaced,
isCustomRelativeDatesActive, isCustomRelativeDatesActive,
children, children,
@@ -57,7 +58,12 @@ const SubsectionCard = ({
const [isFormOpen, openForm, closeForm] = useToggle(false); const [isFormOpen, openForm, closeForm] = useToggle(false);
const namePrefix = 'subsection'; const namePrefix = 'subsection';
const { sharedClipboardData, showPasteUnit } = useClipboard(); const { sharedClipboardData, showPasteUnit } = useClipboard();
const { librariesV2Enabled } = useStudioHome(); // WARNING: Do not use "useStudioHome" to get "librariesV2Enabled" flag below,
// as it has a useEffect that fetches course waffle flags whenever
// location.search is updated. Course search updates location.search when
// user types, which will then trigger the useEffect and reload the page.
// See https://github.com/openedx/frontend-app-authoring/pull/1938.
const { librariesV2Enabled } = useSelector(getStudioHomeData);
const [ const [
isAddLibraryUnitModalOpen, isAddLibraryUnitModalOpen,
openAddLibraryUnitModal, openAddLibraryUnitModal,
@@ -93,7 +99,7 @@ const SubsectionCard = ({
return false; return false;
}; };
const [isExpanded, setIsExpanded] = useState(containsSearchResult() || !isHeaderVisible); const [isExpanded, setIsExpanded] = useState(containsSearchResult() || !isHeaderVisible || isSectionsExpanded);
const subsectionStatus = getItemStatus({ const subsectionStatus = getItemStatus({
published, published,
visibilityState, visibilityState,
@@ -101,6 +107,10 @@ const SubsectionCard = ({
}); });
const borderStyle = getItemStatusBorder(subsectionStatus); const borderStyle = getItemStatusBorder(subsectionStatus);
useEffect(() => {
setIsExpanded(isSectionsExpanded);
}, [isSectionsExpanded]);
const handleExpandContent = () => { const handleExpandContent = () => {
setIsExpanded((prevState) => !prevState); setIsExpanded((prevState) => !prevState);
}; };
@@ -247,7 +257,7 @@ const SubsectionCard = ({
</div> </div>
</> </>
)} )}
{isExpanded && ( {(isExpanded) && (
<div <div
data-testid="subsection-card__units" data-testid="subsection-card__units"
className={classNames('subsection-card__units', { 'item-children': isDraggable })} className={classNames('subsection-card__units', { 'item-children': isDraggable })}
@@ -349,6 +359,7 @@ SubsectionCard.propTypes = {
}).isRequired, }).isRequired,
}).isRequired, }).isRequired,
children: PropTypes.node, children: PropTypes.node,
isSectionsExpanded: PropTypes.bool.isRequired,
isSelfPaced: PropTypes.bool.isRequired, isSelfPaced: PropTypes.bool.isRequired,
isCustomRelativeDatesActive: PropTypes.bool.isRequired, isCustomRelativeDatesActive: PropTypes.bool.isRequired,
onOpenPublishModal: PropTypes.func.isRequired, onOpenPublishModal: PropTypes.func.isRequired,

View File

@@ -24,8 +24,9 @@ jest.mock('react-router-dom', () => ({
}), }),
})); }));
jest.mock('../../studio-home/hooks', () => ({ jest.mock('react-redux', () => ({
useStudioHome: () => ({ ...jest.requireActual('react-redux'),
useSelector: () => ({
librariesV2Enabled: true, librariesV2Enabled: true,
}), }),
})); }));

View File

@@ -229,12 +229,14 @@ const UnitCard = ({
</div> </div>
</div> </div>
</SortableItem> </SortableItem>
<PreviewLibraryXBlockChanges {blockSyncData && (
blockData={blockSyncData} <PreviewLibraryXBlockChanges
isModalOpen={isSyncModalOpen} blockData={blockSyncData}
closeModal={closeSyncModal} isModalOpen={isSyncModalOpen}
postChange={handleOnPostChangeSync} closeModal={closeSyncModal}
/> postChange={handleOnPostChangeSync}
/>
)}
</> </>
); );
}; };

View File

@@ -3,8 +3,7 @@ import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { import {
Container, Layout, Stack, Button, TransitionReplace, Alert, Container, Layout, Button, TransitionReplace,
Alert,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { import {
@@ -27,8 +26,6 @@ import AddComponent from './add-component/AddComponent';
import HeaderTitle from './header-title/HeaderTitle'; import HeaderTitle from './header-title/HeaderTitle';
import Breadcrumbs from './breadcrumbs/Breadcrumbs'; import Breadcrumbs from './breadcrumbs/Breadcrumbs';
import Sequence from './course-sequence'; import Sequence from './course-sequence';
import Sidebar from './sidebar';
import SplitTestSidebarInfo from './sidebar/SplitTestSidebarInfo';
import { useCourseUnit, useLayoutGrid, useScrollToLastPosition } from './hooks'; import { useCourseUnit, useLayoutGrid, useScrollToLastPosition } from './hooks';
import messages from './messages'; import messages from './messages';
import { PasteNotificationAlert } from './clipboard'; import { PasteNotificationAlert } from './clipboard';
@@ -140,7 +137,7 @@ const CourseUnit = ({ courseId }) => {
/> />
) : null} ) : null}
</TransitionReplace> </TransitionReplace>
{courseUnit.upstreamInfo.upstreamLink && ( {courseUnit.upstreamInfo?.upstreamLink && (
<AlertMessage <AlertMessage
title={intl.formatMessage( title={intl.formatMessage(
messages.alertLibraryUnitReadOnlyText, messages.alertLibraryUnitReadOnlyText,
@@ -244,22 +241,15 @@ const CourseUnit = ({ courseId }) => {
<IframePreviewLibraryXBlockChanges /> <IframePreviewLibraryXBlockChanges />
</Layout.Element> </Layout.Element>
<Layout.Element> <Layout.Element>
<Stack gap={3}> <CourseAuthoringUnitSidebarSlot
{isUnitVerticalType && ( courseId={courseId}
<CourseAuthoringUnitSidebarSlot blockId={blockId}
courseId={courseId} unitTitle={unitTitle}
blockId={blockId} xBlocks={courseVerticalChildren.children}
unitTitle={unitTitle} readOnly={readOnly}
xBlocks={courseVerticalChildren.children} isUnitVerticalType={isUnitVerticalType}
readOnly={readOnly} isSplitTestType={isSplitTestType}
/> />
)}
{isSplitTestType && (
<Sidebar data-testid="course-split-test-sidebar">
<SplitTestSidebarInfo />
</Sidebar>
)}
</Stack>
</Layout.Element> </Layout.Element>
</Layout> </Layout>
</section> </section>

View File

@@ -65,6 +65,7 @@ import xblockContainerIframeMessages from './xblock-container-iframe/messages';
import headerNavigationsMessages from './header-navigations/messages'; import headerNavigationsMessages from './header-navigations/messages';
import sidebarMessages from './sidebar/messages'; import sidebarMessages from './sidebar/messages';
import messages from './messages'; import messages from './messages';
import * as selectors from '../data/selectors';
let axiosMock; let axiosMock;
let store; let store;
@@ -166,27 +167,27 @@ describe('<CourseUnit />', () => {
}); });
it('render CourseUnit component correctly', async () => { it('render CourseUnit component correctly', async () => {
const { getByText, getByRole, getByTestId } = render(<RootWrapper />); render(<RootWrapper />);
const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
await waitFor(() => { await waitFor(() => {
const unitHeaderTitle = getByTestId('unit-header-title'); const unitHeaderTitle = screen.getByTestId('unit-header-title');
expect(getByText(unitDisplayName)).toBeInTheDocument(); expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument(); expect(screen.getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument(); expect(screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument(); expect(screen.getByRole('button', { name: currentSectionName })).toBeInTheDocument();
expect(getByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); expect(screen.getByRole('button', { name: currentSubSectionName })).toBeInTheDocument();
}); });
}); });
it('renders the course unit iframe with correct attributes', async () => { it('renders the course unit iframe with correct attributes', async () => {
const { getByTitle } = render(<RootWrapper />); render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute('src', `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`); expect(iframe).toHaveAttribute('src', `${getConfig().STUDIO_BASE_URL}/container_embed/${blockId}`);
expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY); expect(iframe).toHaveAttribute('allow', IFRAME_FEATURE_POLICY);
expect(iframe).toHaveAttribute('style', 'height: 0px;'); expect(iframe).toHaveAttribute('style', 'height: 0px;');
@@ -210,27 +211,27 @@ describe('<CourseUnit />', () => {
}); });
it('displays an error alert when a studioAjaxError message is received', async () => { it('displays an error alert when a studioAjaxError message is received', async () => {
const { getByTitle, getByTestId } = render(<RootWrapper />); render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument(); expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.studioAjaxError, { simulatePostMessageEvent(messageTypes.studioAjaxError, {
error: 'Some error text...', error: 'Some error text...',
}); });
}); });
expect(getByTestId('saving-error-alert')).toBeInTheDocument(); expect(screen.getByTestId('saving-error-alert')).toBeInTheDocument();
}); });
it('renders XBlock iframe and opens legacy edit modal on editXBlock message', async () => { it('renders XBlock iframe and opens legacy edit modal on editXBlock message', async () => {
const { getByTitle } = render(<RootWrapper />); render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument(); expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.editXBlock, { id: blockId }); simulatePostMessageEvent(messageTypes.editXBlock, { id: blockId });
const legacyXBlockEditModalIframe = getByTitle( const legacyXBlockEditModalIframe = screen.getByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage, xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
); );
expect(legacyXBlockEditModalIframe).toBeInTheDocument(); expect(legacyXBlockEditModalIframe).toBeInTheDocument();
@@ -248,14 +249,14 @@ describe('<CourseUnit />', () => {
}); });
it('closes the legacy edit modal when closeXBlockEditorModal message is received', async () => { it('closes the legacy edit modal when closeXBlockEditorModal message is received', async () => {
const { getByTitle, queryByTitle } = render(<RootWrapper />); render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument(); expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.closeXBlockEditorModal, { id: blockId }); simulatePostMessageEvent(messageTypes.closeXBlockEditorModal, { id: blockId });
const legacyXBlockEditModalIframe = queryByTitle( const legacyXBlockEditModalIframe = screen.queryByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage, xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
); );
expect(legacyXBlockEditModalIframe).not.toBeInTheDocument(); expect(legacyXBlockEditModalIframe).not.toBeInTheDocument();
@@ -263,14 +264,14 @@ describe('<CourseUnit />', () => {
}); });
it('closes legacy edit modal and updates course unit sidebar after saveEditedXBlockData message', async () => { it('closes legacy edit modal and updates course unit sidebar after saveEditedXBlockData message', async () => {
const { getByTitle, queryByTitle, getByTestId } = render(<RootWrapper />); render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument(); expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.saveEditedXBlockData); simulatePostMessageEvent(messageTypes.saveEditedXBlockData);
const legacyXBlockEditModalIframe = queryByTitle( const legacyXBlockEditModalIframe = screen.queryByTitle(
xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage, xblockContainerIframeMessages.legacyEditModalIframeTitle.defaultMessage,
); );
expect(legacyXBlockEditModalIframe).not.toBeInTheDocument(); expect(legacyXBlockEditModalIframe).not.toBeInTheDocument();
@@ -285,7 +286,7 @@ describe('<CourseUnit />', () => {
}); });
await waitFor(() => { await waitFor(() => {
const courseUnitSidebar = getByTestId('course-unit-sidebar'); const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
expect( expect(
within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage), within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -304,10 +305,10 @@ describe('<CourseUnit />', () => {
}); });
it('updates course unit sidebar after receiving refreshPositions message', async () => { it('updates course unit sidebar after receiving refreshPositions message', async () => {
const { getByTitle, getByTestId } = render(<RootWrapper />); render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument(); expect(xblocksIframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.refreshPositions); simulatePostMessageEvent(messageTypes.refreshPositions);
}); });
@@ -321,7 +322,7 @@ describe('<CourseUnit />', () => {
}); });
await waitFor(() => { await waitFor(() => {
const courseUnitSidebar = getByTestId('course-unit-sidebar'); const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
expect( expect(
within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage), within(courseUnitSidebar).getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -340,12 +341,10 @@ describe('<CourseUnit />', () => {
}); });
it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => { it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => {
const { render(<RootWrapper />);
getByTitle, getByText, queryByRole, getByRole,
} = render(<RootWrapper />);
await waitFor(async () => { await waitFor(async () => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute( expect(iframe).toHaveAttribute(
'aria-label', 'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
@@ -356,10 +355,10 @@ describe('<CourseUnit />', () => {
usageId: courseVerticalChildrenMock.children[0].block_id, usageId: courseVerticalChildrenMock.children[0].block_id,
}); });
expect(getByText(/Delete this component?/i)).toBeInTheDocument(); expect(screen.getByText(/Delete this component?/i)).toBeInTheDocument();
expect(getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument(); expect(screen.getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument();
const dialog = getByRole('dialog'); const dialog = screen.getByRole('dialog');
expect(dialog).toBeInTheDocument(); expect(dialog).toBeInTheDocument();
// Find the Cancel and Delete buttons within the iframe by their specific classes // Find the Cancel and Delete buttons within the iframe by their specific classes
@@ -372,7 +371,7 @@ describe('<CourseUnit />', () => {
usageId: courseVerticalChildrenMock.children[0].block_id, usageId: courseVerticalChildrenMock.children[0].block_id,
}); });
expect(getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
userEvent.click(deleteButton); userEvent.click(deleteButton);
}); });
@@ -393,14 +392,14 @@ describe('<CourseUnit />', () => {
await waitFor(() => { await waitFor(() => {
// check if the sidebar status is Published and Live // check if the sidebar status is Published and Live
expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(getByText( expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on) .replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedBy}', userName), .replace('{publishedBy}', userName),
)).toBeInTheDocument(); )).toBeInTheDocument();
expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(getByText(unitDisplayName)).toBeInTheDocument(); expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
}); });
axiosMock axiosMock
@@ -431,28 +430,28 @@ describe('<CourseUnit />', () => {
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => { await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute( expect(iframe).toHaveAttribute(
'aria-label', 'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
.replace('{xblockCount}', updatedCourseVerticalChildren.length), .replace('{xblockCount}', updatedCourseVerticalChildren.length),
); );
// after removing the xblock, the sidebar status changes to Draft (unpublished changes) // after removing the xblock, the sidebar status changes to Draft (unpublished changes)
expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(getByText( expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by), .replace('{editedBy}', courseUnitIndexMock.edited_by),
)).toBeInTheDocument(); )).toBeInTheDocument();
expect(getByText( expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from), .replace('{sectionName}', courseUnitIndexMock.release_date_from),
)).toBeInTheDocument(); )).toBeInTheDocument();
@@ -460,14 +459,16 @@ describe('<CourseUnit />', () => {
}); });
it('checks if xblock is a duplicate when the corresponding duplicate button is clicked and if the sidebar status is updated', async () => { it('checks if xblock is a duplicate when the corresponding duplicate button is clicked and if the sidebar status is updated', async () => {
const { render(<RootWrapper />);
getByTitle, getByRole, getByText, queryByRole,
} = render(<RootWrapper />);
simulatePostMessageEvent(messageTypes.duplicateXBlock, { simulatePostMessageEvent(messageTypes.duplicateXBlock, {
id: courseVerticalChildrenMock.children[0].block_id, id: courseVerticalChildrenMock.children[0].block_id,
}); });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
axiosMock axiosMock
.onPost(postXBlockBaseApiUrl({ .onPost(postXBlockBaseApiUrl({
parent_locator: blockId, parent_locator: blockId,
@@ -478,8 +479,14 @@ describe('<CourseUnit />', () => {
const updatedCourseVerticalChildren = [ const updatedCourseVerticalChildren = [
...courseVerticalChildrenMock.children, ...courseVerticalChildrenMock.children,
{ {
...courseVerticalChildrenMock.children[0],
name: 'New Cloned XBlock', name: 'New Cloned XBlock',
block_id: '1234567890',
block_type: 'drag-and-drop-v2',
user_partition_info: {
selectable_partitions: [],
selected_partition_index: -1,
selected_groups_label: '',
},
}, },
]; ];
@@ -491,9 +498,9 @@ describe('<CourseUnit />', () => {
}); });
await waitFor(() => { await waitFor(() => {
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute( expect(iframe).toHaveAttribute(
'aria-label', 'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
@@ -522,14 +529,14 @@ describe('<CourseUnit />', () => {
await waitFor(() => { await waitFor(() => {
// check if the sidebar status is Published and Live // check if the sidebar status is Published and Live
expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(getByText( expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on) .replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedBy}', userName), .replace('{publishedBy}', userName),
)).toBeInTheDocument(); )).toBeInTheDocument();
expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
expect(getByText(unitDisplayName)).toBeInTheDocument(); expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
}); });
axiosMock axiosMock
@@ -538,7 +545,7 @@ describe('<CourseUnit />', () => {
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => { await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute( expect(iframe).toHaveAttribute(
'aria-label', 'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
@@ -546,21 +553,21 @@ describe('<CourseUnit />', () => {
); );
// after duplicate the xblock, the sidebar status changes to Draft (unpublished changes) // after duplicate the xblock, the sidebar status changes to Draft (unpublished changes)
expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(getByText( expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by), .replace('{editedBy}', courseUnitIndexMock.edited_by),
)).toBeInTheDocument(); )).toBeInTheDocument();
expect(getByText( expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from), .replace('{sectionName}', courseUnitIndexMock.release_date_from),
)).toBeInTheDocument(); )).toBeInTheDocument();
@@ -570,19 +577,19 @@ describe('<CourseUnit />', () => {
it('handles CourseUnit header action buttons', async () => { it('handles CourseUnit header action buttons', async () => {
const { open } = window; const { open } = window;
window.open = jest.fn(); window.open = jest.fn();
const { getByRole } = render(<RootWrapper />); render(<RootWrapper />);
const { const {
draft_preview_link: draftPreviewLink, draft_preview_link: draftPreviewLink,
published_preview_link: publishedPreviewLink, published_preview_link: publishedPreviewLink,
} = courseSectionVerticalMock; } = courseSectionVerticalMock;
await waitFor(() => { await waitFor(() => {
const viewLiveButton = getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage }); const viewLiveButton = screen.getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage });
userEvent.click(viewLiveButton); userEvent.click(viewLiveButton);
expect(window.open).toHaveBeenCalled(); expect(window.open).toHaveBeenCalled();
expect(window.open).toHaveBeenCalledWith(publishedPreviewLink, '_blank'); expect(window.open).toHaveBeenCalledWith(publishedPreviewLink, '_blank');
const previewButton = getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage }); const previewButton = screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage });
userEvent.click(previewButton); userEvent.click(previewButton);
expect(window.open).toHaveBeenCalled(); expect(window.open).toHaveBeenCalled();
expect(window.open).toHaveBeenCalledWith(draftPreviewLink, '_blank'); expect(window.open).toHaveBeenCalledWith(draftPreviewLink, '_blank');
@@ -592,12 +599,7 @@ describe('<CourseUnit />', () => {
}); });
it('checks courseUnit title changing when edit query is successfully', async () => { it('checks courseUnit title changing when edit query is successfully', async () => {
const { render(<RootWrapper />);
findByText,
queryByRole,
getByRole,
getByTestId,
} = render(<RootWrapper />);
let editTitleButton = null; let editTitleButton = null;
let titleEditField = null; let titleEditField = null;
const newDisplayName = `${unitDisplayName} new`; const newDisplayName = `${unitDisplayName} new`;
@@ -633,7 +635,7 @@ describe('<CourseUnit />', () => {
}); });
await waitFor(() => { await waitFor(() => {
const unitHeaderTitle = getByTestId('unit-header-title'); const unitHeaderTitle = screen.getByTestId('unit-header-title');
editTitleButton = within(unitHeaderTitle) editTitleButton = within(unitHeaderTitle)
.getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); .getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
titleEditField = within(unitHeaderTitle) titleEditField = within(unitHeaderTitle)
@@ -641,7 +643,7 @@ describe('<CourseUnit />', () => {
}); });
expect(titleEditField).not.toBeInTheDocument(); expect(titleEditField).not.toBeInTheDocument();
userEvent.click(editTitleButton); userEvent.click(editTitleButton);
titleEditField = getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); titleEditField = screen.getByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
await userEvent.clear(titleEditField); await userEvent.clear(titleEditField);
await userEvent.type(titleEditField, newDisplayName); await userEvent.type(titleEditField, newDisplayName);
@@ -649,9 +651,10 @@ describe('<CourseUnit />', () => {
expect(titleEditField).toHaveValue(newDisplayName); expect(titleEditField).toHaveValue(newDisplayName);
titleEditField = queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage }); titleEditField = screen.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
titleEditField = screen.queryByRole('textbox', { name: headerTitleMessages.ariaLabelButtonEdit.defaultMessage });
expect(titleEditField).not.toBeInTheDocument(); expect(titleEditField).not.toBeInTheDocument();
expect(await findByText(newDisplayName)).toBeInTheDocument(); expect(await screen.findByText(newDisplayName)).toBeInTheDocument();
}); });
it('doesn\'t handle creating xblock and displays an error message', async () => { it('doesn\'t handle creating xblock and displays an error message', async () => {
@@ -671,15 +674,14 @@ describe('<CourseUnit />', () => {
}); });
}); });
it('handle creating Problem xblock and navigate to editor page', async () => { it('handle creating Problem xblock and showing editor modal', async () => {
const { courseKey, locator } = courseCreateXblockMock;
axiosMock axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'problem', category: 'problem', parentLocator: blockId })) .onPost(postXBlockBaseApiUrl({ type: 'problem', category: 'problem', parentLocator: blockId }))
.reply(200, courseCreateXblockMock); .reply(200, courseCreateXblockMock);
const { getByText, getByRole } = render(<RootWrapper />); render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
}); });
axiosMock axiosMock
@@ -699,13 +701,12 @@ describe('<CourseUnit />', () => {
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => { await waitFor(() => {
const problemButton = getByRole('button', { const problemButton = screen.getByRole('button', {
name: new RegExp(`problem ${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'), name: new RegExp(`problem ${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'),
hidden: true,
}); });
userEvent.click(problemButton); userEvent.click(problemButton);
expect(mockedUsedNavigate).toHaveBeenCalled();
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseKey}/editor/problem/${locator}`);
}); });
axiosMock axiosMock
@@ -715,66 +716,28 @@ describe('<CourseUnit />', () => {
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
// after creating problem xblock, the sidebar status changes to Draft (unpublished changes) // after creating problem xblock, the sidebar status changes to Draft (unpublished changes)
expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(getByText( expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by), .replace('{editedBy}', courseUnitIndexMock.edited_by),
)).toBeInTheDocument(); )).toBeInTheDocument();
expect(getByText( expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from), .replace('{sectionName}', courseUnitIndexMock.release_date_from),
)).toBeInTheDocument(); )).toBeInTheDocument();
}); });
it('handle creating Text xblock and saves scroll position in localStorage', async () => {
const { getByText, getByRole } = render(<RootWrapper />);
const xblockType = 'text';
axiosMock
.onPost(postXBlockBaseApiUrl({ type: xblockType, category: 'html', parentLocator: blockId }))
.reply(200, courseCreateXblockMock);
window.scrollTo(0, 250);
Object.defineProperty(window, 'scrollY', { value: 250, configurable: true });
await waitFor(() => {
const textButton = screen.getByRole('button', { name: /Text/i });
expect(getByText(addComponentMessages.title.defaultMessage)).toBeInTheDocument();
userEvent.click(textButton);
const addXBlockDialog = getByRole('dialog');
expect(addXBlockDialog).toBeInTheDocument();
expect(getByText(
addComponentMessages.modalContainerTitle.defaultMessage.replace('{componentTitle}', xblockType),
)).toBeInTheDocument();
const textRadio = screen.getByRole('radio', { name: /Text/i });
userEvent.click(textRadio);
expect(textRadio).toBeChecked();
const selectBtn = getByRole('button', { name: addComponentMessages.modalBtnText.defaultMessage });
expect(selectBtn).toBeInTheDocument();
userEvent.click(selectBtn);
});
expect(localStorage.getItem('createXBlockLastYPosition')).toBe('250');
});
it('correct addition of a new course unit after click on the "Add new unit" button', async () => { it('correct addition of a new course unit after click on the "Add new unit" button', async () => {
const { getByRole, getAllByTestId } = render(<RootWrapper />); render(<RootWrapper />);
let units = null; let units = null;
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
@@ -784,7 +747,7 @@ describe('<CourseUnit />', () => {
]); ]);
await waitFor(async () => { await waitFor(async () => {
units = getAllByTestId('course-unit-btn'); units = screen.getAllByTestId('course-unit-btn');
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children; const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
expect(units).toHaveLength(courseUnits.length); expect(units).toHaveLength(courseUnits.length);
}); });
@@ -801,8 +764,8 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const addNewUnitBtn = getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage }); const addNewUnitBtn = screen.getByRole('button', { name: courseSequenceMessages.newUnitBtnText.defaultMessage });
units = getAllByTestId('course-unit-btn'); units = screen.getAllByTestId('course-unit-btn');
const updatedCourseUnits = updatedCourseSectionVerticalData const updatedCourseUnits = updatedCourseSectionVerticalData
.xblock_info.ancestor_info.ancestors[0].child_info.children; .xblock_info.ancestor_info.ancestors[0].child_info.children;
@@ -814,7 +777,7 @@ describe('<CourseUnit />', () => {
}); });
it('the sequence unit is updated after changing the unit header', async () => { it('the sequence unit is updated after changing the unit header', async () => {
const { getAllByTestId, getByTestId } = render(<RootWrapper />); render(<RootWrapper />);
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [ set(updatedCourseSectionVerticalData, 'xblock_info.ancestor_info.ancestors[0].child_info.children', [
@@ -846,7 +809,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
const unitHeaderTitle = getByTestId('unit-header-title'); const unitHeaderTitle = screen.getByTestId('unit-header-title');
const editTitleButton = within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage }); const editTitleButton = within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage });
userEvent.click(editTitleButton); userEvent.click(editTitleButton);
@@ -858,20 +821,21 @@ describe('<CourseUnit />', () => {
await userEvent.tab(); await userEvent.tab();
await waitFor(async () => { await waitFor(async () => {
const units = getAllByTestId('course-unit-btn'); const units = screen.getAllByTestId('course-unit-btn');
expect(units.some(unit => unit.title === newDisplayName)).toBe(true); expect(units.some(unit => unit.title === newDisplayName)).toBe(true);
}); });
}); });
it('handles creating Video xblock and navigates to editor page', async () => { it('handles creating Video xblock and showing editor modal using videogalleryflow', async () => {
const { courseKey, locator } = courseCreateXblockMock; const waffleSpy = jest.spyOn(selectors, 'getWaffleFlags').mockReturnValue({ useVideoGalleryFlow: true });
axiosMock axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId })) .onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
.reply(200, courseCreateXblockMock); .reply(200, courseCreateXblockMock);
const { getByText, queryByRole, getByRole } = render(<RootWrapper />); render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })); userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
}); });
axiosMock axiosMock
@@ -892,23 +856,23 @@ describe('<CourseUnit />', () => {
await waitFor(() => { await waitFor(() => {
// check if the sidebar status is Published and Live // check if the sidebar status is Published and Live
expect(getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
const videoButton = getByRole('button', {
name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'),
});
userEvent.click(videoButton);
expect(mockedUsedNavigate).toHaveBeenCalled();
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseKey}/editor/video/${locator}`);
}); });
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
const videoButton = screen.getByRole('button', {
name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'),
hidden: true,
});
userEvent.click(videoButton);
axiosMock axiosMock
.onGet(getCourseUnitApiUrl(blockId)) .onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock); .reply(200, courseUnitIndexMock);
@@ -916,45 +880,124 @@ describe('<CourseUnit />', () => {
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
// after creating video xblock, the sidebar status changes to Draft (unpublished changes) // after creating video xblock, the sidebar status changes to Draft (unpublished changes)
expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(getByText( expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by), .replace('{editedBy}', courseUnitIndexMock.edited_by),
)).toBeInTheDocument(); )).toBeInTheDocument();
expect(getByText( expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from),
)).toBeInTheDocument();
expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument();
waffleSpy.mockRestore();
});
it('handles creating Video xblock and showing editor modal', async () => {
axiosMock
.onPost(postXBlockBaseApiUrl({ type: 'video', category: 'video', parentLocator: blockId }))
.reply(200, courseCreateXblockMock);
render(<RootWrapper />);
await waitFor(() => {
userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }));
});
axiosMock
.onPost(getXBlockBaseApiUrl(blockId), {
publish: PUBLISH_TYPES.makePublic,
})
.reply(200, { dummy: 'value' });
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, {
...courseUnitIndexMock,
visibility_state: UNIT_VISIBILITY_STATES.live,
has_changes: false,
published_by: userName,
});
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
await waitFor(() => {
// check if the sidebar status is Published and Live
expect(screen.getByText(sidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishLastPublished.defaultMessage
.replace('{publishedOn}', courseUnitIndexMock.published_on)
.replace('{publishedBy}', userName),
)).toBeInTheDocument();
expect(screen.queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument();
const videoButton = screen.getByRole('button', {
name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'),
hidden: true,
});
userEvent.click(videoButton);
});
/** TODO -- fix this test.
await waitFor(() => {
expect(getByRole('textbox', { name: /paste your video id or url/i })).toBeInTheDocument();
});
*/
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch);
// after creating video xblock, the sidebar status changes to Draft (unpublished changes)
expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by),
)).toBeInTheDocument();
expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from), .replace('{sectionName}', courseUnitIndexMock.release_date_from),
)).toBeInTheDocument(); )).toBeInTheDocument();
}); });
it('renders course unit details for a draft with unpublished changes', async () => { it('renders course unit details for a draft with unpublished changes', async () => {
const { getByText } = render(<RootWrapper />); render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
expect(getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityStaffAndLearnersTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarBodyNote.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityWillBeVisibleToTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitIndexMock.release_date)).toBeInTheDocument(); expect(screen.getByText(courseUnitIndexMock.release_date)).toBeInTheDocument();
expect(getByText( expect(screen.getByText(
sidebarMessages.publishInfoDraftSaved.defaultMessage sidebarMessages.publishInfoDraftSaved.defaultMessage
.replace('{editedOn}', courseUnitIndexMock.edited_on) .replace('{editedOn}', courseUnitIndexMock.edited_on)
.replace('{editedBy}', courseUnitIndexMock.edited_by), .replace('{editedBy}', courseUnitIndexMock.edited_by),
)).toBeInTheDocument(); )).toBeInTheDocument();
expect(getByText( expect(screen.getByText(
sidebarMessages.releaseInfoWithSection.defaultMessage sidebarMessages.releaseInfoWithSection.defaultMessage
.replace('{sectionName}', courseUnitIndexMock.release_date_from), .replace('{sectionName}', courseUnitIndexMock.release_date_from),
)).toBeInTheDocument(); )).toBeInTheDocument();
@@ -962,14 +1005,14 @@ describe('<CourseUnit />', () => {
}); });
it('renders course unit details in the sidebar', async () => { it('renders course unit details in the sidebar', async () => {
const { getByText } = render(<RootWrapper />); render(<RootWrapper />);
const courseUnitLocationId = extractCourseUnitId(courseUnitIndexMock.id); const courseUnitLocationId = extractCourseUnitId(courseUnitIndexMock.id);
await waitFor(() => { await waitFor(() => {
expect(getByText(sidebarMessages.sidebarHeaderUnitLocationTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.sidebarHeaderUnitLocationTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(sidebarMessages.unitLocationTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(sidebarMessages.unitLocationTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(courseUnitLocationId)).toBeInTheDocument(); expect(screen.getByText(courseUnitLocationId)).toBeInTheDocument();
expect(getByText(sidebarMessages.unitLocationDescription.defaultMessage expect(screen.getByText(sidebarMessages.unitLocationDescription.defaultMessage
.replace('{id}', courseUnitLocationId))).toBeInTheDocument(); .replace('{id}', courseUnitLocationId))).toBeInTheDocument();
}); });
}); });
@@ -1007,13 +1050,13 @@ describe('<CourseUnit />', () => {
}); });
it('should toggle visibility from sidebar and update course unit state accordingly', async () => { it('should toggle visibility from sidebar and update course unit state accordingly', async () => {
const { getByRole, getByTestId } = render(<RootWrapper />); render(<RootWrapper />);
let courseUnitSidebar; let courseUnitSidebar;
let draftUnpublishedChangesHeading; let draftUnpublishedChangesHeading;
let visibilityCheckbox; let visibilityCheckbox;
await waitFor(() => { await waitFor(() => {
courseUnitSidebar = getByTestId('course-unit-sidebar'); courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
draftUnpublishedChangesHeading = within(courseUnitSidebar) draftUnpublishedChangesHeading = within(courseUnitSidebar)
.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage); .getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage);
@@ -1050,7 +1093,7 @@ describe('<CourseUnit />', () => {
userEvent.click(visibilityCheckbox); userEvent.click(visibilityCheckbox);
const modalNotification = getByRole('dialog'); const modalNotification = screen.getByRole('dialog');
const makeVisibilityBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalMakeVisibilityActionButtonText.defaultMessage }); const makeVisibilityBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalMakeVisibilityActionButtonText.defaultMessage });
const cancelBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalMakeVisibilityCancelButtonText.defaultMessage }); const cancelBtn = within(modalNotification).getByRole('button', { name: sidebarMessages.modalMakeVisibilityCancelButtonText.defaultMessage });
const headingElement = within(modalNotification).getByRole('heading', { name: sidebarMessages.modalMakeVisibilityTitle.defaultMessage, class: 'pgn__modal-title' }); const headingElement = within(modalNotification).getByRole('heading', { name: sidebarMessages.modalMakeVisibilityTitle.defaultMessage, class: 'pgn__modal-title' });
@@ -1082,12 +1125,12 @@ describe('<CourseUnit />', () => {
}); });
it('should publish course unit after click on the "Publish" button', async () => { it('should publish course unit after click on the "Publish" button', async () => {
const { getByTestId } = render(<RootWrapper />); render(<RootWrapper />);
let courseUnitSidebar; let courseUnitSidebar;
let publishBtn; let publishBtn;
await waitFor(() => { await waitFor(() => {
courseUnitSidebar = getByTestId('course-unit-sidebar'); courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
publishBtn = within(courseUnitSidebar).queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }); publishBtn = within(courseUnitSidebar).queryByRole('button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage });
expect(publishBtn).toBeInTheDocument(); expect(publishBtn).toBeInTheDocument();
@@ -1121,12 +1164,12 @@ describe('<CourseUnit />', () => {
}); });
it('should discard changes after click on the "Discard changes" button', async () => { it('should discard changes after click on the "Discard changes" button', async () => {
const { getByTestId, getByRole } = render(<RootWrapper />); render(<RootWrapper />);
let courseUnitSidebar; let courseUnitSidebar;
let discardChangesBtn; let discardChangesBtn;
await waitFor(() => { await waitFor(() => {
courseUnitSidebar = getByTestId('course-unit-sidebar'); courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
const draftUnpublishedChangesHeading = within(courseUnitSidebar) const draftUnpublishedChangesHeading = within(courseUnitSidebar)
.getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage); .getByText(sidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage);
@@ -1136,7 +1179,7 @@ describe('<CourseUnit />', () => {
userEvent.click(discardChangesBtn); userEvent.click(discardChangesBtn);
const modalNotification = getByRole('dialog'); const modalNotification = screen.getByRole('dialog');
expect(modalNotification).toBeInTheDocument(); expect(modalNotification).toBeInTheDocument();
expect(within(modalNotification) expect(within(modalNotification)
.getByText(sidebarMessages.modalDiscardUnitChangesDescription.defaultMessage)).toBeInTheDocument(); .getByText(sidebarMessages.modalDiscardUnitChangesDescription.defaultMessage)).toBeInTheDocument();
@@ -1173,7 +1216,7 @@ describe('<CourseUnit />', () => {
}); });
it('should toggle visibility from header configure modal and update course unit state accordingly', async () => { it('should toggle visibility from header configure modal and update course unit state accordingly', async () => {
const { getByRole, getByTestId } = render(<RootWrapper />); render(<RootWrapper />);
let courseUnitSidebar; let courseUnitSidebar;
let sidebarVisibilityCheckbox; let sidebarVisibilityCheckbox;
let modalVisibilityCheckbox; let modalVisibilityCheckbox;
@@ -1181,16 +1224,16 @@ describe('<CourseUnit />', () => {
let restrictAccessSelect; let restrictAccessSelect;
await waitFor(() => { await waitFor(() => {
courseUnitSidebar = getByTestId('course-unit-sidebar'); courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
sidebarVisibilityCheckbox = within(courseUnitSidebar) sidebarVisibilityCheckbox = within(courseUnitSidebar)
.getByLabelText(sidebarMessages.visibilityCheckboxTitle.defaultMessage); .getByLabelText(sidebarMessages.visibilityCheckboxTitle.defaultMessage);
expect(sidebarVisibilityCheckbox).not.toBeChecked(); expect(sidebarVisibilityCheckbox).not.toBeChecked();
const headerConfigureBtn = getByRole('button', { name: /settings/i }); const headerConfigureBtn = screen.getByRole('button', { name: /settings/i });
expect(headerConfigureBtn).toBeInTheDocument(); expect(headerConfigureBtn).toBeInTheDocument();
userEvent.click(headerConfigureBtn); userEvent.click(headerConfigureBtn);
configureModal = getByTestId('configure-modal'); configureModal = screen.getByTestId('configure-modal');
restrictAccessSelect = within(configureModal) restrictAccessSelect = within(configureModal)
.getByRole('combobox', { name: configureModalMessages.restrictAccessTo.defaultMessage }); .getByRole('combobox', { name: configureModalMessages.restrictAccessTo.defaultMessage });
expect(within(configureModal) expect(within(configureModal)
@@ -1246,8 +1289,8 @@ describe('<CourseUnit />', () => {
...getConfig(), ...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true', ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
}); });
const { getByText } = render(<RootWrapper />); render(<RootWrapper />);
await waitFor(() => { expect(getByText('Unit tags')).toBeInTheDocument(); }); await waitFor(() => { expect(screen.getByText('Unit tags')).toBeInTheDocument(); });
}); });
it('hides the Tags sidebar when not enabled', async () => { it('hides the Tags sidebar when not enabled', async () => {
@@ -1255,15 +1298,13 @@ describe('<CourseUnit />', () => {
...getConfig(), ...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'false', ENABLE_TAGGING_TAXONOMY_PAGES: 'false',
}); });
const { queryByText } = render(<RootWrapper />); render(<RootWrapper />);
await waitFor(() => { expect(queryByText('Unit tags')).not.toBeInTheDocument(); }); await waitFor(() => { expect(screen.queryByText('Unit tags')).not.toBeInTheDocument(); });
}); });
describe('Copy paste functionality', () => { describe('Copy paste functionality', () => {
it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => { it('should copy a unit, paste it as a new unit, and update the course section vertical data', async () => {
const { render(<RootWrapper />);
getAllByTestId, getByRole,
} = render(<RootWrapper />);
axiosMock axiosMock
.onGet(getCourseUnitApiUrl(courseId)) .onGet(getCourseUnitApiUrl(courseId))
@@ -1275,8 +1316,8 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
let units = null; let units = null;
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
@@ -1287,7 +1328,7 @@ describe('<CourseUnit />', () => {
]); ]);
await waitFor(() => { await waitFor(() => {
units = getAllByTestId('course-unit-btn'); units = screen.getAllByTestId('course-unit-btn');
const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children; const courseUnits = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[0].child_info.children;
expect(units).toHaveLength(courseUnits.length); expect(units).toHaveLength(courseUnits.length);
}); });
@@ -1303,7 +1344,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
units = getAllByTestId('course-unit-btn'); units = screen.getAllByTestId('course-unit-btn');
const updatedCourseUnits = updatedCourseSectionVerticalData const updatedCourseUnits = updatedCourseSectionVerticalData
.xblock_info.ancestor_info.ancestors[0].child_info.children; .xblock_info.ancestor_info.ancestors[0].child_info.children;
@@ -1314,7 +1355,7 @@ describe('<CourseUnit />', () => {
}); });
it('should increase the number of course XBlocks after copying and pasting a block', async () => { it('should increase the number of course XBlocks after copying and pasting a block', async () => {
const { getByRole, getByTitle } = render(<RootWrapper />); render(<RootWrapper />);
simulatePostMessageEvent(messageTypes.copyXBlock, { simulatePostMessageEvent(messageTypes.copyXBlock, {
id: courseVerticalChildrenMock.children[0].block_id, id: courseVerticalChildrenMock.children[0].block_id,
@@ -1333,11 +1374,11 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: messages.pasteButtonText.defaultMessage })); userEvent.click(screen.getByRole('button', { name: messages.pasteButtonText.defaultMessage }));
await waitFor(() => { await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute( expect(iframe).toHaveAttribute(
'aria-label', 'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
@@ -1373,7 +1414,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
await waitFor(() => { await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute( expect(iframe).toHaveAttribute(
'aria-label', 'aria-label',
xblockContainerIframeMessages.xblockIframeLabel.defaultMessage xblockContainerIframeMessages.xblockIframeLabel.defaultMessage
@@ -1383,9 +1424,7 @@ describe('<CourseUnit />', () => {
}); });
it('displays a notification about new files after pasting a component', async () => { it('displays a notification about new files after pasting a component', async () => {
const { render(<RootWrapper />);
queryByTestId, getByTestId, getByRole,
} = render(<RootWrapper />);
axiosMock axiosMock
.onGet(getCourseUnitApiUrl(courseId)) .onGet(getCourseUnitApiUrl(courseId))
@@ -1397,8 +1436,8 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
@@ -1417,7 +1456,7 @@ describe('<CourseUnit />', () => {
global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices)); global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices));
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch); await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch);
const newFilesAlert = getByTestId('has-new-files-alert'); const newFilesAlert = screen.getByTestId('has-new-files-alert');
expect(within(newFilesAlert) expect(within(newFilesAlert)
.getByText(pasteNotificationsMessages.hasNewFilesTitle.defaultMessage)).toBeInTheDocument(); .getByText(pasteNotificationsMessages.hasNewFilesTitle.defaultMessage)).toBeInTheDocument();
@@ -1431,13 +1470,11 @@ describe('<CourseUnit />', () => {
userEvent.click(within(newFilesAlert).getByText(/Dismiss/i)); userEvent.click(within(newFilesAlert).getByText(/Dismiss/i));
expect(queryByTestId('has-new-files-alert')).toBeNull(); expect(screen.queryByTestId('has-new-files-alert')).toBeNull();
}); });
it('displays a notification about conflicting errors after pasting a component', async () => { it('displays a notification about conflicting errors after pasting a component', async () => {
const { render(<RootWrapper />);
queryByTestId, getByTestId, getByRole,
} = render(<RootWrapper />);
axiosMock axiosMock
.onGet(getCourseUnitApiUrl(courseId)) .onGet(getCourseUnitApiUrl(courseId))
@@ -1449,8 +1486,8 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
@@ -1471,7 +1508,7 @@ describe('<CourseUnit />', () => {
global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices)); global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices));
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch); await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch);
const conflictingErrorsAlert = getByTestId('has-conflicting-errors-alert'); const conflictingErrorsAlert = screen.getByTestId('has-conflicting-errors-alert');
expect(within(conflictingErrorsAlert) expect(within(conflictingErrorsAlert)
.getByText(pasteNotificationsMessages.hasConflictingErrorsTitle.defaultMessage)).toBeInTheDocument(); .getByText(pasteNotificationsMessages.hasConflictingErrorsTitle.defaultMessage)).toBeInTheDocument();
@@ -1485,13 +1522,11 @@ describe('<CourseUnit />', () => {
userEvent.click(within(conflictingErrorsAlert).getByText(/Dismiss/i)); userEvent.click(within(conflictingErrorsAlert).getByText(/Dismiss/i));
expect(queryByTestId('has-conflicting-errors-alert')).toBeNull(); expect(screen.queryByTestId('has-conflicting-errors-alert')).toBeNull();
}); });
it('displays a notification about error files after pasting a component', async () => { it('displays a notification about error files after pasting a component', async () => {
const { render(<RootWrapper />);
queryByTestId, getByTestId, getByRole,
} = render(<RootWrapper />);
axiosMock axiosMock
.onGet(getCourseUnitApiUrl(courseId)) .onGet(getCourseUnitApiUrl(courseId))
@@ -1503,8 +1538,8 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
userEvent.click(getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage })); userEvent.click(screen.getByRole('button', { name: sidebarMessages.actionButtonCopyUnitTitle.defaultMessage }));
userEvent.click(getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage })); userEvent.click(screen.getByRole('button', { name: courseSequenceMessages.pasteAsNewUnitLink.defaultMessage }));
const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock); const updatedCourseSectionVerticalData = cloneDeep(courseSectionVerticalMock);
const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0]; const updatedAncestorsChild = updatedCourseSectionVerticalData.xblock_info.ancestor_info.ancestors[0];
@@ -1525,7 +1560,7 @@ describe('<CourseUnit />', () => {
global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices)); global.localStorage.setItem('staticFileNotices', JSON.stringify(clipboardMockResponse.staticFileNotices));
await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch); await executeThunk(fetchCourseSectionVerticalData(blockId), store.dispatch);
await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch); await executeThunk(createNewCourseXBlock(camelCaseObject(postXBlockBody), null, blockId), store.dispatch);
const errorFilesAlert = getByTestId('has-error-files-alert'); const errorFilesAlert = screen.getByTestId('has-error-files-alert');
expect(within(errorFilesAlert) expect(within(errorFilesAlert)
.getByText(pasteNotificationsMessages.hasErrorsTitle.defaultMessage)).toBeInTheDocument(); .getByText(pasteNotificationsMessages.hasErrorsTitle.defaultMessage)).toBeInTheDocument();
@@ -1534,11 +1569,11 @@ describe('<CourseUnit />', () => {
userEvent.click(within(errorFilesAlert).getByText(/Dismiss/i)); userEvent.click(within(errorFilesAlert).getByText(/Dismiss/i));
expect(queryByTestId('has-error-files')).toBeNull(); expect(screen.queryByTestId('has-error-files')).toBeNull();
}); });
it('should hide the "Paste component" block if canPasteComponent is false', async () => { it('should hide the "Paste component" block if canPasteComponent is false', async () => {
const { queryByText, queryByRole } = render(<RootWrapper />); render(<RootWrapper />);
axiosMock axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId)) .onGet(getCourseVerticalChildrenApiUrl(blockId))
@@ -1549,10 +1584,10 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
expect(queryByRole('button', { expect(screen.queryByRole('button', {
name: messages.pasteButtonText.defaultMessage, name: messages.pasteButtonText.defaultMessage,
})).not.toBeInTheDocument(); })).not.toBeInTheDocument();
expect(queryByText( expect(screen.queryByText(
pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage, pasteComponentMessages.pasteButtonWhatsInClipboardText.defaultMessage,
)).not.toBeInTheDocument(); )).not.toBeInTheDocument();
}); });
@@ -1586,9 +1621,7 @@ describe('<CourseUnit />', () => {
}); });
it('should display "Move Modal" on receive trigger message', async () => { it('should display "Move Modal" on receive trigger message', async () => {
const { render(<RootWrapper />);
getByRole,
} = render(<RootWrapper />);
await screen.findByText(unitDisplayName); await screen.findByText(unitDisplayName);
@@ -1602,15 +1635,12 @@ describe('<CourseUnit />', () => {
await screen.findByText( await screen.findByText(
moveModalMessages.moveModalTitle.defaultMessage.replace('{displayName}', requestData.title), moveModalMessages.moveModalTitle.defaultMessage.replace('{displayName}', requestData.title),
); );
expect(getByRole('button', { name: moveModalMessages.moveModalSubmitButton.defaultMessage })).toBeInTheDocument(); expect(screen.getByRole('button', { name: moveModalMessages.moveModalSubmitButton.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: moveModalMessages.moveModalCancelButton.defaultMessage })).toBeInTheDocument(); expect(screen.getByRole('button', { name: moveModalMessages.moveModalCancelButton.defaultMessage })).toBeInTheDocument();
}); });
it('should navigates to xBlock current unit', async () => { it('should navigates to xBlock current unit', async () => {
const { render(<RootWrapper />);
getByText,
getByRole,
} = render(<RootWrapper />);
await screen.findByText(unitDisplayName); await screen.findByText(unitDisplayName);
@@ -1626,7 +1656,7 @@ describe('<CourseUnit />', () => {
); );
const currentSection = courseOutlineInfoMock.child_info.children[1]; const currentSection = courseOutlineInfoMock.child_info.children[1];
const currentSectionItemBtn = getByRole('button', { const currentSectionItemBtn = screen.getByRole('button', {
name: `${currentSection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, name: `${currentSection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
}); });
expect(currentSectionItemBtn).toBeInTheDocument(); expect(currentSectionItemBtn).toBeInTheDocument();
@@ -1634,7 +1664,7 @@ describe('<CourseUnit />', () => {
await waitFor(() => { await waitFor(() => {
const currentSubsection = currentSection.child_info.children[0]; const currentSubsection = currentSection.child_info.children[0];
const currentSubsectionItemBtn = getByRole('button', { const currentSubsectionItemBtn = screen.getByRole('button', {
name: `${currentSubsection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, name: `${currentSubsection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
}); });
expect(currentSubsectionItemBtn).toBeInTheDocument(); expect(currentSubsectionItemBtn).toBeInTheDocument();
@@ -1642,7 +1672,7 @@ describe('<CourseUnit />', () => {
}); });
await waitFor(() => { await waitFor(() => {
const currentComponentLocationText = getByText( const currentComponentLocationText = screen.getByText(
moveModalMessages.moveModalOutlineItemCurrentComponentLocationText.defaultMessage, moveModalMessages.moveModalOutlineItemCurrentComponentLocationText.defaultMessage,
); );
expect(currentComponentLocationText).toBeInTheDocument(); expect(currentComponentLocationText).toBeInTheDocument();
@@ -1650,9 +1680,7 @@ describe('<CourseUnit />', () => {
}); });
it('should allow move operation and handles it successfully', async () => { it('should allow move operation and handles it successfully', async () => {
const { render(<RootWrapper />);
getByRole,
} = render(<RootWrapper />);
axiosMock axiosMock
.onPatch(postXBlockBaseApiUrl()) .onPatch(postXBlockBaseApiUrl())
@@ -1676,7 +1704,7 @@ describe('<CourseUnit />', () => {
); );
const currentSection = courseOutlineInfoMock.child_info.children[1]; const currentSection = courseOutlineInfoMock.child_info.children[1];
const currentSectionItemBtn = getByRole('button', { const currentSectionItemBtn = screen.getByRole('button', {
name: `${currentSection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, name: `${currentSection.display_name} ${moveModalMessages.moveModalOutlineItemCurrentLocationText.defaultMessage} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
}); });
expect(currentSectionItemBtn).toBeInTheDocument(); expect(currentSectionItemBtn).toBeInTheDocument();
@@ -1684,7 +1712,7 @@ describe('<CourseUnit />', () => {
const currentSubsection = currentSection.child_info.children[1]; const currentSubsection = currentSection.child_info.children[1];
await waitFor(() => { await waitFor(() => {
const currentSubsectionItemBtn = getByRole('button', { const currentSubsectionItemBtn = screen.getByRole('button', {
name: `${currentSubsection.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, name: `${currentSubsection.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
}); });
expect(currentSubsectionItemBtn).toBeInTheDocument(); expect(currentSubsectionItemBtn).toBeInTheDocument();
@@ -1693,14 +1721,14 @@ describe('<CourseUnit />', () => {
await waitFor(() => { await waitFor(() => {
const currentUnit = currentSubsection.child_info.children[0]; const currentUnit = currentSubsection.child_info.children[0];
const currentUnitItemBtn = getByRole('button', { const currentUnitItemBtn = screen.getByRole('button', {
name: `${currentUnit.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`, name: `${currentUnit.display_name} ${moveModalMessages.moveModalOutlineItemViewText.defaultMessage}`,
}); });
expect(currentUnitItemBtn).toBeInTheDocument(); expect(currentUnitItemBtn).toBeInTheDocument();
userEvent.click(currentUnitItemBtn); userEvent.click(currentUnitItemBtn);
}); });
const moveModalBtn = getByRole('button', { const moveModalBtn = screen.getByRole('button', {
name: moveModalMessages.moveModalSubmitButton.defaultMessage, name: moveModalMessages.moveModalSubmitButton.defaultMessage,
}); });
expect(moveModalBtn).toBeInTheDocument(); expect(moveModalBtn).toBeInTheDocument();
@@ -1714,10 +1742,7 @@ describe('<CourseUnit />', () => {
}); });
it('should display "Move Confirmation" alert after moving and undo operations', async () => { it('should display "Move Confirmation" alert after moving and undo operations', async () => {
const { render(<RootWrapper />);
queryByRole,
getByText,
} = render(<RootWrapper />);
axiosMock axiosMock
.onPatch(postXBlockBaseApiUrl()) .onPatch(postXBlockBaseApiUrl())
@@ -1734,18 +1759,18 @@ describe('<CourseUnit />', () => {
simulatePostMessageEvent(messageTypes.rollbackMovedXBlock, { locator: requestData.sourceLocator }); simulatePostMessageEvent(messageTypes.rollbackMovedXBlock, { locator: requestData.sourceLocator });
const dismissButton = queryByRole('button', { const dismissButton = screen.queryByRole('button', {
name: /dismiss/i, hidden: true, name: /dismiss/i, hidden: true,
}); });
const undoButton = queryByRole('button', { const undoButton = screen.queryByRole('button', {
name: messages.undoMoveButton.defaultMessage, hidden: true, name: messages.undoMoveButton.defaultMessage, hidden: true,
}); });
const newLocationButton = queryByRole('button', { const newLocationButton = screen.queryByRole('button', {
name: messages.newLocationButton.defaultMessage, hidden: true, name: messages.newLocationButton.defaultMessage, hidden: true,
}); });
expect(getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(`${requestData.title} has been moved`)).toBeInTheDocument(); expect(screen.getByText(`${requestData.title} has been moved`)).toBeInTheDocument();
expect(dismissButton).toBeInTheDocument(); expect(dismissButton).toBeInTheDocument();
expect(undoButton).toBeInTheDocument(); expect(undoButton).toBeInTheDocument();
expect(newLocationButton).toBeInTheDocument(); expect(newLocationButton).toBeInTheDocument();
@@ -1753,9 +1778,9 @@ describe('<CourseUnit />', () => {
userEvent.click(undoButton); userEvent.click(undoButton);
await waitFor(() => { await waitFor(() => {
expect(getByText(messages.alertMoveCancelTitle.defaultMessage)).toBeInTheDocument(); expect(screen.getByText(messages.alertMoveCancelTitle.defaultMessage)).toBeInTheDocument();
}); });
expect(getByText( expect(screen.getByText(
messages.alertMoveCancelDescription.defaultMessage.replace('{title}', requestData.title), messages.alertMoveCancelDescription.defaultMessage.replace('{title}', requestData.title),
)).toBeInTheDocument(); )).toBeInTheDocument();
expect(dismissButton).toBeInTheDocument(); expect(dismissButton).toBeInTheDocument();
@@ -1764,9 +1789,7 @@ describe('<CourseUnit />', () => {
}); });
it('should navigate to new location by button click', async () => { it('should navigate to new location by button click', async () => {
const { render(<RootWrapper />);
queryByRole,
} = render(<RootWrapper />);
axiosMock axiosMock
.onPatch(postXBlockBaseApiUrl()) .onPatch(postXBlockBaseApiUrl())
@@ -1781,7 +1804,7 @@ describe('<CourseUnit />', () => {
callbackFn: requestData.callbackFn, callbackFn: requestData.callbackFn,
}), store.dispatch); }), store.dispatch);
const newLocationButton = queryByRole('button', { const newLocationButton = screen.queryByRole('button', {
name: messages.newLocationButton.defaultMessage, hidden: true, name: messages.newLocationButton.defaultMessage, hidden: true,
}); });
userEvent.click(newLocationButton); userEvent.click(newLocationButton);
@@ -1794,16 +1817,14 @@ describe('<CourseUnit />', () => {
describe('XBlock restrict access', () => { describe('XBlock restrict access', () => {
it('opens xblock restrict access modal successfully', async () => { it('opens xblock restrict access modal successfully', async () => {
const { render(<RootWrapper />);
getByTitle, getByTestId,
} = render(<RootWrapper />);
const modalSubtitleText = configureModalMessages.restrictAccessTo.defaultMessage; const modalSubtitleText = configureModalMessages.restrictAccessTo.defaultMessage;
const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage; const modalCancelBtnText = configureModalMessages.cancelButton.defaultMessage;
const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage; const modalSaveBtnText = configureModalMessages.saveButton.defaultMessage;
await waitFor(() => { await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
const usageId = courseVerticalChildrenMock.children[0].block_id; const usageId = courseVerticalChildrenMock.children[0].block_id;
expect(iframe).toBeInTheDocument(); expect(iframe).toBeInTheDocument();
@@ -1813,7 +1834,7 @@ describe('<CourseUnit />', () => {
}); });
await waitFor(() => { await waitFor(() => {
const configureModal = getByTestId('configure-modal'); const configureModal = screen.getByTestId('configure-modal');
expect(within(configureModal).getByText(modalSubtitleText)).toBeInTheDocument(); expect(within(configureModal).getByText(modalSubtitleText)).toBeInTheDocument();
expect(within(configureModal).getByRole('button', { name: modalCancelBtnText })).toBeInTheDocument(); expect(within(configureModal).getByRole('button', { name: modalCancelBtnText })).toBeInTheDocument();
@@ -1822,12 +1843,10 @@ describe('<CourseUnit />', () => {
}); });
it('closes xblock restrict access modal when cancel button is clicked', async () => { it('closes xblock restrict access modal when cancel button is clicked', async () => {
const { render(<RootWrapper />);
getByTitle, queryByTestId, getByTestId,
} = render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument(); expect(iframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.manageXBlockAccess, { simulatePostMessageEvent(messageTypes.manageXBlockAccess, {
usageId: courseVerticalChildrenMock.children[0].block_id, usageId: courseVerticalChildrenMock.children[0].block_id,
@@ -1835,7 +1854,7 @@ describe('<CourseUnit />', () => {
}); });
await waitFor(() => { await waitFor(() => {
const configureModal = getByTestId('configure-modal'); const configureModal = screen.getByTestId('configure-modal');
expect(configureModal).toBeInTheDocument(); expect(configureModal).toBeInTheDocument();
userEvent.click(within(configureModal).getByRole('button', { userEvent.click(within(configureModal).getByRole('button', {
name: configureModalMessages.cancelButton.defaultMessage, name: configureModalMessages.cancelButton.defaultMessage,
@@ -1843,7 +1862,7 @@ describe('<CourseUnit />', () => {
expect(handleConfigureSubmitMock).not.toHaveBeenCalled(); expect(handleConfigureSubmitMock).not.toHaveBeenCalled();
}); });
expect(queryByTestId('configure-modal')).not.toBeInTheDocument(); expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument();
}); });
it('handles submit xblock restrict access data when save button is clicked', async () => { it('handles submit xblock restrict access data when save button is clicked', async () => {
@@ -1854,15 +1873,13 @@ describe('<CourseUnit />', () => {
}) })
.reply(200, { dummy: 'value' }); .reply(200, { dummy: 'value' });
const { render(<RootWrapper />);
getByTitle, getByRole, getByTestId, queryByTestId,
} = render(<RootWrapper />);
const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name; const accessGroupName1 = userPartitionInfoFormatted.selectablePartitions[0].groups[0].name;
const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name; const accessGroupName2 = userPartitionInfoFormatted.selectablePartitions[0].groups[1].name;
await waitFor(() => { await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument(); expect(iframe).toBeInTheDocument();
}); });
@@ -1872,13 +1889,13 @@ describe('<CourseUnit />', () => {
}); });
}); });
const configureModal = await waitFor(() => getByTestId('configure-modal')); const configureModal = await waitFor(() => screen.getByTestId('configure-modal'));
expect(configureModal).toBeInTheDocument(); expect(configureModal).toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument(); expect(within(configureModal).queryByText(accessGroupName1)).not.toBeInTheDocument();
expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument(); expect(within(configureModal).queryByText(accessGroupName2)).not.toBeInTheDocument();
const restrictAccessSelect = getByRole('combobox', { const restrictAccessSelect = screen.getByRole('combobox', {
name: configureModalMessages.restrictAccessTo.defaultMessage, name: configureModalMessages.restrictAccessTo.defaultMessage,
}); });
@@ -1908,17 +1925,17 @@ describe('<CourseUnit />', () => {
expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl(id)); expect(axiosMock.history.post[0].url).toBe(getXBlockBaseApiUrl(id));
}); });
expect(queryByTestId('configure-modal')).not.toBeInTheDocument(); expect(screen.queryByTestId('configure-modal')).not.toBeInTheDocument();
}); });
}); });
const checkLegacyEditModalOnEditMessage = async () => { const checkLegacyEditModalOnEditMessage = async () => {
const { getByTitle, getByTestId } = render(<RootWrapper />); render(<RootWrapper />);
await waitFor(() => { await waitFor(() => {
const editButton = getByTestId('header-edit-button'); const editButton = screen.getByTestId('header-edit-button');
expect(editButton).toBeInTheDocument(); expect(editButton).toBeInTheDocument();
const xblocksIframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const xblocksIframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(xblocksIframe).toBeInTheDocument(); expect(xblocksIframe).toBeInTheDocument();
userEvent.click(editButton); userEvent.click(editButton);
}); });
@@ -2105,46 +2122,65 @@ describe('<CourseUnit />', () => {
}); });
it('should render split test content page correctly', async () => { it('should render split test content page correctly', async () => {
const { render(<RootWrapper />);
getByText,
getByRole,
queryByRole,
getByTestId,
queryByText,
} = render(<RootWrapper />);
const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; const currentSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name; const currentSubSectionName = courseUnitIndexMock.ancestor_info.ancestors[1].display_name;
const helpLinkUrl = 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_components.html#components-that-contain-other-components'; const helpLinkUrl = 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_components.html#components-that-contain-other-components';
waitFor(() => { waitFor(() => {
const unitHeaderTitle = getByTestId('unit-header-title'); const unitHeaderTitle = screen.getByTestId('unit-header-title');
expect(getByText(unitDisplayName)).toBeInTheDocument(); expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument();
expect(getByRole('button', { name: currentSectionName })).toBeInTheDocument(); expect(screen.getByRole('button', { name: currentSectionName })).toBeInTheDocument();
expect(getByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); expect(screen.getByRole('button', { name: currentSubSectionName })).toBeInTheDocument();
expect(queryByRole('heading', { name: addComponentMessages.title.defaultMessage })).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: addComponentMessages.title.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).not.toBeInTheDocument();
expect(queryByRole('heading', { name: /unit tags/i })).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: /unit tags/i })).not.toBeInTheDocument();
expect(queryByRole('heading', { name: /unit location/i })).not.toBeInTheDocument(); expect(screen.queryByRole('heading', { name: /unit location/i })).not.toBeInTheDocument();
// Sidebar // Sidebar
const sidebarContent = [ const sidebarContent = [
{ query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestAddComponentTitle.defaultMessage }, { query: screen.queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestAddComponentTitle.defaultMessage },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestSelectComponentType.defaultMessage.replaceAll('{bold_tag}', '') }, { query: screen.queryByText, name: sidebarMessages.sidebarSplitTestSelectComponentType.defaultMessage.replaceAll('{bold_tag}', '') },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestComponentAdded.defaultMessage }, { query: screen.queryByText, name: sidebarMessages.sidebarSplitTestComponentAdded.defaultMessage },
{ query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestEditComponentTitle.defaultMessage }, { query: screen.queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestEditComponentTitle.defaultMessage },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestEditComponentInstruction.defaultMessage.replaceAll('{bold_tag}', '') }, {
{ query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestReorganizeComponentTitle.defaultMessage }, query: screen.queryByText,
{ query: queryByText, name: sidebarMessages.sidebarSplitTestReorganizeComponentInstruction.defaultMessage }, name: sidebarMessages.sidebarSplitTestEditComponentInstruction.defaultMessage
{ query: queryByText, name: sidebarMessages.sidebarSplitTestReorganizeGroupsInstruction.defaultMessage }, .replaceAll('{bold_tag}', ''),
{ query: queryByRole, type: 'heading', name: sidebarMessages.sidebarSplitTestExperimentComponentTitle.defaultMessage }, },
{ query: queryByText, name: sidebarMessages.sidebarSplitTestExperimentComponentInstruction.defaultMessage }, {
{ query: queryByRole, type: 'link', name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }, query: screen.queryByRole,
type: 'heading',
name: sidebarMessages.sidebarSplitTestReorganizeComponentTitle.defaultMessage,
},
{
query: screen.queryByText,
name: sidebarMessages.sidebarSplitTestReorganizeComponentInstruction.defaultMessage,
},
{
query: screen.queryByText,
name: sidebarMessages.sidebarSplitTestReorganizeGroupsInstruction.defaultMessage,
},
{
query: screen.queryByRole,
type: 'heading',
name: sidebarMessages.sidebarSplitTestExperimentComponentTitle.defaultMessage,
},
{
query: screen.queryByText,
name: sidebarMessages.sidebarSplitTestExperimentComponentInstruction.defaultMessage,
},
{
query: screen.queryByRole,
type: 'link',
name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage,
},
]; ];
sidebarContent.forEach(({ query, type, name }) => { sidebarContent.forEach(({ query, type, name }) => {
@@ -2152,7 +2188,7 @@ describe('<CourseUnit />', () => {
}); });
expect( expect(
queryByRole('link', { name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }), screen.queryByRole('link', { name: sidebarMessages.sidebarSplitTestLearnMoreLinkLabel.defaultMessage }),
).toHaveAttribute('href', helpLinkUrl); ).toHaveAttribute('href', helpLinkUrl);
}); });
}); });
@@ -2165,7 +2201,7 @@ describe('<CourseUnit />', () => {
}); });
it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => { it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => {
const { getByTitle } = render(<RootWrapper />); render(<RootWrapper />);
const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock)); const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock));
const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id; const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id;
@@ -2174,6 +2210,17 @@ describe('<CourseUnit />', () => {
? { ...child, block_type: 'html' } ? { ...child, block_type: 'html' }
: child)); : child));
axiosMock
.onGet(getCourseUnitApiUrl(blockId))
.reply(200, courseUnitIndexMock);
axiosMock
.onPost(postXBlockBaseApiUrl({
parent_locator: blockId,
duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id,
}))
.replyOnce(200, { locator: '1234567890' });
axiosMock axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId)) .onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, updatedCourseVerticalChildrenMock); .reply(200, updatedCourseVerticalChildrenMock);
@@ -2181,7 +2228,7 @@ describe('<CourseUnit />', () => {
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch); await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
await waitFor(() => { await waitFor(() => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument(); expect(iframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.currentXBlockId, { simulatePostMessageEvent(messageTypes.currentXBlockId, {
id: targetBlockId, id: targetBlockId,

View File

@@ -1,13 +1,14 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom'; import { getConfig } from '@edx/frontend-platform';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { import {
ActionRow, Button, StandardModal, useToggle, ActionRow, Button, StandardModal, useToggle,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { getCourseSectionVertical } from '../data/selectors'; import { getCourseSectionVertical } from '../data/selectors';
import { getWaffleFlags } from '../../data/selectors';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants'; import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import ComponentModalView from './add-component-modals/ComponentModalView'; import ComponentModalView from './add-component-modals/ComponentModalView';
import AddComponentButton from './add-component-btn'; import AddComponentButton from './add-component-btn';
@@ -16,6 +17,8 @@ import { ComponentPicker } from '../../library-authoring/component-picker';
import { messageTypes } from '../constants'; import { messageTypes } from '../constants';
import { useIframe } from '../../generic/hooks/context/hooks'; import { useIframe } from '../../generic/hooks/context/hooks';
import { useEventListener } from '../../generic/hooks'; import { useEventListener } from '../../generic/hooks';
import VideoSelectorPage from '../../editors/VideoSelectorPage';
import EditorPage from '../../editors/EditorPage';
const AddComponent = ({ const AddComponent = ({
parentLocator, parentLocator,
@@ -24,7 +27,6 @@ const AddComponent = ({
addComponentTemplateData, addComponentTemplateData,
handleCreateNewCourseXBlock, handleCreateNewCourseXBlock,
}) => { }) => {
const navigate = useNavigate();
const intl = useIntl(); const intl = useIntl();
const [isOpenAdvanced, openAdvanced, closeAdvanced] = useToggle(false); const [isOpenAdvanced, openAdvanced, closeAdvanced] = useToggle(false);
const [isOpenHtml, openHtml, closeHtml] = useToggle(false); const [isOpenHtml, openHtml, closeHtml] = useToggle(false);
@@ -32,10 +34,17 @@ const AddComponent = ({
const { componentTemplates = {} } = useSelector(getCourseSectionVertical); const { componentTemplates = {} } = useSelector(getCourseSectionVertical);
const blockId = addComponentTemplateData.parentLocator || parentLocator; const blockId = addComponentTemplateData.parentLocator || parentLocator;
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle(); const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle();
const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle();
const [blockType, setBlockType] = useState(null);
const [courseId, setCourseId] = useState(null);
const [newBlockId, setNewBlockId] = useState(null);
const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle(); const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle();
const [selectedComponents, setSelectedComponents] = useState([]); const [selectedComponents, setSelectedComponents] = useState([]);
const [usageId, setUsageId] = useState(null); const [usageId, setUsageId] = useState(null);
const { sendMessageToIframe } = useIframe(); const { sendMessageToIframe } = useIframe();
const { useVideoGalleryFlow, useReactMarkdownEditor } = useSelector(getWaffleFlags);
const receiveMessage = useCallback(({ data: { type, payload } }) => { const receiveMessage = useCallback(({ data: { type, payload } }) => {
if (type === messageTypes.showMultipleComponentPicker) { if (type === messageTypes.showMultipleComponentPicker) {
@@ -54,6 +63,12 @@ const AddComponent = ({
closeSelectLibraryContentModal(); closeSelectLibraryContentModal();
}, [selectedComponents]); }, [selectedComponents]);
const onXBlockSave = useCallback(/* istanbul ignore next */ () => {
closeXBlockEditorModal();
closeVideoSelectorModal();
sendMessageToIframe(messageTypes.refreshXBlock, null);
}, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe]);
const handleLibraryV2Selection = useCallback((selection) => { const handleLibraryV2Selection = useCallback((selection) => {
handleCreateNewCourseXBlock({ handleCreateNewCourseXBlock({
type: COMPONENT_TYPES.libraryV2, type: COMPONENT_TYPES.libraryV2,
@@ -71,12 +86,28 @@ const AddComponent = ({
handleCreateNewCourseXBlock({ type, parentLocator: blockId }); handleCreateNewCourseXBlock({ type, parentLocator: blockId });
break; break;
case COMPONENT_TYPES.problem: case COMPONENT_TYPES.problem:
case COMPONENT_TYPES.video:
handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => { handleCreateNewCourseXBlock({ type, parentLocator: blockId }, ({ courseKey, locator }) => {
localStorage.setItem('createXBlockLastYPosition', window.scrollY); setCourseId(courseKey);
navigate(`/course/${courseKey}/editor/${type}/${locator}`); setBlockType(type);
setNewBlockId(locator);
showXBlockEditorModal();
}); });
break; break;
case COMPONENT_TYPES.video:
handleCreateNewCourseXBlock(
{ type, parentLocator: blockId },
/* istanbul ignore next */ ({ courseKey, locator }) => {
setCourseId(courseKey);
setBlockType(type);
setNewBlockId(locator);
if (useVideoGalleryFlow) {
showVideoSelectorModal();
} else {
showXBlockEditorModal();
}
},
);
break;
// TODO: The library functional will be a bit different of current legacy (CMS) // TODO: The library functional will be a bit different of current legacy (CMS)
// behaviour and this ticket is on hold (blocked by other development team). // behaviour and this ticket is on hold (blocked by other development team).
case COMPONENT_TYPES.library: case COMPONENT_TYPES.library:
@@ -99,9 +130,11 @@ const AddComponent = ({
type, type,
boilerplate: moduleName, boilerplate: moduleName,
parentLocator: blockId, parentLocator: blockId,
}, ({ courseKey, locator }) => { }, /* istanbul ignore next */ ({ courseKey, locator }) => {
localStorage.setItem('createXBlockLastYPosition', window.scrollY); setCourseId(courseKey);
navigate(`/course/${courseKey}/editor/html/${locator}`); setBlockType(type);
setNewBlockId(locator);
showXBlockEditorModal();
}); });
break; break;
default: default:
@@ -201,6 +234,38 @@ const AddComponent = ({
onChangeComponentSelection={setSelectedComponents} onChangeComponentSelection={setSelectedComponents}
/> />
</StandardModal> </StandardModal>
<StandardModal
title={intl.formatMessage(messages.videoPickerModalTitle)}
isOpen={isVideoSelectorModalOpen}
onClose={closeVideoSelectorModal}
isOverflowVisible={false}
size="xl"
>
<div className="selector-page">
<VideoSelectorPage
blockId={newBlockId}
courseId={courseId}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
onCancel={closeVideoSelectorModal}
returnFunction={/* istanbul ignore next */ () => onXBlockSave}
/>
</div>
</StandardModal>
{isXBlockEditorModalOpen && (
<div className="editor-page">
<EditorPage
courseId={courseId}
blockType={blockType}
blockId={newBlockId}
isMarkdownEditorEnabledForCourse={useReactMarkdownEditor}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
onClose={closeXBlockEditorModal}
returnFunction={/* istanbul ignore next */ () => onXBlockSave}
/>
</div>
)}
</div> </div>
); );
} }

View File

@@ -31,6 +31,11 @@ const messages = defineMessages({
defaultMessage: 'Add selected components', defaultMessage: 'Add selected components',
description: 'Problem bank component add button text.', description: 'Problem bank component add button text.',
}, },
videoPickerModalTitle: {
id: 'course-authoring.course-unit.modal.video-title.text',
defaultMessage: 'Select video',
description: 'Video picker modal title.',
},
modalContainerTitle: { modalContainerTitle: {
id: 'course-authoring.course-unit.modal.container.title', id: 'course-authoring.course-unit.modal.container.title',
defaultMessage: 'Add {componentTitle} component', defaultMessage: 'Add {componentTitle} component',

View File

@@ -3,7 +3,7 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { PUBLISH_TYPES } from '../constants'; import { PUBLISH_TYPES } from '../constants';
import { normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils'; import { isUnitReadOnly, normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils';
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL; const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
@@ -24,7 +24,9 @@ export async function getCourseUnitData(unitId) {
const { data } = await getAuthenticatedHttpClient() const { data } = await getAuthenticatedHttpClient()
.get(getCourseUnitApiUrl(unitId)); .get(getCourseUnitApiUrl(unitId));
return camelCaseObject(data); const result = camelCaseObject(data);
result.readOnly = isUnitReadOnly(result);
return result;
} }
/** /**

View File

@@ -38,7 +38,7 @@ import {
updateCourseOutlineInfoLoadingStatus, updateCourseOutlineInfoLoadingStatus,
updateMovedXBlockParams, updateMovedXBlockParams,
} from './slice'; } from './slice';
import { getNotificationMessage, isUnitReadOnly } from './utils'; import { getNotificationMessage } from './utils';
export function fetchCourseUnitQuery(courseId) { export function fetchCourseUnitQuery(courseId) {
return async (dispatch) => { return async (dispatch) => {
@@ -46,7 +46,6 @@ export function fetchCourseUnitQuery(courseId) {
try { try {
const courseUnit = await getCourseUnitData(courseId); const courseUnit = await getCourseUnitData(courseId);
courseUnit.readOnly = isUnitReadOnly(courseUnit);
dispatch(fetchCourseItemSuccess(courseUnit)); dispatch(fetchCourseItemSuccess(courseUnit));
dispatch(updateLoadingCourseUnitStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(updateLoadingCourseUnitStatus({ status: RequestStatus.SUCCESSFUL }));
@@ -262,6 +261,8 @@ export function duplicateUnitItemQuery(itemId, xblockId, callback) {
callback(courseKey, locator); callback(courseKey, locator);
const courseUnit = await getCourseUnitData(itemId); const courseUnit = await getCourseUnitData(itemId);
dispatch(fetchCourseItemSuccess(courseUnit)); dispatch(fetchCourseItemSuccess(courseUnit));
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification()); dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) { } catch (error) {

View File

@@ -12,7 +12,6 @@ import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.'
import { messageTypes } from '../constants'; import { messageTypes } from '../constants';
import { libraryBlockChangesUrl } from '../data/api'; import { libraryBlockChangesUrl } from '../data/api';
import { ToastActionData } from '../../generic/toast-context'; import { ToastActionData } from '../../generic/toast-context';
import { getLibraryBlockMetadataUrl, getLibraryContainerApiUrl } from '../../library-authoring/data/api';
const usageKey = 'some-id'; const usageKey = 'some-id';
const defaultEventData: LibraryChangesMessageData = { const defaultEventData: LibraryChangesMessageData = {
@@ -66,7 +65,7 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
expect(await screen.findByRole('tab', { name: 'Old version' })).toBeInTheDocument(); expect(await screen.findByRole('tab', { name: 'Old version' })).toBeInTheDocument();
}); });
it('renders displayName for units', async () => { it('renders default displayName for units with no displayName', async () => {
render({ ...defaultEventData, isVertical: true, displayName: '' }); render({ ...defaultEventData, isVertical: true, displayName: '' });
expect(await screen.findByText('Preview changes: Unit')).toBeInTheDocument(); expect(await screen.findByText('Preview changes: Unit')).toBeInTheDocument();
@@ -78,24 +77,6 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
expect(await screen.findByText('Preview changes: Component')).toBeInTheDocument(); expect(await screen.findByText('Preview changes: Component')).toBeInTheDocument();
}); });
it('renders both new and old title if they are different', async () => {
axiosMock.onGet(getLibraryBlockMetadataUrl(defaultEventData.upstreamBlockId)).reply(200, {
displayName: 'New test block',
});
render();
expect(await screen.findByText('Preview changes: Test block -> New test block')).toBeInTheDocument();
});
it('renders both new and old title if they are different on units', async () => {
axiosMock.onGet(getLibraryContainerApiUrl(defaultEventData.upstreamBlockId)).reply(200, {
displayName: 'New test Unit',
});
render({ ...defaultEventData, isVertical: true, displayName: 'Test Unit' });
expect(await screen.findByText('Preview changes: Test Unit -> New test Unit')).toBeInTheDocument();
});
it('accept changes works', async () => { it('accept changes works', async () => {
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {}); axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
render(); render();
@@ -104,7 +85,10 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
const acceptBtn = await screen.findByRole('button', { name: 'Accept changes' }); const acceptBtn = await screen.findByRole('button', { name: 'Accept changes' });
userEvent.click(acceptBtn); userEvent.click(acceptBtn);
await waitFor(() => { await waitFor(() => {
expect(mockSendMessageToIframe).toHaveBeenCalledWith(messageTypes.refreshXBlock, null); expect(mockSendMessageToIframe).toHaveBeenCalledWith(
messageTypes.completeXBlockEditing,
{ locator: usageKey },
);
expect(axiosMock.history.post.length).toEqual(1); expect(axiosMock.history.post.length).toEqual(1);
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey)); expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
}); });
@@ -119,7 +103,6 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
const acceptBtn = await screen.findByRole('button', { name: 'Accept changes' }); const acceptBtn = await screen.findByRole('button', { name: 'Accept changes' });
userEvent.click(acceptBtn); userEvent.click(acceptBtn);
await waitFor(() => { await waitFor(() => {
expect(mockSendMessageToIframe).not.toHaveBeenCalledWith(messageTypes.refreshXBlock, null);
expect(axiosMock.history.post.length).toEqual(1); expect(axiosMock.history.post.length).toEqual(1);
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey)); expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
}); });
@@ -137,7 +120,10 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
const ignoreConfirmBtn = await screen.findByRole('button', { name: 'Ignore' }); const ignoreConfirmBtn = await screen.findByRole('button', { name: 'Ignore' });
userEvent.click(ignoreConfirmBtn); userEvent.click(ignoreConfirmBtn);
await waitFor(() => { await waitFor(() => {
expect(mockSendMessageToIframe).toHaveBeenCalledWith(messageTypes.refreshXBlock, null); expect(mockSendMessageToIframe).toHaveBeenCalledWith(
messageTypes.completeXBlockEditing,
{ locator: usageKey },
);
expect(axiosMock.history.delete.length).toEqual(1); expect(axiosMock.history.delete.length).toEqual(1);
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey)); expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
}); });

View File

@@ -1,20 +1,21 @@
import React, { useCallback, useContext, useState } from 'react'; import { useCallback, useContext, useState } from 'react';
import { import {
ActionRow, Button, ModalDialog, useToggle, ActionRow, Button, ModalDialog, useToggle,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { Warning } from '@openedx/paragon/icons';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { useEventListener } from '../../generic/hooks'; import { useEventListener } from '../../generic/hooks';
import { messageTypes } from '../constants'; import { messageTypes } from '../constants';
import CompareChangesWidget from '../../library-authoring/component-comparison/CompareChangesWidget'; import CompareChangesWidget from '../../library-authoring/component-comparison/CompareChangesWidget';
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks'; import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks';
import AlertMessage from '../../generic/alert-message';
import { useIframe } from '../../generic/hooks/context/hooks'; import { useIframe } from '../../generic/hooks/context/hooks';
import DeleteModal from '../../generic/delete-modal/DeleteModal'; import DeleteModal from '../../generic/delete-modal/DeleteModal';
import messages from './messages'; import messages from './messages';
import { ToastContext } from '../../generic/toast-context'; import { ToastContext } from '../../generic/toast-context';
import LoadingButton from '../../generic/loading-button'; import LoadingButton from '../../generic/loading-button';
import Loading from '../../generic/Loading'; import Loading from '../../generic/Loading';
import { useContainer, useLibraryBlockMetadata } from '../../library-authoring/data/apiHooks';
export interface LibraryChangesMessageData { export interface LibraryChangesMessageData {
displayName: string, displayName: string,
@@ -25,11 +26,10 @@ export interface LibraryChangesMessageData {
} }
export interface PreviewLibraryXBlockChangesProps { export interface PreviewLibraryXBlockChangesProps {
blockData?: LibraryChangesMessageData, blockData: LibraryChangesMessageData,
isModalOpen: boolean, isModalOpen: boolean,
closeModal: () => void, closeModal: () => void,
postChange: (accept: boolean) => void, postChange: (accept: boolean) => void,
alertNode?: React.ReactNode,
} }
/** /**
@@ -41,7 +41,6 @@ export const PreviewLibraryXBlockChanges = ({
isModalOpen, isModalOpen,
closeModal, closeModal,
postChange, postChange,
alertNode,
}: PreviewLibraryXBlockChangesProps) => { }: PreviewLibraryXBlockChangesProps) => {
const { showToast } = useContext(ToastContext); const { showToast } = useContext(ToastContext);
const intl = useIntl(); const intl = useIntl();
@@ -49,32 +48,9 @@ export const PreviewLibraryXBlockChanges = ({
// ignore changes confirmation modal toggle. // ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
// TODO: Split into two different components to avoid making these two calls in which
// one will definitely fail
const { data: componentMetadata } = useLibraryBlockMetadata(blockData?.upstreamBlockId);
const { data: unitMetadata } = useContainer(blockData?.upstreamBlockId);
const metadata = blockData?.isVertical ? unitMetadata : componentMetadata;
const acceptChangesMutation = useAcceptLibraryBlockChanges(); const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges(); const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const getTitle = useCallback(() => {
const oldName = blockData?.displayName;
const newName = metadata?.displayName;
if (!oldName) {
if (blockData?.isVertical) {
return intl.formatMessage(messages.defaultUnitTitle);
}
return intl.formatMessage(messages.defaultComponentTitle);
}
if (oldName === newName || !newName) {
return intl.formatMessage(messages.title, { blockTitle: oldName });
}
return intl.formatMessage(messages.diffTitle, { oldName, newName });
}, [blockData, metadata]);
const getBody = useCallback(() => { const getBody = useCallback(() => {
if (!blockData) { if (!blockData) {
return <Loading />; return <Loading />;
@@ -108,12 +84,21 @@ export const PreviewLibraryXBlockChanges = ({
} }
}, [blockData]); }, [blockData]);
const defaultTitle = intl.formatMessage(
blockData.isVertical
? messages.defaultUnitTitle
: messages.defaultComponentTitle,
);
const title = blockData.displayName
? intl.formatMessage(messages.title, { blockTitle: blockData?.displayName })
: defaultTitle;
return ( return (
<ModalDialog <ModalDialog
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={closeModal} onClose={closeModal}
size="xl" size="xl"
title={getTitle()} title={title}
className="lib-preview-xblock-changes-modal" className="lib-preview-xblock-changes-modal"
hasCloseButton hasCloseButton
isFullscreenOnMobile isFullscreenOnMobile
@@ -121,11 +106,16 @@ export const PreviewLibraryXBlockChanges = ({
> >
<ModalDialog.Header> <ModalDialog.Header>
<ModalDialog.Title> <ModalDialog.Title>
{getTitle()} {title}
</ModalDialog.Title> </ModalDialog.Title>
</ModalDialog.Header> </ModalDialog.Header>
<ModalDialog.Body> <ModalDialog.Body>
{alertNode} <AlertMessage
show
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
/>
{getBody()} {getBody()}
</ModalDialog.Body> </ModalDialog.Body>
<ModalDialog.Footer> <ModalDialog.Footer>
@@ -186,12 +176,18 @@ const IframePreviewLibraryXBlockChanges = () => {
useEventListener('message', receiveMessage); useEventListener('message', receiveMessage);
if (!blockData) {
return null;
}
const blockPayload = { locator: blockData.downstreamBlockId };
return ( return (
<PreviewLibraryXBlockChanges <PreviewLibraryXBlockChanges
blockData={blockData} blockData={blockData}
isModalOpen={isModalOpen} isModalOpen={isModalOpen}
closeModal={closeModal} closeModal={closeModal}
postChange={() => sendMessageToIframe(messageTypes.refreshXBlock, null)} postChange={() => sendMessageToIframe(messageTypes.completeXBlockEditing, blockPayload)}
/> />
); );
}; };

View File

@@ -6,11 +6,6 @@ const messages = defineMessages({
defaultMessage: 'Preview changes: {blockTitle}', defaultMessage: 'Preview changes: {blockTitle}',
description: 'Preview changes modal title text', description: 'Preview changes modal title text',
}, },
diffTitle: {
id: 'authoring.course-unit.preview-changes.modal-diff-title',
defaultMessage: 'Preview changes: {oldName} -> {newName}',
description: 'Preview changes modal title text',
},
defaultUnitTitle: { defaultUnitTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-unit-title', id: 'authoring.course-unit.preview-changes.modal-default-unit-title',
defaultMessage: 'Preview changes: Unit', defaultMessage: 'Preview changes: Unit',
@@ -61,6 +56,11 @@ const messages = defineMessages({
defaultMessage: 'Ignore', defaultMessage: 'Ignore',
description: 'Preview changes confirmation dialog confirm button text when user clicks on ignore changes.', description: 'Preview changes confirmation dialog confirm button text when user clicks on ignore changes.',
}, },
olderVersionPreviewAlert: {
id: 'course-authoring.review-tab.preview.old-version-alert',
defaultMessage: 'The old version preview is the previous library version',
description: 'Alert message stating that older version in preview is of library block',
},
}); });
export default messages; export default messages;

View File

@@ -1,11 +1,11 @@
export type UseMessageHandlersTypes = { export type UseMessageHandlersTypes = {
courseId: string; courseId: string;
navigate: (path: string) => void;
dispatch: (action: any) => void; dispatch: (action: any) => void;
setIframeOffset: (height: number) => void; setIframeOffset: (height: number) => void;
handleDeleteXBlock: (usageId: string) => void; handleDeleteXBlock: (usageId: string) => void;
handleScrollToXBlock: (scrollOffset: number) => void; handleScrollToXBlock: (scrollOffset: number) => void;
handleDuplicateXBlock: (blockType: string, usageId: string) => void; handleDuplicateXBlock: (usageId: string) => void;
handleEditXBlock: (blockType: string, usageId: string) => void;
handleManageXBlockAccess: (usageId: string) => void; handleManageXBlockAccess: (usageId: string) => void;
handleShowLegacyEditXBlockModal: (id: string) => void; handleShowLegacyEditXBlockModal: (id: string) => void;
handleCloseLegacyEditorXBlockModal: () => void; handleCloseLegacyEditorXBlockModal: () => void;
@@ -14,7 +14,6 @@ export type UseMessageHandlersTypes = {
handleOpenManageTagsModal: (id: string) => void; handleOpenManageTagsModal: (id: string) => void;
handleShowProcessingNotification: (variant: string) => void; handleShowProcessingNotification: (variant: string) => void;
handleHideProcessingNotification: () => void; handleHideProcessingNotification: () => void;
handleRedirectToXBlockEditPage: (payload: { type: string, locator: string }) => void;
}; };
export type MessageHandlersTypes = Record<string, (payload: any) => void>; export type MessageHandlersTypes = Record<string, (payload: any) => void>;

View File

@@ -16,7 +16,6 @@ import { MessageHandlersTypes, UseMessageHandlersTypes } from './types';
*/ */
export const useMessageHandlers = ({ export const useMessageHandlers = ({
courseId, courseId,
navigate,
dispatch, dispatch,
setIframeOffset, setIframeOffset,
handleDeleteXBlock, handleDeleteXBlock,
@@ -30,15 +29,15 @@ export const useMessageHandlers = ({
handleOpenManageTagsModal, handleOpenManageTagsModal,
handleShowProcessingNotification, handleShowProcessingNotification,
handleHideProcessingNotification, handleHideProcessingNotification,
handleRedirectToXBlockEditPage, handleEditXBlock,
}: UseMessageHandlersTypes): MessageHandlersTypes => { }: UseMessageHandlersTypes): MessageHandlersTypes => {
const { copyToClipboard } = useClipboard(); const { copyToClipboard } = useClipboard();
return useMemo(() => ({ return useMemo(() => ({
[messageTypes.copyXBlock]: ({ usageId }) => copyToClipboard(usageId), [messageTypes.copyXBlock]: ({ usageId }) => copyToClipboard(usageId),
[messageTypes.deleteXBlock]: ({ usageId }) => handleDeleteXBlock(usageId), [messageTypes.deleteXBlock]: ({ usageId }) => handleDeleteXBlock(usageId),
[messageTypes.newXBlockEditor]: ({ blockType, usageId }) => navigate(`/course/${courseId}/editor/${blockType}/${usageId}`), [messageTypes.newXBlockEditor]: ({ blockType, usageId }) => handleEditXBlock(blockType, usageId),
[messageTypes.duplicateXBlock]: ({ blockType, usageId }) => handleDuplicateXBlock(blockType, usageId), [messageTypes.duplicateXBlock]: ({ usageId }) => handleDuplicateXBlock(usageId),
[messageTypes.manageXBlockAccess]: ({ usageId }) => handleManageXBlockAccess(usageId), [messageTypes.manageXBlockAccess]: ({ usageId }) => handleManageXBlockAccess(usageId),
[messageTypes.scrollToXBlock]: debounce(({ scrollOffset }) => handleScrollToXBlock(scrollOffset), 1000), [messageTypes.scrollToXBlock]: debounce(({ scrollOffset }) => handleScrollToXBlock(scrollOffset), 1000),
[messageTypes.toggleCourseXBlockDropdown]: ({ [messageTypes.toggleCourseXBlockDropdown]: ({
@@ -52,9 +51,14 @@ export const useMessageHandlers = ({
[messageTypes.openManageTags]: (payload) => handleOpenManageTagsModal(payload.contentId), [messageTypes.openManageTags]: (payload) => handleOpenManageTagsModal(payload.contentId),
[messageTypes.addNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.adding), [messageTypes.addNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.adding),
[messageTypes.pasteNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.pasting), [messageTypes.pasteNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.pasting),
[messageTypes.copyXBlockLegacy]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.copying), [messageTypes.copyXBlockLegacy]: /* istanbul ignore next */ () => handleShowProcessingNotification(
NOTIFICATION_MESSAGES.copying,
),
[messageTypes.hideProcessingNotification]: handleHideProcessingNotification, [messageTypes.hideProcessingNotification]: handleHideProcessingNotification,
[messageTypes.handleRedirectToXBlockEditPage]: (payload) => handleRedirectToXBlockEditPage(payload), [messageTypes.handleRedirectToXBlockEditPage]: /* istanbul ignore next */ (payload) => handleEditXBlock(
payload.type,
payload.locator,
),
}), [ }), [
courseId, courseId,
handleDeleteXBlock, handleDeleteXBlock,

View File

@@ -1,10 +1,10 @@
import { getConfig } from '@edx/frontend-platform';
import { import {
FC, useEffect, useState, useMemo, useCallback, FC, useEffect, useState, useMemo, useCallback,
} from 'react'; } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { useToggle, Sheet } from '@openedx/paragon'; import { useToggle, Sheet, StandardModal } from '@openedx/paragon';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { import {
hideProcessingNotification, hideProcessingNotification,
@@ -13,9 +13,9 @@ import {
import DeleteModal from '../../generic/delete-modal/DeleteModal'; import DeleteModal from '../../generic/delete-modal/DeleteModal';
import ConfigureModal from '../../generic/configure-modal/ConfigureModal'; import ConfigureModal from '../../generic/configure-modal/ConfigureModal';
import ModalIframe from '../../generic/modal-iframe'; import ModalIframe from '../../generic/modal-iframe';
import { getWaffleFlags } from '../../data/selectors';
import { IFRAME_FEATURE_POLICY } from '../../constants'; import { IFRAME_FEATURE_POLICY } from '../../constants';
import ContentTagsDrawer from '../../content-tags-drawer/ContentTagsDrawer'; import ContentTagsDrawer from '../../content-tags-drawer/ContentTagsDrawer';
import supportedEditors from '../../editors/supportedEditors';
import { useIframe } from '../../generic/hooks/context/hooks'; import { useIframe } from '../../generic/hooks/context/hooks';
import { import {
fetchCourseSectionVerticalData, fetchCourseSectionVerticalData,
@@ -35,16 +35,22 @@ import messages from './messages';
import { useIframeBehavior } from '../../generic/hooks/useIframeBehavior'; import { useIframeBehavior } from '../../generic/hooks/useIframeBehavior';
import { useIframeContent } from '../../generic/hooks/useIframeContent'; import { useIframeContent } from '../../generic/hooks/useIframeContent';
import { useIframeMessages } from '../../generic/hooks/useIframeMessages'; import { useIframeMessages } from '../../generic/hooks/useIframeMessages';
import VideoSelectorPage from '../../editors/VideoSelectorPage';
import EditorPage from '../../editors/EditorPage';
const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType, courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const dispatch = useDispatch(); const dispatch = useDispatch();
const navigate = useNavigate();
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false); const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false); const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle();
const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle();
const [blockType, setBlockType] = useState<string>('');
const { useVideoGalleryFlow, useReactMarkdownEditor } = useSelector(getWaffleFlags);
const [newBlockId, setNewBlockId] = useState<string>('');
const [accessManagedXBlockData, setAccessManagedXBlockData] = useState<AccessManagedXBlockDataTypes | {}>({}); const [accessManagedXBlockData, setAccessManagedXBlockData] = useState<AccessManagedXBlockDataTypes | {}>({});
const [iframeOffset, setIframeOffset] = useState(0); const [iframeOffset, setIframeOffset] = useState(0);
const [deleteXBlockId, setDeleteXBlockId] = useState<string | null>(null); const [deleteXBlockId, setDeleteXBlockId] = useState<string | null>(null);
@@ -64,14 +70,27 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
setIframeRef(iframeRef); setIframeRef(iframeRef);
}, [setIframeRef]); }, [setIframeRef]);
const onXBlockSave = useCallback(/* istanbul ignore next */ () => {
closeXBlockEditorModal();
closeVideoSelectorModal();
sendMessageToIframe(messageTypes.refreshXBlock, null);
}, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe]);
const handleEditXBlock = useCallback((type: string, id: string) => {
setBlockType(type);
setNewBlockId(id);
if (type === 'video' && useVideoGalleryFlow) {
showVideoSelectorModal();
} else {
showXBlockEditorModal();
}
}, [showVideoSelectorModal, showXBlockEditorModal]);
const handleDuplicateXBlock = useCallback( const handleDuplicateXBlock = useCallback(
(blockType: string, usageId: string) => { (usageId: string) => {
unitXBlockActions.handleDuplicate(usageId); unitXBlockActions.handleDuplicate(usageId);
if (supportedEditors[blockType]) {
navigate(`/course/${courseId}/editor/${blockType}/${usageId}`);
}
}, },
[unitXBlockActions, courseId, navigate], [unitXBlockActions, courseId],
); );
const handleDeleteXBlock = (usageId: string) => { const handleDeleteXBlock = (usageId: string) => {
@@ -147,13 +166,8 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
dispatch(hideProcessingNotification()); dispatch(hideProcessingNotification());
}; };
const handleRedirectToXBlockEditPage = (payload: { type: string, locator: string }) => {
navigate(`/course/${courseId}/editor/${payload.type}/${payload.locator}`);
};
const messageHandlers = useMessageHandlers({ const messageHandlers = useMessageHandlers({
courseId, courseId,
navigate,
dispatch, dispatch,
setIframeOffset, setIframeOffset,
handleDeleteXBlock, handleDeleteXBlock,
@@ -167,7 +181,7 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
handleOpenManageTagsModal, handleOpenManageTagsModal,
handleShowProcessingNotification, handleShowProcessingNotification,
handleHideProcessingNotification, handleHideProcessingNotification,
handleRedirectToXBlockEditPage, handleEditXBlock,
}); });
useIframeMessages(messageHandlers); useIframeMessages(messageHandlers);
@@ -186,6 +200,38 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
close={closeDeleteModal} close={closeDeleteModal}
onDeleteSubmit={onDeleteSubmit} onDeleteSubmit={onDeleteSubmit}
/> />
<StandardModal
title={intl.formatMessage(messages.videoPickerModalTitle)}
isOpen={isVideoSelectorModalOpen}
onClose={closeVideoSelectorModal}
isOverflowVisible={false}
size="xl"
>
<div className="selector-page">
<VideoSelectorPage
blockId={newBlockId}
courseId={courseId}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
onCancel={closeVideoSelectorModal}
returnFunction={/* istanbul ignore next */ () => onXBlockSave}
/>
</div>
</StandardModal>
{isXBlockEditorModalOpen && (
<div className="editor-page">
<EditorPage
courseId={courseId}
blockType={blockType}
blockId={newBlockId}
isMarkdownEditorEnabledForCourse={useReactMarkdownEditor}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
onClose={closeXBlockEditorModal}
returnFunction={/* istanbul ignore next */ () => onXBlockSave}
/>
</div>
)}
{Object.keys(accessManagedXBlockData).length ? ( {Object.keys(accessManagedXBlockData).length ? (
<ConfigureModal <ConfigureModal
isXBlockComponent isXBlockComponent

View File

@@ -15,6 +15,10 @@ const messages = defineMessages({
id: 'course-authoring.course-unit.xblock.iframe.label', id: 'course-authoring.course-unit.xblock.iframe.label',
defaultMessage: '{xblockCount} xBlocks inside the frame', defaultMessage: '{xblockCount} xBlocks inside the frame',
}, },
videoPickerModalTitle: {
id: 'course-authoring.course-unit.xblock.video-editor.title',
defaultMessage: 'Select video',
},
}); });
export default messages; export default messages;

View File

@@ -26,6 +26,7 @@ const slice = createSlice({
useNewCertificatesPage: true, useNewCertificatesPage: true,
useNewTextbooksPage: true, useNewTextbooksPage: true,
useNewGroupConfigurationsPage: true, useNewGroupConfigurationsPage: true,
useVideoGalleryFlow: false,
}, },
}, },
reducers: { reducers: {

View File

@@ -7,7 +7,6 @@ import * as hooks from './hooks';
import supportedEditors from './supportedEditors'; import supportedEditors from './supportedEditors';
import type { EditorComponent } from './EditorComponent'; import type { EditorComponent } from './EditorComponent';
import { useEditorContext } from './EditorContext';
import AdvancedEditor from './AdvancedEditor'; import AdvancedEditor from './AdvancedEditor';
export interface Props extends EditorComponent { export interface Props extends EditorComponent {
@@ -17,7 +16,6 @@ export interface Props extends EditorComponent {
learningContextId: string | null; learningContextId: string | null;
lmsEndpointUrl: string | null; lmsEndpointUrl: string | null;
studioEndpointUrl: string | null; studioEndpointUrl: string | null;
fullScreen?: boolean; // eslint-disable-line react/no-unused-prop-types
} }
const Editor: React.FC<Props> = ({ const Editor: React.FC<Props> = ({
@@ -42,7 +40,6 @@ const Editor: React.FC<Props> = ({
studioEndpointUrl, studioEndpointUrl,
}, },
}); });
const { fullScreen } = useEditorContext();
const EditorComponent = supportedEditors[blockType]; const EditorComponent = supportedEditors[blockType];
@@ -60,24 +57,7 @@ const Editor: React.FC<Props> = ({
); );
} }
const innerEditor = <EditorComponent {...{ onClose, returnFunction }} />; return <EditorComponent {...{ onClose, returnFunction }} />;
if (fullScreen) {
return (
<div
className="d-flex flex-column"
>
<div
className="pgn__modal-fullscreen h-100"
role="dialog"
aria-label={blockType}
>
{innerEditor}
</div>
</div>
);
}
return innerEditor;
}; };
export default Editor; export default Editor;

View File

@@ -7,14 +7,6 @@ import React from 'react';
*/ */
export interface EditorContext { export interface EditorContext {
learningContextId: string; learningContextId: string;
/**
* When editing components in the libraries part of the Authoring MFE, we show
* the editors in a modal (fullScreen = false). This is the preferred approach
* so that authors can see context behind the modal.
* However, when making edits from the legacy course view, we display the
* editors in a fullscreen view. This approach is deprecated.
*/
fullScreen: boolean;
} }
const context = React.createContext<EditorContext | undefined>(undefined); const context = React.createContext<EditorContext | undefined>(undefined);
@@ -32,7 +24,6 @@ export function useEditorContext() {
export const EditorContextProvider: React.FC<{ export const EditorContextProvider: React.FC<{
children: React.ReactNode, children: React.ReactNode,
learningContextId: string; learningContextId: string;
fullScreen: boolean;
}> = ({ children, ...contextData }) => { }> = ({ children, ...contextData }) => {
const ctx: EditorContext = React.useMemo(() => ({ ...contextData }), []); const ctx: EditorContext = React.useMemo(() => ({ ...contextData }), []);
return <context.Provider value={ctx}>{children}</context.Provider>; return <context.Provider value={ctx}>{children}</context.Provider>;

View File

@@ -37,7 +37,6 @@ const defaultPropsHtml = {
lmsEndpointUrl: 'http://lms.test.none/', lmsEndpointUrl: 'http://lms.test.none/',
studioEndpointUrl: 'http://cms.test.none/', studioEndpointUrl: 'http://cms.test.none/',
onClose: jest.fn(), onClose: jest.fn(),
fullScreen: false,
}; };
const fieldsHtml = { const fieldsHtml = {
displayName: 'Introduction to Testing', displayName: 'Introduction to Testing',
@@ -66,22 +65,6 @@ describe('EditorPage', () => {
expect(modalElement.classList).not.toContain('pgn__modal-fullscreen'); expect(modalElement.classList).not.toContain('pgn__modal-fullscreen');
}); });
test('it can display the Text (html) editor as a full page (when coming from the legacy UI)', async () => {
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => (
{ status: 200, data: snakeCaseObject(fieldsHtml) }
));
render(<EditorPage {...defaultPropsHtml} fullScreen />);
// Then the editor should open
expect(await screen.findByRole('heading', { name: /Introduction to Testing/ })).toBeInTheDocument();
const modalElement = screen.getByRole('dialog');
expect(modalElement.classList).toContain('pgn__modal-fullscreen');
expect(modalElement.classList).not.toContain('pgn__modal');
expect(modalElement.classList).not.toContain('pgn__modal-xl');
});
test('it shows the Advanced Editor if there is no corresponding editor', async () => { test('it shows the Advanced Editor if there is no corresponding editor', async () => {
jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => ( // eslint-disable-next-line jest.spyOn(editorCmsApi, 'fetchBlockById').mockImplementationOnce(async () => ( // eslint-disable-next-line
{ status: 200, data: { display_name: 'Fake Un-editable Block', category: 'fake', metadata: {}, data: '' } } { status: 200, data: { display_name: 'Fake Un-editable Block', category: 'fake', metadata: {}, data: '' } }

View File

@@ -14,7 +14,6 @@ interface Props extends EditorComponent {
isMarkdownEditorEnabledForCourse?: boolean; isMarkdownEditorEnabledForCourse?: boolean;
lmsEndpointUrl?: string; lmsEndpointUrl?: string;
studioEndpointUrl?: string; studioEndpointUrl?: string;
fullScreen?: boolean;
children?: never; children?: never;
} }
@@ -31,7 +30,6 @@ const EditorPage: React.FC<Props> = ({
studioEndpointUrl = null, studioEndpointUrl = null,
onClose = null, onClose = null,
returnFunction = null, returnFunction = null,
fullScreen = true,
}) => ( }) => (
<Provider store={store}> <Provider store={store}>
<ErrorBoundary <ErrorBoundary
@@ -40,7 +38,7 @@ const EditorPage: React.FC<Props> = ({
studioEndpointUrl, studioEndpointUrl,
}} }}
> >
<EditorContextProvider fullScreen={fullScreen} learningContextId={courseId}> <EditorContextProvider learningContextId={courseId}>
<Editor <Editor
{...{ {...{
onClose, onClose,

View File

@@ -9,6 +9,8 @@ const VideoSelector = ({
learningContextId, learningContextId,
lmsEndpointUrl, lmsEndpointUrl,
studioEndpointUrl, studioEndpointUrl,
returnFunction,
onCancel,
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const loading = hooks.useInitializeApp({ const loading = hooks.useInitializeApp({
@@ -26,7 +28,7 @@ const VideoSelector = ({
return null; return null;
} }
return ( return (
<VideoGallery /> <VideoGallery returnFunction={returnFunction} onCancel={onCancel} />
); );
}; };
@@ -35,6 +37,8 @@ VideoSelector.propTypes = {
learningContextId: PropTypes.string.isRequired, learningContextId: PropTypes.string.isRequired,
lmsEndpointUrl: PropTypes.string.isRequired, lmsEndpointUrl: PropTypes.string.isRequired,
studioEndpointUrl: PropTypes.string.isRequired, studioEndpointUrl: PropTypes.string.isRequired,
returnFunction: PropTypes.func,
onCancel: PropTypes.func,
}; };
export default VideoSelector; export default VideoSelector;

View File

@@ -10,6 +10,8 @@ const VideoSelectorPage = ({
courseId, courseId,
lmsEndpointUrl, lmsEndpointUrl,
studioEndpointUrl, studioEndpointUrl,
returnFunction,
onCancel,
}) => ( }) => (
<Provider store={store}> <Provider store={store}>
<ErrorBoundary <ErrorBoundary
@@ -24,6 +26,8 @@ const VideoSelectorPage = ({
learningContextId: courseId, learningContextId: courseId,
lmsEndpointUrl, lmsEndpointUrl,
studioEndpointUrl, studioEndpointUrl,
returnFunction,
onCancel,
}} }}
/> />
</ErrorBoundary> </ErrorBoundary>
@@ -42,6 +46,8 @@ VideoSelectorPage.propTypes = {
courseId: PropTypes.string, courseId: PropTypes.string,
lmsEndpointUrl: PropTypes.string, lmsEndpointUrl: PropTypes.string,
studioEndpointUrl: PropTypes.string, studioEndpointUrl: PropTypes.string,
returnFunction: PropTypes.func,
onCancel: PropTypes.func,
}; };
export default VideoSelectorPage; export default VideoSelectorPage;

View File

@@ -32,7 +32,6 @@ const defaultPropsHtml = {
lmsEndpointUrl: 'http://lms.test.none/', lmsEndpointUrl: 'http://lms.test.none/',
studioEndpointUrl: 'http://cms.test.none/', studioEndpointUrl: 'http://cms.test.none/',
onClose: jest.fn(), onClose: jest.fn(),
fullScreen: false,
}; };
const fieldsHtml = { const fieldsHtml = {
displayName: 'Introduction to Testing', displayName: 'Introduction to Testing',

View File

@@ -14,7 +14,6 @@ import { Close } from '@openedx/paragon/icons';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { EditorComponent } from '../../EditorComponent'; import { EditorComponent } from '../../EditorComponent';
import { useEditorContext } from '../../EditorContext';
import TitleHeader from './components/TitleHeader'; import TitleHeader from './components/TitleHeader';
import * as hooks from './hooks'; import * as hooks from './hooks';
import messages from './messages'; import messages from './messages';
@@ -30,37 +29,18 @@ interface WrapperProps {
} }
export const EditorModalWrapper: React.FC<WrapperProps & { onClose: () => void }> = ({ children, onClose }) => { export const EditorModalWrapper: React.FC<WrapperProps & { onClose: () => void }> = ({ children, onClose }) => {
const { fullScreen } = useEditorContext();
const intl = useIntl(); const intl = useIntl();
if (fullScreen) {
return (
<div
className="editor-container d-flex flex-column position-relative zindex-0"
style={{ minHeight: '100%' }}
>
{children}
</div>
);
}
const title = intl.formatMessage(messages.modalTitle); const title = intl.formatMessage(messages.modalTitle);
return ( return (
<ModalDialog isOpen size="xl" isOverflowVisible={false} onClose={onClose} title={title}>{children}</ModalDialog> <ModalDialog isOpen size="xl" isOverflowVisible={false} onClose={onClose} title={title}>{children}</ModalDialog>
); );
}; };
export const EditorModalBody: React.FC<WrapperProps> = ({ children }) => { export const EditorModalBody: React.FC<WrapperProps> = ({ children }) => <ModalDialog.Body className="pb-0">{ children }</ModalDialog.Body>;
const { fullScreen } = useEditorContext();
return <ModalDialog.Body className={fullScreen ? 'pb-6' : 'pb-0'}>{ children }</ModalDialog.Body>;
};
export const FooterWrapper: React.FC<WrapperProps> = ({ children }) => { // eslint-disable-next-line react/jsx-no-useless-fragment
const { fullScreen } = useEditorContext(); export const FooterWrapper: React.FC<WrapperProps> = ({ children }) => <>{ children }</>;
if (fullScreen) {
return <div className="editor-footer fixed-bottom">{children}</div>;
}
// eslint-disable-next-line react/jsx-no-useless-fragment
return <>{ children }</>;
};
interface Props extends EditorComponent { interface Props extends EditorComponent {
children: React.ReactNode; children: React.ReactNode;

View File

@@ -20,7 +20,7 @@ describe('SelectTypeModal', () => {
jest.spyOn(hooks, 'onSelect').mockImplementation(mockSelect); jest.spyOn(hooks, 'onSelect').mockImplementation(mockSelect);
// This is a new-style test, unlike most of the old snapshot-based editor tests. // This is a new-style test, unlike most of the old snapshot-based editor tests.
render( render(
<EditorContextProvider fullScreen={false} learningContextId="course-v1:Org+COURSE+RUN"> <EditorContextProvider learningContextId="course-v1:Org+COURSE+RUN">
<Provider store={editorStore}> <Provider store={editorStore}>
<SelectTypeModal onClose={mockClose} /> <SelectTypeModal onClose={mockClose} />
</Provider> </Provider>

View File

@@ -50,13 +50,13 @@ exports[`TextEditor snapshots block failed to load, Toast is shown 1`] = `
} }
editorType="text" editorType="text"
enableImageUpload={true} enableImageUpload={true}
height="100%"
id={null} id={null}
images={{}} images={{}}
initializeEditor={[MockFunction args.intializeEditor]} initializeEditor={[MockFunction args.intializeEditor]}
isLibrary={null} isLibrary={null}
learningContextId="course+org+run" learningContextId="course+org+run"
lmsEndpointUrl="" lmsEndpointUrl=""
maxHeight={500}
minHeight={500} minHeight={500}
onChange={[Function]} onChange={[Function]}
setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]} setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]}
@@ -226,13 +226,13 @@ exports[`TextEditor snapshots renders as expected with default behavior 1`] = `
} }
editorType="text" editorType="text"
enableImageUpload={true} enableImageUpload={true}
height="100%"
id={null} id={null}
images={{}} images={{}}
initializeEditor={[MockFunction args.intializeEditor]} initializeEditor={[MockFunction args.intializeEditor]}
isLibrary={null} isLibrary={null}
learningContextId="course+org+run" learningContextId="course+org+run"
lmsEndpointUrl="" lmsEndpointUrl=""
maxHeight={500}
minHeight={500} minHeight={500}
onChange={[Function]} onChange={[Function]}
setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]} setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]}
@@ -292,13 +292,13 @@ exports[`TextEditor snapshots renders static images with relative paths 1`] = `
} }
editorType="text" editorType="text"
enableImageUpload={true} enableImageUpload={true}
height="100%"
id={null} id={null}
images={{}} images={{}}
initializeEditor={[MockFunction args.intializeEditor]} initializeEditor={[MockFunction args.intializeEditor]}
isLibrary={null} isLibrary={null}
learningContextId="course+org+run" learningContextId="course+org+run"
lmsEndpointUrl="" lmsEndpointUrl=""
maxHeight={500}
minHeight={500} minHeight={500}
onChange={[Function]} onChange={[Function]}
setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]} setEditorRef={[MockFunction hooks.prepareEditorRef.setEditorRef]}

View File

@@ -65,7 +65,7 @@ const TextEditor = ({
editorContentHtml={editorContent} editorContentHtml={editorContent}
setEditorRef={setEditorRef} setEditorRef={setEditorRef}
minHeight={500} minHeight={500}
height="100%" maxHeight={500}
initializeEditor={initializeEditor} initializeEditor={initializeEditor}
{...{ {...{
images, images,

View File

@@ -18,6 +18,7 @@ exports[`VideoEditor snapshots renders as expected with default behavior 1`] = `
"useSelector": [MockFunction], "useSelector": [MockFunction],
} }
} }
onClose={[MockFunction props.onClose]}
/> />
</div> </div>
</EditorContainer> </EditorContainer>

View File

@@ -7,7 +7,9 @@ import VideoSettingsModal from './VideoSettingsModal';
import { RequestKeys } from '../../../data/constants/requests'; import { RequestKeys } from '../../../data/constants/requests';
interface Props { interface Props {
onReturn?: (() => void);
isLibrary: boolean; isLibrary: boolean;
onClose?: (() => void) | null;
} }
export const { export const {
@@ -27,13 +29,15 @@ export const hooks = {
const VideoEditorModal: React.FC<Props> = ({ const VideoEditorModal: React.FC<Props> = ({
isLibrary, isLibrary,
onClose,
onReturn,
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const location = useLocation(); const location = useLocation();
const searchParams = new URLSearchParams(location.search); const searchParams = new URLSearchParams(location.search);
const selectedVideoId = searchParams.get('selectedVideoId'); const selectedVideoId = searchParams.get('selectedVideoId');
const selectedVideoUrl = searchParams.get('selectedVideoUrl'); const selectedVideoUrl = searchParams.get('selectedVideoUrl');
const onReturn = hooks.useReturnToGallery(); const onSettingsReturn = onReturn || hooks.useReturnToGallery();
const isLoaded = useSelector( const isLoaded = useSelector(
(state) => selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchVideos }), (state) => selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchVideos }),
); );
@@ -44,8 +48,9 @@ const VideoEditorModal: React.FC<Props> = ({
return ( return (
<VideoSettingsModal {...{ <VideoSettingsModal {...{
onReturn, onReturn: onSettingsReturn,
isLibrary, isLibrary,
onClose,
}} }}
/> />
); );

View File

@@ -20,11 +20,13 @@ import messages from '../../messages';
interface Props { interface Props {
onReturn: () => void; onReturn: () => void;
isLibrary: boolean; isLibrary: boolean;
onClose?: (() => void) | null;
} }
const VideoSettingsModal: React.FC<Props> = ({ const VideoSettingsModal: React.FC<Props> = ({
onReturn, onReturn,
isLibrary, isLibrary,
onClose,
}) => ( }) => (
<> <>
{!isLibrary && ( {!isLibrary && (
@@ -32,7 +34,7 @@ const VideoSettingsModal: React.FC<Props> = ({
variant="link" variant="link"
className="text-primary-500" className="text-primary-500"
size="sm" size="sm"
onClick={onReturn} onClick={onClose || onReturn}
style={{ style={{
textDecoration: 'none', textDecoration: 'none',
marginLeft: '3px', marginLeft: '3px',

View File

@@ -39,7 +39,7 @@ const VideoEditor: React.FC<EditorComponent> = ({
> >
{(isCreateWorkflow || studioViewFinished) ? ( {(isCreateWorkflow || studioViewFinished) ? (
<div className="video-editor"> <div className="video-editor">
<VideoEditorModal {...{ isLibrary }} /> <VideoEditorModal {...{ isLibrary, onClose, returnFunction }} />
</div> </div>
) : ( ) : (
<div style={{ <div style={{

View File

@@ -86,6 +86,7 @@ export const filterList = ({
export const useVideoListProps = ({ export const useVideoListProps = ({
searchSortProps, searchSortProps,
videos, videos,
returnFunction,
}) => { }) => {
const [highlighted, setHighlighted] = React.useState(null); const [highlighted, setHighlighted] = React.useState(null);
const [ const [
@@ -128,7 +129,10 @@ export const useVideoListProps = ({
}, },
selectBtnProps: { selectBtnProps: {
onClick: () => { onClick: () => {
if (highlighted) { /* istanbul ignore next */
if (returnFunction) {
returnFunction()();
} else if (highlighted) {
navigateTo(`/course/${learningContextId}/editor/video/${blockId}?selectedVideoId=${highlighted}`); navigateTo(`/course/${learningContextId}/editor/video/${blockId}?selectedVideoId=${highlighted}`);
} else { } else {
setShowSelectVideoError(true); setShowSelectVideoError(true);
@@ -138,10 +142,15 @@ export const useVideoListProps = ({
}; };
}; };
export const useVideoUploadHandler = ({ replace }) => { export const useVideoUploadHandler = ({ replace, uploadHandler }) => {
const learningContextId = useSelector(selectors.app.learningContextId); const learningContextId = useSelector(selectors.app.learningContextId);
const blockId = useSelector(selectors.app.blockId); const blockId = useSelector(selectors.app.blockId);
const path = `/course/${learningContextId}/editor/video_upload/${blockId}`; const path = `/course/${learningContextId}/editor/video_upload/${blockId}`;
if (uploadHandler) {
return () => {
uploadHandler();
};
}
if (replace) { if (replace) {
return () => window.location.replace(path); return () => window.location.replace(path);
} }
@@ -191,11 +200,12 @@ export const getstatusBadgeVariant = ({ status }) => {
export const getStatusMessage = ({ status }) => Object.values(filterMessages).find((m) => m.defaultMessage === status); export const getStatusMessage = ({ status }) => Object.values(filterMessages).find((m) => m.defaultMessage === status);
export const useVideoProps = ({ videos }) => { export const useVideoProps = ({ videos, uploadHandler, returnFunction }) => {
const searchSortProps = useSearchAndSortProps(); const searchSortProps = useSearchAndSortProps();
const videoList = useVideoListProps({ const videoList = useVideoListProps({
searchSortProps, searchSortProps,
videos, videos,
returnFunction,
}); });
const { const {
galleryError, galleryError,
@@ -203,7 +213,7 @@ export const useVideoProps = ({ videos }) => {
inputError, inputError,
selectBtnProps, selectBtnProps,
} = videoList; } = videoList;
const fileInput = { click: useVideoUploadHandler({ replace: false }) }; const fileInput = { click: useVideoUploadHandler({ replace: false, uploadHandler }) };
return { return {
galleryError, galleryError,

View File

@@ -1,5 +1,10 @@
import React, { useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { Image } from '@openedx/paragon'; import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Image, useToggle, StandardModal,
} from '@openedx/paragon';
import { useSearchParams } from 'react-router-dom';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { selectors } from '../../data/redux'; import { selectors } from '../../data/redux';
import * as hooks from './hooks'; import * as hooks from './hooks';
@@ -8,8 +13,11 @@ import { acceptedImgKeys } from './utils';
import messages from './messages'; import messages from './messages';
import { RequestKeys } from '../../data/constants/requests'; import { RequestKeys } from '../../data/constants/requests';
import videoThumbnail from '../../data/images/videoThumbnail.svg'; import videoThumbnail from '../../data/images/videoThumbnail.svg';
import VideoUploadEditor from '../VideoUploadEditor';
import VideoEditor from '../VideoEditor';
const VideoGallery = () => { const VideoGallery = ({ returnFunction, onCancel }) => {
const intl = useIntl();
const rawVideos = useSelector(selectors.app.videos); const rawVideos = useSelector(selectors.app.videos);
const isLoaded = useSelector( const isLoaded = useSelector(
(state) => selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchVideos }), (state) => selectors.requests.isFinished(state, { requestKey: RequestKeys.fetchVideos }),
@@ -21,14 +29,27 @@ const VideoGallery = () => {
(state) => selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadVideo }), (state) => selectors.requests.isFailed(state, { requestKey: RequestKeys.uploadVideo }),
); );
const videos = hooks.buildVideos({ rawVideos }); const videos = hooks.buildVideos({ rawVideos });
const handleVideoUpload = hooks.useVideoUploadHandler({ replace: true }); const [isVideoUploadModalOpen, showVideoUploadModal, closeVideoUploadModal] = useToggle();
const [isVideoEditorModalOpen, showVideoEditorModal, closeVideoEditorModal] = useToggle();
const setSearchParams = useSearchParams()[1];
useEffect(() => { useEffect(() => {
// If no videos exists redirects to the video upload screen // If no videos exists opens to the video upload modal
if (isLoaded && videos.length === 0) { if (isLoaded && videos.length === 0) {
handleVideoUpload(); showVideoUploadModal();
} }
}, [isLoaded]); }, [isLoaded]);
const onVideoUpload = useCallback((videoUrl) => {
closeVideoUploadModal();
showVideoEditorModal();
setSearchParams({ selectedVideoUrl: videoUrl });
}, [closeVideoUploadModal, showVideoEditorModal, setSearchParams]);
const uploadHandler = useCallback(() => {
showVideoUploadModal();
});
const { const {
galleryError, galleryError,
inputError, inputError,
@@ -36,7 +57,7 @@ const VideoGallery = () => {
galleryProps, galleryProps,
searchSortProps, searchSortProps,
selectBtnProps, selectBtnProps,
} = hooks.useVideoProps({ videos }); } = hooks.useVideoProps({ videos, uploadHandler, returnFunction });
const handleCancel = hooks.useCancelHandler(); const handleCancel = hooks.useCancelHandler();
const modalMessages = { const modalMessages = {
@@ -60,8 +81,8 @@ const VideoGallery = () => {
<SelectionModal <SelectionModal
{...{ {...{
isOpen: true, isOpen: true,
close: handleCancel, close: onCancel || handleCancel,
size: 'fullscreen', size: 'xl',
isFullscreenScroll: false, isFullscreenScroll: false,
galleryError, galleryError,
inputError, inputError,
@@ -79,10 +100,34 @@ const VideoGallery = () => {
isFetchError, isFetchError,
}} }}
/> />
<StandardModal
title={intl.formatMessage(messages.videoUploadModalTitle)}
isOpen={isVideoUploadModalOpen}
onClose={closeVideoUploadModal}
isOverflowVisible={false}
size="xl"
hasCloseButton={false}
>
<div className="editor-page">
<VideoUploadEditor
onUpload={onVideoUpload}
onClose={closeVideoUploadModal}
/>
</div>
</StandardModal>
{isVideoEditorModalOpen && (
<VideoEditor
onClose={closeVideoEditorModal}
returnFunction={returnFunction}
/>
)}
</div> </div>
); );
}; };
VideoGallery.propTypes = {}; VideoGallery.propTypes = {
onCancel: PropTypes.func,
returnFunction: PropTypes.func,
};
export default VideoGallery; export default VideoGallery;

View File

@@ -6,6 +6,8 @@ import React from 'react';
import { import {
act, fireEvent, render, screen, act, fireEvent, render, screen,
} from '@testing-library/react'; } from '@testing-library/react';
import * as reactRouterDom from 'react-router-dom';
import * as reduxThunks from '../../data/redux';
import VideoGallery from './index'; import VideoGallery from './index';
@@ -120,11 +122,10 @@ describe('VideoGallery', () => {
expect(screen.getByText(video.client_video_id)).toBeInTheDocument() expect(screen.getByText(video.client_video_id)).toBeInTheDocument()
)); ));
}); });
it('navigates to video upload page when there are no videos', async () => { it('renders video upload modal when there are no videos', async () => {
expect(window.location.replace).not.toHaveBeenCalled();
updateState({ videos: [] }); updateState({ videos: [] });
await renderComponent(); await renderComponent();
expect(window.location.replace).toHaveBeenCalled(); expect(screen.getByRole('heading', { name: /upload or embed a new video/i })).toBeInTheDocument();
}); });
it.each([ it.each([
[/newest/i, [2, 1, 3]], [/newest/i, [2, 1, 3]],
@@ -191,5 +192,36 @@ describe('VideoGallery', () => {
expect(screen.queryByText('client_id_1')).not.toBeInTheDocument(); expect(screen.queryByText('client_id_1')).not.toBeInTheDocument();
expect(screen.queryByText('client_id_3')).not.toBeInTheDocument(); expect(screen.queryByText('client_id_3')).not.toBeInTheDocument();
}); });
it('calls onVideoUpload correctly when a video is uploaded', async () => {
// Mock useSearchParams
const setSearchParams = jest.fn();
jest.spyOn(reactRouterDom, 'useSearchParams').mockReturnValue([{}, setSearchParams]);
// Mock the uploadVideo thunk to immediately call postUploadRedirect
jest.spyOn(reduxThunks.thunkActions.video, 'uploadVideo').mockImplementation(
({ postUploadRedirect }) => () => {
if (postUploadRedirect) {
postUploadRedirect('http://test.video/url.mp4');
}
return { type: 'MOCK_UPLOAD_VIDEO' };
},
);
await renderComponent();
// Open the upload modal by clicking the button
const openModalButton = screen.getByRole('button', { name: /upload or embed a new video/i });
fireEvent.click(openModalButton);
// Wait for the input to appear in the modal
const urlInput = await screen.findByPlaceholderText('Paste your video ID or URL');
fireEvent.change(urlInput, { target: { value: 'http://test.video/url.mp4' } });
const submitButton = screen.getByRole('button', { name: /submit/i });
fireEvent.click(submitButton);
expect(setSearchParams).toHaveBeenCalledWith({ selectedVideoUrl: 'http://test.video/url.mp4' });
});
}); });
}); });

View File

@@ -21,7 +21,16 @@ const messages = {
defaultMessage: 'Upload or embed a new video', defaultMessage: 'Upload or embed a new video',
description: 'Label for upload button', description: 'Label for upload button',
}, },
videoUploadModalTitle: {
id: 'authoring.selectvideomodal.upload.title',
defaultMessage: 'Upload or embed a new video',
description: 'Label for upload modal',
},
videoEditorModalTitle: {
id: 'authoring.selectvideomodal.edit.title',
defaultMessage: 'Edit selected video',
description: 'Label for editor modal',
},
// Sort Dropdown // Sort Dropdown
sortByDateNewest: { sortByDateNewest: {
id: 'authoring.selectvideomodal.sort.datenewest.label', id: 'authoring.selectvideomodal.sort.datenewest.label',

View File

@@ -10,9 +10,9 @@ import { thunkActions } from '../../data/redux';
import * as hooks from './hooks'; import * as hooks from './hooks';
import messages from './messages'; import messages from './messages';
const URLUploader = () => { const URLUploader = ({ onUpload }) => {
const [textInputValue, setTextInputValue] = React.useState(''); const [textInputValue, setTextInputValue] = React.useState('');
const onURLUpload = hooks.onVideoUpload('selectedVideoUrl'); const onURLUpload = hooks.onVideoUpload('selectedVideoUrl', onUpload);
const intl = useIntl(); const intl = useIntl();
return ( return (
<div className="d-flex flex-column"> <div className="d-flex flex-column">
@@ -58,16 +58,16 @@ const URLUploader = () => {
); );
}; };
export const VideoUploader = ({ setLoading }) => { export const VideoUploader = ({ setLoading, onUpload, onClose }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const intl = useIntl(); const intl = useIntl();
const goBack = hooks.useHistoryGoBack(); const goBack = onClose || hooks.useHistoryGoBack();
const handleProcessUpload = ({ fileData }) => { const handleProcessUpload = ({ fileData }) => {
dispatch(thunkActions.video.uploadVideo({ dispatch(thunkActions.video.uploadVideo({
supportedFiles: [fileData], supportedFiles: [fileData],
setLoadSpinner: setLoading, setLoadSpinner: setLoading,
postUploadRedirect: hooks.onVideoUpload('selectedVideoId'), postUploadRedirect: hooks.onVideoUpload('selectedVideoId', onUpload),
})); }));
}; };
@@ -85,14 +85,20 @@ export const VideoUploader = ({ setLoading }) => {
<Dropzone <Dropzone
accept={{ 'video/*': ['.mp4', '.mov'] }} accept={{ 'video/*': ['.mp4', '.mov'] }}
onProcessUpload={handleProcessUpload} onProcessUpload={handleProcessUpload}
inputComponent={<URLUploader />} inputComponent={<URLUploader onUpload={onUpload} />}
/> />
</div> </div>
); );
}; };
URLUploader.propTypes = {
onUpload: PropTypes.func,
};
VideoUploader.propTypes = { VideoUploader.propTypes = {
setLoading: PropTypes.func.isRequired, setLoading: PropTypes.func.isRequired,
onUpload: PropTypes.func,
onClose: PropTypes.func,
}; };
export default VideoUploader; export default VideoUploader;

View File

@@ -11,15 +11,20 @@ export const {
navigateTo, navigateTo,
} = appHooks; } = appHooks;
export const postUploadRedirect = (storeState, uploadType = 'selectedVideoUrl') => { export const postUploadRedirect = (storeState, uploadType = 'selectedVideoUrl', onUpload = null) => {
const learningContextId = selectors.app.learningContextId(storeState); const learningContextId = selectors.app.learningContextId(storeState);
const blockId = selectors.app.blockId(storeState); const blockId = selectors.app.blockId(storeState);
if (onUpload) {
return (videoUrl) => {
onUpload(videoUrl, learningContextId, blockId);
};
}
return (videoUrl) => navigateTo(`/course/${learningContextId}/editor/video/${blockId}?${uploadType}=${videoUrl}`); return (videoUrl) => navigateTo(`/course/${learningContextId}/editor/video/${blockId}?${uploadType}=${videoUrl}`);
}; };
export const onVideoUpload = (uploadType) => { export const onVideoUpload = (uploadType, onUpload) => {
const storeState = store.getState(); const storeState = store.getState();
return module.postUploadRedirect(storeState, uploadType); return module.postUploadRedirect(storeState, uploadType, onUpload);
}; };
export const useUploadVideo = async ({ export const useUploadVideo = async ({

View File

@@ -1,17 +1,18 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { Spinner } from '@openedx/paragon'; import { Spinner } from '@openedx/paragon';
import './index.scss'; import './index.scss';
import messages from './messages'; import messages from './messages';
import { VideoUploader } from './VideoUploader'; import { VideoUploader } from './VideoUploader';
const VideoUploadEditor = () => { const VideoUploadEditor = ({ onUpload, onClose }) => {
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const intl = useIntl(); const intl = useIntl();
return (!loading) ? ( return (!loading) ? (
<div className="d-flex marked-area flex-column p-3"> <div className="d-flex marked-area flex-column p-3">
<VideoUploader setLoading={setLoading} /> <VideoUploader onUpload={onUpload} onClose={onClose} setLoading={setLoading} />
</div> </div>
) : ( ) : (
<div style={{ <div style={{
@@ -30,4 +31,9 @@ const VideoUploadEditor = () => {
); );
}; };
VideoUploadEditor.propTypes = {
onUpload: PropTypes.func,
onClose: PropTypes.func,
};
export default VideoUploadEditor; export default VideoUploadEditor;

View File

@@ -10,7 +10,6 @@ import {
} from './problem'; } from './problem';
import { checkboxesOLXWithFeedbackAndHintsOLX, advancedProblemOlX, blankProblemOLX } from '../../../containers/ProblemEditor/data/mockData/olxTestData'; import { checkboxesOLXWithFeedbackAndHintsOLX, advancedProblemOlX, blankProblemOLX } from '../../../containers/ProblemEditor/data/mockData/olxTestData';
import { ProblemTypeKeys } from '../../constants/problem'; import { ProblemTypeKeys } from '../../constants/problem';
import * as requests from './requests';
const mockOlx = 'SOmEVALue'; const mockOlx = 'SOmEVALue';
const mockBuildOlx = jest.fn(() => mockOlx); const mockBuildOlx = jest.fn(() => mockOlx);
@@ -72,22 +71,13 @@ describe('problem thunkActions', () => {
); );
}); });
test('switchToMarkdownEditor dispatches correct actions', () => { test('switchToMarkdownEditor dispatches correct actions', () => {
switchToMarkdownEditor()(dispatch, getState); switchToMarkdownEditor()(dispatch);
expect(dispatch).toHaveBeenCalledWith( expect(dispatch).toHaveBeenCalledWith(
actions.problem.updateField({ actions.problem.updateField({
isMarkdownEditorEnabled: true, isMarkdownEditorEnabled: true,
}), }),
); );
expect(dispatch).toHaveBeenCalledWith(
requests.saveBlock({
content: {
settings: { markdown_edited: true },
olx: blockValue.data.data,
},
}),
);
}); });
describe('switchEditor', () => { describe('switchEditor', () => {
@@ -110,7 +100,7 @@ describe('problem thunkActions', () => {
test('dispatches switchToMarkdownEditor when editorType is markdown', () => { test('dispatches switchToMarkdownEditor when editorType is markdown', () => {
switchEditor('markdown')(dispatch, getState); switchEditor('markdown')(dispatch, getState);
expect(switchToMarkdownEditorMock).toHaveBeenCalledWith(dispatch, getState); expect(switchToMarkdownEditorMock).toHaveBeenCalledWith(dispatch);
}); });
}); });

View File

@@ -24,17 +24,17 @@ export const switchToAdvancedEditor = () => (dispatch, getState) => {
dispatch(actions.problem.updateField({ problemType: ProblemTypeKeys.ADVANCED, rawOLX })); dispatch(actions.problem.updateField({ problemType: ProblemTypeKeys.ADVANCED, rawOLX }));
}; };
export const switchToMarkdownEditor = () => (dispatch, getState) => { export const switchToMarkdownEditor = () => (dispatch) => {
const state = getState();
dispatch(actions.problem.updateField({ isMarkdownEditorEnabled: true })); dispatch(actions.problem.updateField({ isMarkdownEditorEnabled: true }));
const { blockValue } = state.app;
const olx = get(blockValue, 'data.data', '');
const content = { settings: { markdown_edited: true }, olx };
// Sending a request to save the problem block with the updated markdown_edited value
dispatch(requests.saveBlock({ content }));
}; };
export const switchEditor = (editorType) => (dispatch, getState) => (editorType === 'advanced' ? switchToAdvancedEditor : switchToMarkdownEditor)()(dispatch, getState); export const switchEditor = (editorType) => (dispatch, getState) => {
if (editorType === 'advanced') {
switchToAdvancedEditor()(dispatch, getState);
} else {
switchToMarkdownEditor()(dispatch);
}
};
export const isBlankProblem = ({ rawOLX }) => { export const isBlankProblem = ({ rawOLX }) => {
if (['<problem></problem>', '<problem/>'].includes(rawOLX.replace(/\s/g, ''))) { if (['<problem></problem>', '<problem/>'].includes(rawOLX.replace(/\s/g, ''))) {

View File

@@ -100,7 +100,7 @@ export const createCodeMirrorDomNode = ({
}) => { }) => {
// eslint-disable-next-line react-hooks/rules-of-hooks // eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => { useEffect(() => {
const languageExtension = CODEMIRROR_LANGUAGES[lang](); const languageExtension = CODEMIRROR_LANGUAGES[lang] ? CODEMIRROR_LANGUAGES[lang]() : xml();
const cleanText = cleanHTML({ initialText }); const cleanText = cleanHTML({ initialText });
const newState = EditorState.create({ const newState = EditorState.create({
doc: cleanText, doc: cleanText,

View File

@@ -41,6 +41,7 @@ exports[`SourceCodeModal renders as expected with default behavior 1`] = `
> >
<injectIntl(ShimmedIntlComponent) <injectIntl(ShimmedIntlComponent)
innerRef="moCKrEf" innerRef="moCKrEf"
lang="html"
value="mOckHtMl" value="mOckHtMl"
/> />
</div> </div>

View File

@@ -1,4 +1,3 @@
import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { import {
@@ -41,6 +40,7 @@ const SourceCodeModal = ({
<CodeEditor <CodeEditor
innerRef={ref} innerRef={ref}
value={value} value={value}
lang="html" // hardcoded value to do lookup on CODEMIRROR_LANGUAGES
/> />
</div> </div>
</BaseModal> </BaseModal>

View File

@@ -304,6 +304,7 @@ export const editorConfig = ({
updateContent, updateContent,
content, content,
minHeight, minHeight,
maxHeight,
learningContextId, learningContextId,
staticRootUrl, staticRootUrl,
enableImageUpload, enableImageUpload,
@@ -335,6 +336,7 @@ export const editorConfig = ({
content_css: false, content_css: false,
content_style: tinyMCEStyles + a11ycheckerCss, content_style: tinyMCEStyles + a11ycheckerCss,
min_height: minHeight, min_height: minHeight,
max_height: maxHeight,
contextmenu: 'link table', contextmenu: 'link table',
directionality: isLocaleRtl ? 'rtl' : 'ltr', directionality: isLocaleRtl ? 'rtl' : 'ltr',
document_base_url: baseURL, document_base_url: baseURL,

View File

@@ -32,6 +32,7 @@ import { getFileSizeToClosestByte } from '../../utils';
import FileThumbnail from './FileThumbnail'; import FileThumbnail from './FileThumbnail';
import FileInfoModalSidebar from './FileInfoModalSidebar'; import FileInfoModalSidebar from './FileInfoModalSidebar';
import FileValidationModal from './FileValidationModal'; import FileValidationModal from './FileValidationModal';
import './FilesPage.scss';
const FilesPage = ({ const FilesPage = ({
courseId, courseId,

View File

@@ -0,0 +1,5 @@
.files-table {
.pgn__data-table-container {
overflow-x: visible;
}
}

View File

@@ -1,10 +1,9 @@
import React, { useCallback } from 'react'; import { useCallback } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { import {
DndContext, DndContext,
closestCenter,
KeyboardSensor, KeyboardSensor,
PointerSensor, PointerSensor,
useSensor, useSensor,
@@ -18,6 +17,7 @@ import {
verticalListSortingStrategy, verticalListSortingStrategy,
} from '@dnd-kit/sortable'; } from '@dnd-kit/sortable';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { verticalSortableListCollisionDetection } from './verticalSortableList';
const DraggableList = ({ const DraggableList = ({
itemList, itemList,
@@ -56,13 +56,20 @@ const DraggableList = ({
setActiveId?.(event.active.id); setActiveId?.(event.active.id);
}, [setActiveId]); }, [setActiveId]);
const handleDragCancel = useCallback(() => {
setActiveId?.(null);
}, [setActiveId]);
return ( return (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
modifiers={[restrictToVerticalAxis]} modifiers={[restrictToVerticalAxis]}
collisionDetection={closestCenter} collisionDetection={verticalSortableListCollisionDetection}
onDragStart={handleDragStart} onDragStart={handleDragStart}
// autoScroll does not play well with verticalSortableListCollisionDetection strategy
autoScroll={false}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
> >
<SortableContext <SortableContext
items={itemList} items={itemList}

View File

@@ -18,6 +18,7 @@ const SortableItem = ({
isClickable, isClickable,
onClick, onClick,
disabled, disabled,
cardClassName = '',
// injected // injected
intl, intl,
}) => { }) => {
@@ -45,14 +46,15 @@ const SortableItem = ({
}; };
return ( return (
/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */
<div <div
ref={setNodeRef} ref={setNodeRef}
onClick={onClick}
> >
<Card <Card
style={style} style={style}
className="mx-0" className={`mx-0 ${cardClassName}`}
isClickable={isClickable} isClickable={isClickable}
onClick={onClick}
> >
<ActionRow style={actionStyle}> <ActionRow style={actionStyle}>
{actions} {actions}
@@ -93,6 +95,7 @@ SortableItem.propTypes = {
isClickable: PropTypes.bool, isClickable: PropTypes.bool,
onClick: PropTypes.func, onClick: PropTypes.func,
disabled: PropTypes.bool, disabled: PropTypes.bool,
cardClassName: PropTypes.string,
// injected // injected
intl: intlShape.isRequired, intl: intlShape.isRequired,
}; };

View File

@@ -0,0 +1,80 @@
/* istanbul ignore file */
/**
This sorting strategy was copied over from https://github.com/clauderic/dnd-kit/pull/805
to resolve issues with variable sized draggables.
*/
import { CollisionDetection, DroppableContainer } from '@dnd-kit/core';
import { sortBy } from 'lodash';
const collision = (dropppableContainer?: DroppableContainer) => ({
id: dropppableContainer?.id ?? '',
value: dropppableContainer,
});
// Look for the first (/ furthest up / highest) droppable container that is at least
// 50% covered by the top edge of the dragging container.
const highestDroppableContainerMajorityCovered: CollisionDetection = ({
droppableContainers,
collisionRect,
}) => {
const ascendingDroppabaleContainers = sortBy(
droppableContainers,
(c) => c?.rect.current?.top,
);
for (const droppableContainer of ascendingDroppabaleContainers) {
const {
rect: { current: droppableRect },
} = droppableContainer;
if (droppableRect) {
const coveredPercentage = (droppableRect.top + droppableRect.height - collisionRect.top)
/ droppableRect.height;
if (coveredPercentage > 0.5) {
return [collision(droppableContainer)];
}
}
}
// if we haven't found anything then we are off the top, so return the first item
return [collision(ascendingDroppabaleContainers[0])];
};
// Look for the last (/ furthest down / lowest) droppable container that is at least
// 50% covered by the bottom edge of the dragging container.
const lowestDroppableContainerMajorityCovered: CollisionDetection = ({
droppableContainers,
collisionRect,
}) => {
const descendingDroppabaleContainers = sortBy(
droppableContainers,
(c) => c?.rect.current?.top,
).reverse();
for (const droppableContainer of descendingDroppabaleContainers) {
const {
rect: { current: droppableRect },
} = droppableContainer;
if (droppableRect) {
const coveredPercentage = (collisionRect.bottom - droppableRect.top) / droppableRect.height;
if (coveredPercentage > 0.5) {
return [collision(droppableContainer)];
}
}
}
// if we haven't found anything then we are off the bottom, so return the last item
return [collision(descendingDroppabaleContainers[0])];
};
export const verticalSortableListCollisionDetection: CollisionDetection = (
args,
) => {
if (args.collisionRect.top < (args.active.rect.current?.initial?.top ?? 0)) {
return highestDroppableContainerMajorityCovered(args);
}
return lowestDroppableContainerMajorityCovered(args);
};

View File

@@ -0,0 +1,6 @@
// TODO: remove this after upstream fix merging: https://github.com/openedx/paragon/pull/3562
.alert {
.alert-message-content {
align-self: baseline;
}
}

View File

@@ -1,4 +1,3 @@
import PropTypes from 'prop-types';
import { import {
ActionRow, ActionRow,
Button, Button,
@@ -9,17 +8,29 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages'; import messages from './messages';
import LoadingButton from '../loading-button'; import LoadingButton from '../loading-button';
interface DeleteModalProps {
isOpen: boolean;
close: () => void;
category?: string;
onDeleteSubmit: () => void | Promise<void>;
title?: string;
description?: React.ReactNode | React.ReactNode[];
variant?: string;
btnLabel?: string;
icon?: React.ElementType;
}
const DeleteModal = ({ const DeleteModal = ({
category, category = '',
isOpen, isOpen,
close, close,
onDeleteSubmit, onDeleteSubmit,
title, title,
description, description,
variant, variant = 'default',
btnLabel, btnLabel,
icon, icon,
}) => { }: DeleteModalProps) => {
const intl = useIntl(); const intl = useIntl();
const modalTitle = title || intl.formatMessage(messages.title, { category }); const modalTitle = title || intl.formatMessage(messages.title, { category });
@@ -62,28 +73,4 @@ const DeleteModal = ({
); );
}; };
DeleteModal.defaultProps = {
category: '',
title: '',
description: '',
variant: 'default',
btnLabel: '',
icon: null,
};
DeleteModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
close: PropTypes.func.isRequired,
category: PropTypes.string,
onDeleteSubmit: PropTypes.func.isRequired,
title: PropTypes.string,
description: PropTypes.oneOfType([
PropTypes.element,
PropTypes.string,
]),
variant: PropTypes.string,
btnLabel: PropTypes.string,
icon: PropTypes.elementType,
};
export default DeleteModal; export default DeleteModal;

View File

@@ -96,7 +96,8 @@ describe('useIframeBehavior', () => {
window.dispatchEvent(new MessageEvent('message', message)); window.dispatchEvent(new MessageEvent('message', message));
}); });
expect(setIframeHeight).toHaveBeenCalledWith(500); // +10 padding
expect(setIframeHeight).toHaveBeenCalledWith(510);
expect(setHasLoaded).toHaveBeenCalledWith(true); expect(setHasLoaded).toHaveBeenCalledWith(true);
}); });

View File

@@ -46,7 +46,8 @@ export const useIframeBehavior = ({
switch (type) { switch (type) {
case iframeMessageTypes.resize: case iframeMessageTypes.resize:
setIframeHeight(payload.height); // Adding 10px as padding
setIframeHeight(payload.height + 10);
if (!hasLoaded && iframeHeight === 0 && payload.height > 0) { if (!hasLoaded && iframeHeight === 0 && payload.height > 0) {
setHasLoaded(true); setHasLoaded(true);
} }

View File

@@ -1,6 +1,11 @@
import React from 'react'; import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
import { fireEvent, render as baseRender, screen } from '@testing-library/react'; import {
act,
fireEvent,
render as baseRender,
screen,
} from '@testing-library/react';
import { InplaceTextEditor } from '.'; import { InplaceTextEditor } from '.';
const mockOnSave = jest.fn(); const mockOnSave = jest.fn();
@@ -24,8 +29,8 @@ describe('<InplaceTextEditor />', () => {
expect(screen.queryByRole('button', { name: /edit/ })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /edit/ })).not.toBeInTheDocument();
}); });
it('should render the edit button if alwaysShowEditButton is true', () => { it('should render the edit button', () => {
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} alwaysShowEditButton />); render(<InplaceTextEditor text="Test text" onSave={mockOnSave} />);
expect(screen.getByText('Test text')).toBeInTheDocument(); expect(screen.getByText('Test text')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
@@ -36,7 +41,10 @@ describe('<InplaceTextEditor />', () => {
const title = screen.getByText('Test text'); const title = screen.getByText('Test text');
expect(title).toBeInTheDocument(); expect(title).toBeInTheDocument();
fireEvent.click(title);
const editButton = screen.getByRole('button', { name: /edit/i });
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
const textBox = screen.getByRole('textbox'); const textBox = screen.getByRole('textbox');
@@ -52,7 +60,10 @@ describe('<InplaceTextEditor />', () => {
const title = screen.getByText('Test text'); const title = screen.getByText('Test text');
expect(title).toBeInTheDocument(); expect(title).toBeInTheDocument();
fireEvent.click(title);
const editButton = screen.getByRole('button', { name: /edit/i });
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
const textBox = screen.getByRole('textbox'); const textBox = screen.getByRole('textbox');
@@ -62,4 +73,62 @@ describe('<InplaceTextEditor />', () => {
expect(textBox).not.toBeInTheDocument(); expect(textBox).not.toBeInTheDocument();
expect(mockOnSave).not.toHaveBeenCalled(); expect(mockOnSave).not.toHaveBeenCalled();
}); });
it('should show the new text while processing and roolback in case of error', async () => {
let rejecter: (err: Error) => void;
const longMockOnSave = jest.fn().mockReturnValue(
new Promise<void>((_resolve, reject) => {
rejecter = reject;
}),
);
render(<InplaceTextEditor text="Test text" onSave={longMockOnSave} />);
const text = screen.getByText('Test text');
expect(text).toBeInTheDocument();
const editButton = screen.getByRole('button', { name: /edit/i });
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
const textBox = screen.getByRole('textbox');
fireEvent.change(textBox, { target: { value: 'New text' } });
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
expect(textBox).not.toBeInTheDocument();
expect(longMockOnSave).toHaveBeenCalledWith('New text');
// Show pending new text
const newText = screen.getByText('New text');
expect(newText).toBeInTheDocument();
await act(async () => { rejecter(new Error('error')); });
// Remove pending new text on error
expect(newText).not.toBeInTheDocument();
// Show original text
expect(screen.getByText('Test text')).toBeInTheDocument();
});
it('should disappear edit button while editing', async () => {
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} />);
const title = screen.getByText('Test text');
expect(title).toBeInTheDocument();
const editButton = screen.getByRole('button', { name: /edit/i });
expect(editButton).toBeInTheDocument();
fireEvent.click(editButton);
const textBox = screen.getByRole('textbox');
expect(editButton).not.toBeInTheDocument();
fireEvent.change(textBox, { target: { value: 'New text' } });
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
expect(textBox).not.toBeInTheDocument();
expect(mockOnSave).toHaveBeenCalledWith('New text');
expect(await screen.findByRole('button', { name: /edit/i })).toBeInTheDocument();
});
}); });

View File

@@ -1,14 +1,12 @@
import React, { import React, {
useCallback, useCallback,
useEffect,
useState, useState,
forwardRef,
} from 'react'; } from 'react';
import { import {
Form, Form,
Icon, Icon,
IconButton, IconButton,
OverlayTrigger, Truncate,
Stack, Stack,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { Edit } from '@openedx/paragon/icons'; import { Edit } from '@openedx/paragon/icons';
@@ -16,33 +14,11 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages'; import messages from './messages';
interface IconWrapperProps {
popper: any;
children: React.ReactNode;
[key: string]: any;
}
const IconWrapper = forwardRef<HTMLDivElement, IconWrapperProps>(({ popper, children, ...props }, ref) => {
useEffect(() => {
// This is a workaround to force the popper to update its position when
// the editor is opened.
// Ref: https://react-bootstrap.netlify.app/docs/components/overlays/#updating-position-dynamically
popper.scheduleUpdate();
}, [popper, children]);
return (
<div ref={ref} {...props}>
{children}
</div>
);
});
interface InplaceTextEditorProps { interface InplaceTextEditorProps {
text: string; text: string;
onSave: (newText: string) => void; onSave: (newText: string) => Promise<void>;
readOnly?: boolean; readOnly?: boolean;
textClassName?: string; textClassName?: string;
alwaysShowEditButton?: boolean;
} }
export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({ export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({
@@ -50,18 +26,29 @@ export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({
onSave, onSave,
readOnly = false, readOnly = false,
textClassName, textClassName,
alwaysShowEditButton = false,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
const [inputIsActive, setIsActive] = useState(false); const [inputIsActive, setIsActive] = useState(false);
const [pendingSaveText, setPendingSaveText] = useState<string>(); // state with the new text while updating
const handleOnChangeText = useCallback( const handleOnChangeText = useCallback(
(event) => { async (event: React.ChangeEvent<HTMLInputElement> | React.KeyboardEvent<HTMLInputElement>) => {
const newText = event.target.value; const inputText = event.currentTarget.value;
if (newText && newText !== text) {
onSave(newText);
}
setIsActive(false); setIsActive(false);
if (inputText && inputText !== text) {
// NOTE: While using react query for optimistic updates would be the best approach,
// it could not be possible in some cases. For that reason, we use the `pendingSaveText` state
// to show the new text while saving.
setPendingSaveText(inputText);
try {
await onSave(inputText);
} catch {
// don't propagate the exception
} finally {
// reset the pending save text
setPendingSaveText(undefined);
}
}
}, },
[text], [text],
); );
@@ -78,86 +65,46 @@ export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({
} }
}; };
if (readOnly) { // If we have the `pendingSaveText` state it means that we are in the process of saving the new text.
// In that case, we show the new text instead of the original in read-only mode as an optimistic update.
if (readOnly || pendingSaveText) {
return ( return (
<span className={textClassName}> <Truncate className={textClassName}>
{text} {pendingSaveText || text}
</span> </Truncate>
);
}
if (alwaysShowEditButton) {
return (
<Stack
direction="horizontal"
gap={1}
>
{inputIsActive
? (
<Form.Control
autoFocus
type="text"
aria-label="Text input"
defaultValue={text}
onBlur={handleOnChangeText}
onKeyDown={handleOnKeyDown}
/>
)
: (
<span className={textClassName}>
{text}
</span>
)}
<IconButton
src={Edit}
iconAs={Icon}
alt={intl.formatMessage(messages.editTextButtonAlt)}
onClick={handleEdit}
size="inline"
/>
</Stack>
); );
} }
return ( return (
<OverlayTrigger <Stack
trigger={['hover', 'focus']} direction="horizontal"
placement="right" gap={1}
overlay={(
<IconWrapper>
<Icon
id="edit-text-icon"
src={Edit}
className="ml-1.5"
onClick={handleEdit}
/>
</IconWrapper>
)}
> >
<div> {inputIsActive
{inputIsActive ? (
? ( <Form.Control
<Form.Control autoFocus
autoFocus type="text"
type="text" aria-label="Text input"
aria-label="Text input" defaultValue={text}
defaultValue={text} onBlur={handleOnChangeText}
onBlur={handleOnChangeText} onKeyDown={handleOnKeyDown}
onKeyDown={handleOnKeyDown} />
/> )
) : (
: ( <>
<span <Truncate className={textClassName}>
onClick={handleEdit}
onKeyDown={handleEdit}
className={textClassName}
role="button"
tabIndex={0}
>
{text} {text}
</span> </Truncate>
)} <IconButton
</div> src={Edit}
</OverlayTrigger> iconAs={Icon}
alt={intl.formatMessage(messages.editTextButtonAlt)}
onClick={handleEdit}
size="sm"
/>
</>
)}
</Stack>
); );
}; };

View File

@@ -12,4 +12,5 @@
@import "./modal-dropzone/ModalDropzone"; @import "./modal-dropzone/ModalDropzone";
@import "./configure-modal/ConfigureModal"; @import "./configure-modal/ConfigureModal";
@import "./block-type-utils"; @import "./block-type-utils";
@import "./modal-iframe" @import "./modal-iframe";
@import "./alert-message";

View File

@@ -55,6 +55,10 @@ const path = '/library/:libraryId/*';
const libraryTitle = mockContentLibrary.libraryData.title; const libraryTitle = mockContentLibrary.libraryData.title;
describe('<LibraryAuthoringPage />', () => { describe('<LibraryAuthoringPage />', () => {
beforeAll(() => {
jest.useFakeTimers();
});
beforeEach(async () => { beforeEach(async () => {
const mocks = initializeMocks(); const mocks = initializeMocks();
axiosMock = mocks.axiosMock; axiosMock = mocks.axiosMock;
@@ -78,6 +82,10 @@ describe('<LibraryAuthoringPage />', () => {
}); });
}); });
afterAll(() => {
jest.useRealTimers();
});
const renderLibraryPage = async () => { const renderLibraryPage = async () => {
render(<LibraryLayout />, { path, params: { libraryId: mockContentLibrary.libraryId } }); render(<LibraryLayout />, { path, params: { libraryId: mockContentLibrary.libraryId } });
@@ -362,7 +370,7 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.change(searchBox, { target: { value: 'words to find' } }); fireEvent.change(searchBox, { target: { value: 'words to find' } });
// Default sort option changes to "Most Relevant" // Default sort option changes to "Most Relevant"
expect(screen.getAllByText('Most Relevant').length).toEqual(2); expect((await screen.findAllByText('Most Relevant')).length).toEqual(2);
await waitFor(() => { await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining('"sort":[]'), body: expect.stringContaining('"sort":[]'),
@@ -392,7 +400,7 @@ describe('<LibraryAuthoringPage />', () => {
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
}); });
it('should open component sidebar, showing manage tab on clicking add to collection menu item (component)', async () => { it('should open component sidebar, showing manage tab on clicking add to collection menu item - component', async () => {
const mockResult0 = { ...mockResult }.results[0].hits[0]; const mockResult0 = { ...mockResult }.results[0].hits[0];
const displayName = 'Introduction to Testing'; const displayName = 'Introduction to Testing';
expect(mockResult0.display_name).toStrictEqual(displayName); expect(mockResult0.display_name).toStrictEqual(displayName);
@@ -407,9 +415,10 @@ describe('<LibraryAuthoringPage />', () => {
const sidebar = screen.getByTestId('library-sidebar'); const sidebar = screen.getByTestId('library-sidebar');
const { getByRole, queryByText } = within(sidebar); const { getByRole, findByText } = within(sidebar);
await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument()); expect(await findByText(displayName)).toBeInTheDocument();
jest.advanceTimersByTime(300);
expect(getByRole('tab', { selected: true })).toHaveTextContent('Manage'); expect(getByRole('tab', { selected: true })).toHaveTextContent('Manage');
const closeButton = getByRole('button', { name: /close/i }); const closeButton = getByRole('button', { name: /close/i });
fireEvent.click(closeButton); fireEvent.click(closeButton);
@@ -417,7 +426,7 @@ describe('<LibraryAuthoringPage />', () => {
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument()); await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
}); });
it('should open component sidebar, showing manage tab on clicking add to collection menu item (unit)', async () => { it('should open component sidebar, showing manage tab on clicking add to collection menu item - unit', async () => {
const displayName = 'Test Unit'; const displayName = 'Test Unit';
await renderLibraryPage(); await renderLibraryPage();
@@ -430,10 +439,11 @@ describe('<LibraryAuthoringPage />', () => {
const sidebar = screen.getByTestId('library-sidebar'); const sidebar = screen.getByTestId('library-sidebar');
const { getByRole, queryByText } = within(sidebar); const { getByRole, findByText } = within(sidebar);
await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument()); expect(await findByText(displayName)).toBeInTheDocument();
expect(getByRole('tab', { selected: true })).toHaveTextContent('Organize'); jest.advanceTimersByTime(300);
expect(getByRole('tab', { selected: true })).toHaveTextContent('Manage');
const closeButton = getByRole('button', { name: /close/i }); const closeButton = getByRole('button', { name: /close/i });
fireEvent.click(closeButton); fireEvent.click(closeButton);

View File

@@ -15,12 +15,11 @@ import {
Breadcrumb, Breadcrumb,
Button, Button,
Container, Container,
Icon,
Stack, Stack,
Tab, Tab,
Tabs, Tabs,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { Add, ArrowBack, InfoOutline } from '@openedx/paragon/icons'; import { Add, InfoOutline } from '@openedx/paragon/icons';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import Loading from '../generic/Loading'; import Loading from '../generic/Loading';
@@ -32,7 +31,6 @@ import {
ClearFiltersButton, ClearFiltersButton,
FilterByBlockType, FilterByBlockType,
FilterByTags, FilterByTags,
FilterByPublished,
SearchContextProvider, SearchContextProvider,
SearchKeywordsField, SearchKeywordsField,
SearchSortWidget, SearchSortWidget,
@@ -46,6 +44,7 @@ import { SidebarBodyComponentId, useSidebarContext } from './common/context/Side
import { allLibraryPageTabs, ContentType, useLibraryRoutes } from './routes'; import { allLibraryPageTabs, ContentType, useLibraryRoutes } from './routes';
import messages from './messages'; import messages from './messages';
import LibraryFilterByPublished from './generic/filter-by-published';
const HeaderActions = () => { const HeaderActions = () => {
const intl = useIntl(); const intl = useIntl();
@@ -114,7 +113,7 @@ export const SubHeaderTitle = ({ title }: { title: ReactNode }) => {
const showReadOnlyBadge = readOnly && !componentPickerMode; const showReadOnlyBadge = readOnly && !componentPickerMode;
return ( return (
<Stack direction="vertical"> <Stack direction="vertical" className="mt-1.5">
{title} {title}
{showReadOnlyBadge && ( {showReadOnlyBadge && (
<div> <div>
@@ -214,16 +213,11 @@ const LibraryAuthoringPage = ({
const breadcumbs = componentPickerMode && !restrictToLibrary ? ( const breadcumbs = componentPickerMode && !restrictToLibrary ? (
<Breadcrumb <Breadcrumb
links={[ links={[
{
label: '',
to: '',
},
{ {
label: intl.formatMessage(messages.returnToLibrarySelection), label: intl.formatMessage(messages.returnToLibrarySelection),
onClick: returnToLibrarySelection, onClick: returnToLibrarySelection,
}, },
]} ]}
spacer={<Icon src={ArrowBack} size="sm" />}
linkAs={Link} linkAs={Link}
/> />
) : undefined; ) : undefined;
@@ -246,6 +240,17 @@ const LibraryAuthoringPage = ({
extraFilter.push(activeTypeFilters[activeKey]); extraFilter.push(activeTypeFilters[activeKey]);
} }
/*
<FilterByPublished key={
// It is necessary to re-render `FilterByPublished` every time `FilterByBlockType`
// appears or disappears, this is because when the menu is opened it is rendered
// in a previous state, causing an inconsistency in its position.
// By changing the key we can re-render the component.
!(insideCollections || insideUnits) ? 'filter-published-1' : 'filter-published-2'
}
*/
// Disable filtering by block/problem type when viewing the Collections tab. // Disable filtering by block/problem type when viewing the Collections tab.
const overrideTypesFilter = (insideCollections || insideUnits) ? new TypesFilterData() : undefined; const overrideTypesFilter = (insideCollections || insideUnits) ? new TypesFilterData() : undefined;
@@ -256,7 +261,7 @@ const LibraryAuthoringPage = ({
[ContentType.units]: intl.formatMessage(messages.unitsTab), [ContentType.units]: intl.formatMessage(messages.unitsTab),
}; };
const visibleTabsToRender = visibleTabs.map((contentType) => ( const visibleTabsToRender = visibleTabs.map((contentType) => (
<Tab eventKey={contentType} title={tabTitles[contentType]} /> <Tab key={contentType} eventKey={contentType} title={tabTitles[contentType]} />
)); ));
return ( return (
@@ -299,7 +304,14 @@ const LibraryAuthoringPage = ({
<SearchKeywordsField className="mr-3" /> <SearchKeywordsField className="mr-3" />
<FilterByTags /> <FilterByTags />
{!(insideCollections || insideUnits) && <FilterByBlockType />} {!(insideCollections || insideUnits) && <FilterByBlockType />}
<FilterByPublished /> <LibraryFilterByPublished key={
// It is necessary to re-render `LibraryFilterByPublished` every time `FilterByBlockType`
// appears or disappears, this is because when the menu is opened it is rendered
// in a previous state, causing an inconsistency in its position.
// By changing the key we can re-render the component.
!(insideCollections || insideUnits) ? 'filter-published-1' : 'filter-published-2'
}
/>
<ClearFiltersButton /> <ClearFiltersButton />
<ActionRow.Spacer /> <ActionRow.Spacer />
<SearchSortWidget /> <SearchSortWidget />

View File

@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
@@ -16,6 +17,7 @@ interface LibraryBlockProps {
view?: string; view?: string;
scrolling?: string; scrolling?: string;
minHeight?: string; minHeight?: string;
scrollIntoView?: boolean;
} }
/** /**
* React component that displays an XBlock in a sandboxed IFrame. * React component that displays an XBlock in a sandboxed IFrame.
@@ -33,6 +35,7 @@ export const LibraryBlock = ({
view, view,
minHeight, minHeight,
scrolling = 'no', scrolling = 'no',
scrollIntoView = false,
}: LibraryBlockProps) => { }: LibraryBlockProps) => {
const { iframeRef, setIframeRef } = useIframe(); const { iframeRef, setIframeRef } = useIframe();
const xblockView = view ?? 'student_view'; const xblockView = view ?? 'student_view';
@@ -49,6 +52,13 @@ export const LibraryBlock = ({
onBlockNotification, onBlockNotification,
}); });
useEffect(() => {
/* istanbul ignore next */
if (scrollIntoView) {
iframeRef?.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [scrollIntoView]);
useIframeContent(iframeRef, setIframeRef); useIframeContent(iframeRef, setIframeRef);
return ( return (

View File

@@ -494,7 +494,7 @@
], ],
"created": 1742221203.895054, "created": 1742221203.895054,
"modified": 1742221203.895054, "modified": 1742221203.895054,
"usage_key": "lct:Axim:TEST:unit:test-unit-9284e2", "usage_key": "lct:org:lib:unit:test-unit-9a207",
"block_type": "unit", "block_type": "unit",
"context_key": "lib:Axim:TEST", "context_key": "lib:Axim:TEST",
"org": "Axim", "org": "Axim",
@@ -512,12 +512,18 @@
], ],
"created": "1742221203.895054", "created": "1742221203.895054",
"modified": "1742221203.895054", "modified": "1742221203.895054",
"usage_key": "lct:Axim:TEST:unit:test-unit-9284e2", "usage_key": "lct:org:lib:unit:test-unit-9a207",
"block_type": "unit", "block_type": "unit",
"context_key": "lib:Axim:TEST", "context_key": "lib:Axim:TEST",
"org": "Axim", "org": "Axim",
"access_id": "15", "access_id": "15",
"num_children": "0" "num_children": "0",
"published": {
"display_name": "Published Test Unit"
}
},
"published": {
"display_name": "Published Test Unit"
} }
} }
], ],

View File

@@ -272,7 +272,7 @@ describe('<AddContent />', () => {
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl)); await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1)); await waitFor(() => expect(axiosMock.history.patch.length).toEqual(1));
await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl)); await waitFor(() => expect(axiosMock.history.patch[0].url).toEqual(collectionComponentUrl));
expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this collection.'); expect(mockShowToast).toHaveBeenCalledWith('Failed to add content to collection.');
}); });
it('should stop user from pasting unsupported blocks and show toast', async () => { it('should stop user from pasting unsupported blocks and show toast', async () => {

View File

@@ -29,6 +29,8 @@ import { useLibraryContext } from '../common/context/LibraryContext';
import { PickLibraryContentModal } from './PickLibraryContentModal'; import { PickLibraryContentModal } from './PickLibraryContentModal';
import { blockTypes } from '../../editors/data/constants/app'; import { blockTypes } from '../../editors/data/constants/app';
import { ContentType as LibraryContentTypes } from '../routes';
import genericMessages from '../generic/messages';
import messages from './messages'; import messages from './messages';
import type { BlockTypeMetadata } from '../data/api'; import type { BlockTypeMetadata } from '../data/api';
import { getContainerTypeFromId, ContainerType } from '../../generic/key-utils'; import { getContainerTypeFromId, ContainerType } from '../../generic/key-utils';
@@ -114,6 +116,9 @@ const AddContentView = ({
blockType: 'libraryContent', blockType: 'libraryContent',
}; };
const extraFilter = unitId ? ['NOT block_type = "unit"', 'NOT type = "collections"'] : undefined;
const visibleTabs = unitId ? [LibraryContentTypes.components] : undefined;
return ( return (
<> <>
{(collectionId || unitId) && componentPicker && ( {(collectionId || unitId) && componentPicker && (
@@ -123,6 +128,8 @@ const AddContentView = ({
<PickLibraryContentModal <PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen} isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal} onClose={closeAddLibraryContentModal}
extraFilter={extraFilter}
visibleTabs={visibleTabs}
/> />
</> </>
)} )}
@@ -301,7 +308,7 @@ const AddContent = () => {
const linkComponent = (opaqueKey: string) => { const linkComponent = (opaqueKey: string) => {
if (collectionId) { if (collectionId) {
addComponentsToCollectionMutation.mutateAsync([opaqueKey]).catch(() => { addComponentsToCollectionMutation.mutateAsync([opaqueKey]).catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage)); showToast(intl.formatMessage(genericMessages.manageCollectionsFailed));
}); });
} }
if (unitId) { if (unitId) {

View File

@@ -92,7 +92,10 @@ describe('<PickLibraryContentModal />', () => {
} }
}); });
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith('Content linked successfully.'); const text = context === 'collection'
? 'Content added to collection.'
: 'Content linked successfully.';
expect(mockShowToast).toHaveBeenCalledWith(text);
}); });
it(`show error when api call fails (${context})`, async () => { it(`show error when api call fails (${context})`, async () => {
@@ -130,8 +133,10 @@ describe('<PickLibraryContentModal />', () => {
} }
}); });
expect(onClose).toHaveBeenCalled(); expect(onClose).toHaveBeenCalled();
const name = context === 'collection' ? 'collection' : 'container'; const text = context === 'collection'
expect(mockShowToast).toHaveBeenCalledWith(`There was an error linking the content to this ${name}.`); ? 'Failed to add content to collection.'
: 'There was an error linking the content to this container.';
expect(mockShowToast).toHaveBeenCalledWith(text);
}); });
}); });
}); });

View File

@@ -6,6 +6,8 @@ import { ToastContext } from '../../generic/toast-context';
import { useLibraryContext } from '../common/context/LibraryContext'; import { useLibraryContext } from '../common/context/LibraryContext';
import type { SelectedComponent } from '../common/context/ComponentPickerContext'; import type { SelectedComponent } from '../common/context/ComponentPickerContext';
import { useAddItemsToCollection, useAddComponentsToContainer } from '../data/apiHooks'; import { useAddItemsToCollection, useAddComponentsToContainer } from '../data/apiHooks';
import genericMessages from '../generic/messages';
import type { ContentType } from '../routes';
import messages from './messages'; import messages from './messages';
interface PickLibraryContentModalFooterProps { interface PickLibraryContentModalFooterProps {
@@ -32,12 +34,14 @@ interface PickLibraryContentModalProps {
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
extraFilter?: string[]; extraFilter?: string[];
visibleTabs?: ContentType[],
} }
export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = ({ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = ({
isOpen, isOpen,
onClose, onClose,
extraFilter, extraFilter,
visibleTabs,
}) => { }) => {
const intl = useIntl(); const intl = useIntl();
@@ -69,16 +73,16 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
if (collectionId) { if (collectionId) {
updateCollectionItemsMutation.mutateAsync(usageKeys) updateCollectionItemsMutation.mutateAsync(usageKeys)
.then(() => { .then(() => {
showToast(intl.formatMessage(messages.successAssociateComponentMessage)); showToast(intl.formatMessage(genericMessages.manageCollectionsSuccess));
}) })
.catch(() => { .catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage)); showToast(intl.formatMessage(genericMessages.manageCollectionsFailed));
}); });
} }
if (unitId) { if (unitId) {
updateUnitComponentsMutation.mutateAsync(usageKeys) updateUnitComponentsMutation.mutateAsync(usageKeys)
.then(() => { .then(() => {
showToast(intl.formatMessage(messages.successAssociateComponentMessage)); showToast(intl.formatMessage(messages.successAssociateComponentToContainerMessage));
}) })
.catch(() => { .catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage)); showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage));
@@ -109,6 +113,7 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
componentPickerMode="multiple" componentPickerMode="multiple"
onChangeComponentSelection={setSelectedComponents} onChangeComponentSelection={setSelectedComponents}
extraFilter={extraFilter} extraFilter={extraFilter}
visibleTabs={visibleTabs}
/> />
</StandardModal> </StandardModal>
); );

View File

@@ -84,15 +84,10 @@ const messages = defineMessages({
+ ' The {detail} text provides more information about the error.' + ' The {detail} text provides more information about the error.'
), ),
}, },
successAssociateComponentMessage: { successAssociateComponentToContainerMessage: {
id: 'course-authoring.library-authoring.associate-collection-content.success.text', id: 'course-authoring.library-authoring.associate-container-content.success.text',
defaultMessage: 'Content linked successfully.', defaultMessage: 'Content linked successfully.',
description: 'Message when linking of content to a collection in library is success', description: 'Message when linking of content to a container in library is success',
},
errorAssociateComponentToCollectionMessage: {
id: 'course-authoring.library-authoring.associate-collection-content.error.text',
defaultMessage: 'There was an error linking the content to this collection.',
description: 'Message when linking of content to a collection in library fails',
}, },
errorAssociateComponentToContainerMessage: { errorAssociateComponentToContainerMessage: {
id: 'course-authoring.library-authoring.associate-container-content.error.text', id: 'course-authoring.library-authoring.associate-container-content.error.text',

View File

@@ -48,7 +48,8 @@ const CollectionInfo = () => {
if (componentPickerMode) { if (componentPickerMode) {
setCollectionId(collectionId); setCollectionId(collectionId);
} else { } else {
navigateTo({ collectionId }); /* istanbul ignore next */
navigateTo({ collectionId, doubleClicked: true });
} }
}, [componentPickerMode, navigateTo]); }, [componentPickerMode, navigateTo]);

View File

@@ -26,14 +26,16 @@ const CollectionInfoHeader = () => {
const updateMutation = useUpdateCollection(libraryId, collectionId); const updateMutation = useUpdateCollection(libraryId, collectionId);
const { showToast } = useContext(ToastContext); const { showToast } = useContext(ToastContext);
const handleSaveTitle = (newTitle: string) => { const handleSaveTitle = async (newTitle: string) => {
updateMutation.mutateAsync({ try {
title: newTitle, await updateMutation.mutateAsync({
}).then(() => { title: newTitle,
});
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg)); showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
}).catch(() => { } catch (err) {
showToast(intl.formatMessage(messages.updateCollectionErrorMsg)); showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
}); throw err;
}
}; };
if (!collection) { if (!collection) {
@@ -46,7 +48,6 @@ const CollectionInfoHeader = () => {
text={collection.title} text={collection.title}
readOnly={readOnly} readOnly={readOnly}
textClassName="font-weight-bold m-1.5" textClassName="font-weight-bold m-1.5"
alwaysShowEditButton
/> />
); );
}; };

View File

@@ -315,7 +315,7 @@ describe('<LibraryCollectionPage />', () => {
fireEvent.change(searchBox, { target: { value: 'words to find' } }); fireEvent.change(searchBox, { target: { value: 'words to find' } });
// Default sort option changes to "Most Relevant" // Default sort option changes to "Most Relevant"
expect(screen.getAllByText('Most Relevant').length).toEqual(2); expect((await screen.findAllByText('Most Relevant')).length).toEqual(2);
await waitFor(() => { await waitFor(() => {
expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, { expect(fetchMock).toHaveBeenLastCalledWith(searchEndpoint, {
body: expect.stringContaining('"sort":[]'), body: expect.stringContaining('"sort":[]'),

View File

@@ -22,7 +22,6 @@ import NotFoundAlert from '../../generic/NotFoundAlert';
import { import {
ClearFiltersButton, ClearFiltersButton,
FilterByBlockType, FilterByBlockType,
FilterByPublished,
FilterByTags, FilterByTags,
SearchContextProvider, SearchContextProvider,
SearchKeywordsField, SearchKeywordsField,
@@ -36,6 +35,7 @@ import { SidebarBodyComponentId, useSidebarContext } from '../common/context/Sid
import messages from './messages'; import messages from './messages';
import { LibrarySidebar } from '../library-sidebar'; import { LibrarySidebar } from '../library-sidebar';
import LibraryCollectionComponents from './LibraryCollectionComponents'; import LibraryCollectionComponents from './LibraryCollectionComponents';
import LibraryFilterByPublished from '../generic/filter-by-published';
const HeaderActions = () => { const HeaderActions = () => {
const intl = useIntl(); const intl = useIntl();
@@ -218,7 +218,7 @@ const LibraryCollectionPage = () => {
<SearchKeywordsField className="mr-3" /> <SearchKeywordsField className="mr-3" />
<FilterByTags /> <FilterByTags />
<FilterByBlockType /> <FilterByBlockType />
<FilterByPublished /> <LibraryFilterByPublished />
<ClearFiltersButton /> <ClearFiltersButton />
<ActionRow.Spacer /> <ActionRow.Spacer />
<SearchSortWidget /> <SearchSortWidget />

View File

@@ -36,7 +36,7 @@ export const isComponentInfoTab = (tab: string): tab is ComponentInfoTab => (
export const UNIT_INFO_TABS = { export const UNIT_INFO_TABS = {
Preview: 'preview', Preview: 'preview',
Organize: 'organize', Manage: 'manage',
Usage: 'usage', Usage: 'usage',
Settings: 'settings', Settings: 'settings',
} as const; } as const;
@@ -63,7 +63,8 @@ export interface SidebarComponentInfo {
} }
export enum SidebarActions { export enum SidebarActions {
JumpToAddCollections = 'jump-to-add-collections', JumpToManageCollections = 'jump-to-manage-collections',
JumpToManageTags = 'jump-to-manage-tags',
ManageTeam = 'manage-team', ManageTeam = 'manage-team',
None = '', None = '',
} }

View File

@@ -8,7 +8,7 @@ import {
import { mockFetchIndexDocuments, mockContentSearchConfig } from '../../search-manager/data/api.mock'; import { mockFetchIndexDocuments, mockContentSearchConfig } from '../../search-manager/data/api.mock';
import { import {
mockContentLibrary, mockContentLibrary,
mockGetUnpaginatedEntityLinks, mockGetEntityLinks,
mockLibraryBlockMetadata, mockLibraryBlockMetadata,
mockXBlockAssets, mockXBlockAssets,
mockXBlockOLX, mockXBlockOLX,
@@ -21,7 +21,7 @@ mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock(); mockLibraryBlockMetadata.applyMock();
mockXBlockAssets.applyMock(); mockXBlockAssets.applyMock();
mockXBlockOLX.applyMock(); mockXBlockOLX.applyMock();
mockGetUnpaginatedEntityLinks.applyMock(); mockGetEntityLinks.applyMock();
mockFetchIndexDocuments.applyMock(); mockFetchIndexDocuments.applyMock();
const render = (usageKey: string) => baseRender(<ComponentDetails />, { const render = (usageKey: string) => baseRender(<ComponentDetails />, {

View File

@@ -7,7 +7,7 @@ import {
import { import {
mockContentLibrary, mockContentLibrary,
mockLibraryBlockMetadata, mockLibraryBlockMetadata,
mockGetUnpaginatedEntityLinks, mockGetEntityLinks,
} from '../data/api.mocks'; } from '../data/api.mocks';
import { mockContentSearchConfig, mockFetchIndexDocuments } from '../../search-manager/data/api.mock'; import { mockContentSearchConfig, mockFetchIndexDocuments } from '../../search-manager/data/api.mock';
import { LibraryProvider } from '../common/context/LibraryContext'; import { LibraryProvider } from '../common/context/LibraryContext';
@@ -18,7 +18,7 @@ import { getXBlockPublishApiUrl } from '../data/api';
mockContentSearchConfig.applyMock(); mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock(); mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock(); mockLibraryBlockMetadata.applyMock();
mockGetUnpaginatedEntityLinks.applyMock(); mockGetEntityLinks.applyMock();
mockFetchIndexDocuments.applyMock(); mockFetchIndexDocuments.applyMock();
jest.mock('./ComponentPreview', () => ({ jest.mock('./ComponentPreview', () => ({
__esModule: true, // Required when mocking 'default' export __esModule: true, // Required when mocking 'default' export

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React from 'react';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import { import {
Button, Button,
@@ -17,7 +17,6 @@ import { useLibraryContext } from '../common/context/LibraryContext';
import { import {
type ComponentInfoTab, type ComponentInfoTab,
COMPONENT_INFO_TABS, COMPONENT_INFO_TABS,
SidebarActions,
isComponentInfoTab, isComponentInfoTab,
useSidebarContext, useSidebarContext,
} from '../common/context/SidebarContext'; } from '../common/context/SidebarContext';
@@ -107,9 +106,9 @@ const ComponentInfo = () => {
sidebarTab, sidebarTab,
setSidebarTab, setSidebarTab,
sidebarComponentInfo, sidebarComponentInfo,
sidebarAction,
defaultTab, defaultTab,
hiddenTabs, hiddenTabs,
resetSidebarAction,
} = useSidebarContext(); } = useSidebarContext();
const [ const [
isPublishConfirmationOpen, isPublishConfirmationOpen,
@@ -117,20 +116,16 @@ const ComponentInfo = () => {
closePublishConfirmation, closePublishConfirmation,
] = useToggle(false); ] = useToggle(false);
const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections;
const tab: ComponentInfoTab = ( const tab: ComponentInfoTab = (
isComponentInfoTab(sidebarTab) isComponentInfoTab(sidebarTab)
? sidebarTab ? sidebarTab
: defaultTab.component : defaultTab.component
); );
useEffect(() => { const handleTabChange = (newTab: ComponentInfoTab) => {
// Show Manage tab if JumpToAddCollections action is set in sidebarComponentInfo resetSidebarAction();
if (jumpToCollections) { setSidebarTab(newTab);
setSidebarTab(COMPONENT_INFO_TABS.Manage); };
}
}, [jumpToCollections, setSidebarTab]);
const usageKey = sidebarComponentInfo?.id; const usageKey = sidebarComponentInfo?.id;
// istanbul ignore if: this should never happen // istanbul ignore if: this should never happen
@@ -198,7 +193,7 @@ const ComponentInfo = () => {
className="my-3 d-flex justify-content-around" className="my-3 d-flex justify-content-around"
defaultActiveKey={defaultTab.component} defaultActiveKey={defaultTab.component}
activeKey={tab} activeKey={tab}
onSelect={setSidebarTab} onSelect={handleTabChange}
> >
{renderTab(COMPONENT_INFO_TABS.Preview, <ComponentPreview />, intl.formatMessage(messages.previewTabTitle))} {renderTab(COMPONENT_INFO_TABS.Preview, <ComponentPreview />, intl.formatMessage(messages.previewTabTitle))}
{renderTab(COMPONENT_INFO_TABS.Manage, <ComponentManagement />, intl.formatMessage(messages.manageTabTitle))} {renderTab(COMPONENT_INFO_TABS.Manage, <ComponentManagement />, intl.formatMessage(messages.manageTabTitle))}

View File

@@ -26,16 +26,18 @@ const ComponentInfoHeader = () => {
const updateMutation = useUpdateXBlockFields(usageKey); const updateMutation = useUpdateXBlockFields(usageKey);
const { showToast } = useContext(ToastContext); const { showToast } = useContext(ToastContext);
const handleSaveDisplayName = (newDisplayName: string) => { const handleSaveDisplayName = async (newDisplayName: string) => {
updateMutation.mutateAsync({ try {
metadata: { await updateMutation.mutateAsync({
display_name: newDisplayName, metadata: {
}, display_name: newDisplayName,
}).then(() => { },
});
showToast(intl.formatMessage(messages.updateComponentSuccessMsg)); showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
}).catch(() => { } catch (err) {
showToast(intl.formatMessage(messages.updateComponentErrorMsg)); showToast(intl.formatMessage(messages.updateComponentErrorMsg));
}); throw err;
}
}; };
if (!xblockFields) { if (!xblockFields) {
@@ -48,7 +50,6 @@ const ComponentInfoHeader = () => {
text={xblockFields?.displayName} text={xblockFields?.displayName}
readOnly={readOnly} readOnly={readOnly}
textClassName="font-weight-bold m-1.5" textClassName="font-weight-bold m-1.5"
alwaysShowEditButton
/> />
); );
}; };

View File

@@ -8,7 +8,7 @@ import {
waitFor, waitFor,
} from '../../testUtils'; } from '../../testUtils';
import { LibraryProvider } from '../common/context/LibraryContext'; import { LibraryProvider } from '../common/context/LibraryContext';
import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext'; import { SidebarActions, SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext';
import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks'; import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks';
import ComponentManagement from './ComponentManagement'; import ComponentManagement from './ComponentManagement';
@@ -19,6 +19,16 @@ jest.mock('../../content-tags-drawer', () => ({
), ),
})); }));
const mockSearchParam = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useSearchParams: () => [
{ getAll: (paramName: string) => mockSearchParam(paramName) },
() => {},
],
}));
mockContentLibrary.applyMock(); mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock(); mockLibraryBlockMetadata.applyMock();
mockContentTaxonomyTagsData.applyMock(); mockContentTaxonomyTagsData.applyMock();
@@ -55,6 +65,11 @@ const render = (usageKey: string, libraryId?: string) => baseRender(<ComponentMa
describe('<ComponentManagement />', () => { describe('<ComponentManagement />', () => {
beforeEach(() => { beforeEach(() => {
initializeMocks(); initializeMocks();
mockSearchParam.mockResolvedValue([undefined, () => {}]);
});
afterEach(() => {
jest.clearAllMocks();
}); });
it('should render draft status', async () => { it('should render draft status', async () => {
@@ -119,4 +134,34 @@ describe('<ComponentManagement />', () => {
render(mockLibraryBlockMetadata.usageKeyWithCollections); render(mockLibraryBlockMetadata.usageKeyWithCollections);
expect(await screen.findByText('Collections (1)')).toBeInTheDocument(); expect(await screen.findByText('Collections (1)')).toBeInTheDocument();
}); });
it('should open collection section when sidebarAction = JumpToManageCollections', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
mockSearchParam.mockReturnValue([SidebarActions.JumpToManageCollections]);
render(mockLibraryBlockMetadata.usageKeyWithCollections);
expect(await screen.findByText('Collections (1)')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Manage tags' })).not.toBeInTheDocument();
const tagsSection = await screen.findByRole('button', { name: 'Tags (0)' });
expect(tagsSection).toHaveAttribute('aria-expanded', 'false');
const collectionsSection = await screen.findByRole('button', { name: 'Collections (1)' });
expect(collectionsSection).toHaveAttribute('aria-expanded', 'true');
});
it('should open tags section when sidebarAction = JumpToManageTags', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
mockSearchParam.mockReturnValue([SidebarActions.JumpToManageTags]);
render(mockLibraryBlockMetadata.usageKeyForTags);
expect(await screen.findByText('Collections (0)')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Manage tags' })).not.toBeInTheDocument();
const tagsSection = await screen.findByRole('button', { name: 'Tags (6)' });
expect(tagsSection).toHaveAttribute('aria-expanded', 'true');
const collectionsSection = await screen.findByRole('button', { name: 'Collections (0)' });
expect(collectionsSection).toHaveAttribute('aria-expanded', 'false');
});
}); });

View File

@@ -18,7 +18,8 @@ const ComponentManagement = () => {
const intl = useIntl(); const intl = useIntl();
const { readOnly, isLoadingLibraryData } = useLibraryContext(); const { readOnly, isLoadingLibraryData } = useLibraryContext();
const { sidebarComponentInfo, sidebarAction, resetSidebarAction } = useSidebarContext(); const { sidebarComponentInfo, sidebarAction, resetSidebarAction } = useSidebarContext();
const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections; const jumpToCollections = sidebarAction === SidebarActions.JumpToManageCollections;
const jumpToTags = sidebarAction === SidebarActions.JumpToManageTags;
const [tagsCollapseIsOpen, setTagsCollapseOpen] = React.useState(!jumpToCollections); const [tagsCollapseIsOpen, setTagsCollapseOpen] = React.useState(!jumpToCollections);
const [collectionsCollapseIsOpen, setCollectionsCollapseOpen] = React.useState(true); const [collectionsCollapseIsOpen, setCollectionsCollapseOpen] = React.useState(true);
@@ -26,8 +27,11 @@ const ComponentManagement = () => {
if (jumpToCollections) { if (jumpToCollections) {
setTagsCollapseOpen(false); setTagsCollapseOpen(false);
setCollectionsCollapseOpen(true); setCollectionsCollapseOpen(true);
} else if (jumpToTags) {
setTagsCollapseOpen(true);
setCollectionsCollapseOpen(false);
} }
}, [jumpToCollections, tagsCollapseIsOpen, collectionsCollapseIsOpen]); }, [jumpToCollections, jumpToTags]);
useEffect(() => { useEffect(() => {
// This is required to redo actions. // This is required to redo actions.

View File

@@ -2,7 +2,7 @@ import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Collapsible, Hyperlink, Stack } from '@openedx/paragon'; import { Collapsible, Hyperlink, Stack } from '@openedx/paragon';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useUnpaginatedEntityLinks } from '../../course-libraries/data/apiHooks'; import { useEntityLinks } from '../../course-libraries/data/apiHooks';
import AlertError from '../../generic/alert-error'; import AlertError from '../../generic/alert-error';
import Loading from '../../generic/Loading'; import Loading from '../../generic/Loading';
@@ -34,7 +34,7 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => {
isError: isErrorDownstreamLinks, isError: isErrorDownstreamLinks,
error: errorDownstreamLinks, error: errorDownstreamLinks,
isLoading: isLoadingDownstreamLinks, isLoading: isLoadingDownstreamLinks,
} = useUnpaginatedEntityLinks({ upstreamUsageKey: usageKey }); } = useEntityLinks({ upstreamUsageKey: usageKey });
const downstreamKeys = useMemo( const downstreamKeys = useMemo(
() => dataDownstreamLinks?.map(link => link.downstreamUsageKey) || [], () => dataDownstreamLinks?.map(link => link.downstreamUsageKey) || [],

Some files were not shown because too many files have changed in this diff Show More