Compare commits

..

1 Commits

Author SHA1 Message Date
XnpioChV
49c28de286 feat: Base page for Migrate Legacy Libraries 2025-09-02 14:19:37 -05:00
389 changed files with 5992 additions and 25307 deletions

1
.env
View File

@@ -48,4 +48,3 @@ LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'

View File

@@ -51,4 +51,3 @@ LIBRARY_UNSUPPORTED_BLOCKS="conditional,step-builder,problem-builder"
# Fallback in local style files
PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL=''
ADMIN_CONSOLE_URL='http://localhost:2025/admin-console'

View File

@@ -36,6 +36,7 @@ ENABLE_CERTIFICATE_PAGE=true
ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries

View File

@@ -1,15 +0,0 @@
name: Trigger to add Issue or PR to a Core Contributor project board
on:
issues:
types: [labeled]
pull_request:
types: [labeled]
jobs:
add-to-cc-board:
if: github.event.label.name == 'Core Contributor assignee'
uses: openedx/.github/.github/workflows/add-to-cc-board.yml@master
with:
board_name: cc-frontend-apps
secrets:
projects_access_token: ${{ secrets.PROJECTS_TOKEN }}

View File

@@ -12,12 +12,12 @@ jobs:
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v6
- uses: actions/setup-node@v4
with:
node-version-file: '.nvmrc'
- run: make validate.ci
- name: Archive code coverage results
uses: actions/upload-artifact@v5
uses: actions/upload-artifact@v4
with:
name: code-coverage-report
path: coverage/*.*
@@ -27,11 +27,9 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Download code coverage results
uses: actions/download-artifact@v6
uses: actions/download-artifact@v5
with:
pattern: code-coverage-report
path: coverage
merge-multiple: true
name: code-coverage-report
- name: Upload coverage
uses: codecov/codecov-action@v5
with:

2
.nvmrc
View File

@@ -1 +1 @@
24
20

View File

@@ -11,5 +11,4 @@ coverage:
ignore:
- "src/grading-settings/grading-scale/react-ranger.js"
- "src/generic/DraggableList/verticalSortableList.ts"
- "src/container-comparison/data/api.mock.ts"
- "src/index.js"

9604
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -45,7 +45,7 @@
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.5.0",
"@edx/frontend-component-footer": "^14.9.0",
"@edx/frontend-component-header": "^8.1.0",
"@edx/frontend-component-header": "^6.2.0",
"@edx/frontend-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.7.0",
@@ -59,17 +59,17 @@
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
"@openedx/frontend-build": "^14.6.2",
"@openedx/frontend-build": "^14.5.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.5.0",
"@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "5.90.5",
"@tinymce/tinymce-react": "^6.0.0",
"@tanstack/react-query": "4.40.1",
"@tinymce/tinymce-react": "^3.14.0",
"classnames": "2.5.1",
"codemirror": "^6.0.0",
"email-validator": "2.0.4",
"fast-xml-parser": "^5.0.0",
"fast-xml-parser": "^4.0.10",
"file-saver": "^2.0.5",
"formik": "2.4.6",
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
@@ -97,7 +97,7 @@
"redux-thunk": "^2.4.1",
"reselect": "^4.1.5",
"tinymce": "^5.10.4",
"universal-cookie": "^8.0.0",
"universal-cookie": "^4.0.4",
"uuid": "^11.1.0",
"xmlchecker": "^0.1.0",
"yup": "0.32.11"

View File

@@ -125,13 +125,10 @@ describe('ORASettings', () => {
});
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
await mockStore({ apiStatus: 200, enabled: true });
renderComponent();
await mockStore({ apiStatus: 200, enabled: true });
const checkbox = await screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
expect(checkbox).toBeChecked();
await waitFor(() => {
waitFor(() => {
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
const enableBadge = screen.getByTestId('enable-badge');

View File

@@ -1,6 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
'authoring.proctoring.alert.error': {
id: 'authoring.proctoring.alert.error',
defaultMessage: 'We encountered a technical error while trying to save proctored exam settings. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {support_link} for help.',
description: 'Alert message for proctoring settings save error.',
},
'authoring.proctoring.alert.forbidden': {
id: 'authoring.proctoring.alert.forbidden',
defaultMessage: 'You do not have permission to edit proctored exam settings for this course. If you are a course team member and this problem persists, please go to the {support_link} for help.',

View File

@@ -1,16 +0,0 @@
export default {
content: {
id: 67,
userId: 3,
created: '2024-01-16T13:09:11.540615Z',
purpose: 'clipboard',
status: 'ready',
blockType: 'chapter',
blockTypeDisplay: 'Section',
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
displayName: 'Chapter 1',
},
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
sourceContextTitle: 'Demonstration Course',
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@chapter+block@chapter_0270f6de40fc',
};

View File

@@ -1,4 +1,3 @@
export { default as clipboardUnit } from './clipboardUnit';
export { default as clipboardSubsection } from './clipboardSubsection';
export { default as clipboardXBlock } from './clipboardXBlock';
export { default as clipboardSection } from './clipboardSection';

View File

@@ -30,7 +30,7 @@ const SettingCard = ({
const { deprecated, help, displayName } = settingData;
const initialValue = JSON.stringify(settingData.value, null, 4);
const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState<HTMLButtonElement | null>(null);
const [target, setTarget] = useState(null);
const [newValue, setNewValue] = useState(initialValue);
const handleSettingChange = (e) => {
@@ -118,7 +118,7 @@ SettingCard.propTypes = {
deprecated: PropTypes.bool,
help: PropTypes.string,
displayName: PropTypes.string,
value: PropTypes.oneOfType([
value: PropTypes.PropTypes.oneOfType([
PropTypes.string,
PropTypes.bool,
PropTypes.number,

View File

@@ -1,3 +1,4 @@
import React from 'react';
import { fireEvent, render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -21,6 +22,7 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
<textarea
{...props}
onFocus={() => {}}
onBlur={() => {}}
/>
)));
@@ -84,10 +86,10 @@ describe('<SettingCard />', () => {
await waitFor(() => {
expect(inputBox).toHaveValue('3, 2, 1');
});
await user.tab(); // blur off of the input.
await waitFor(() => {
await (async () => {
expect(setEdited).toHaveBeenCalled();
expect(handleBlur).toHaveBeenCalled();
});
fireEvent.focusOut(inputBox);
});
});

View File

@@ -1,16 +0,0 @@
export const CONTENT_LIBRARY_PERMISSIONS = {
DELETE_LIBRARY: 'content_libraries.delete_library',
MANAGE_LIBRARY_TAGS: 'content_libraries.manage_library_tags',
VIEW_LIBRARY: 'content_libraries.view_library',
EDIT_LIBRARY_CONTENT: 'content_libraries.edit_library_content',
PUBLISH_LIBRARY_CONTENT: 'content_libraries.publish_library_content',
REUSE_LIBRARY_CONTENT: 'content_libraries.reuse_library_content',
CREATE_LIBRARY_COLLECTION: 'content_libraries.create_library_collection',
EDIT_LIBRARY_COLLECTION: 'content_libraries.edit_library_collection',
DELETE_LIBRARY_COLLECTION: 'content_libraries.delete_library_collection',
MANAGE_LIBRARY_TEAM: 'content_libraries.manage_library_team',
VIEW_LIBRARY_TEAM: 'content_libraries.view_library_team',
};

View File

@@ -1,41 +0,0 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import {
PermissionValidationAnswer,
PermissionValidationQuery,
PermissionValidationRequestItem,
PermissionValidationResponseItem,
} from '@src/authz/types';
import { getApiUrl } from './utils';
export const validateUserPermissions = async (
query: PermissionValidationQuery,
): Promise<PermissionValidationAnswer> => {
// Convert the validations query object into an array for the API request
const request: PermissionValidationRequestItem[] = Object.values(query);
const { data }: { data: PermissionValidationResponseItem[] } = await getAuthenticatedHttpClient().post(
getApiUrl('/api/authz/v1/permissions/validate/me'),
request,
);
// Convert the API response back into the expected answer format
const result: PermissionValidationAnswer = {};
data.forEach((item: { action: string; scope?: string; allowed: boolean }) => {
const key = Object.keys(query).find(
(k) => query[k].action === item.action
&& query[k].scope === item.scope,
);
if (key) {
result[key] = item.allowed;
}
});
// Fill any missing keys with false
Object.keys(query).forEach((key) => {
if (!(key in result)) {
result[key] = false;
}
});
return result;
};

View File

@@ -1,168 +0,0 @@
import { act, ReactNode } from 'react';
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { useUserPermissions } from './apiHooks';
jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return wrapper;
};
const singlePermission = {
canRead: {
action: 'example.read',
scope: 'lib:example-org:test-lib',
},
};
const mockValidSinglePermission = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
];
const mockInvalidSinglePermission = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
];
const mockEmptyPermissions = [
// No permissions returned
];
const multiplePermissions = {
canRead: {
action: 'example.read',
scope: 'lib:example-org:test-lib',
},
canWrite: {
action: 'example.write',
scope: 'lib:example-org:test-lib',
},
};
const mockValidMultiplePermissions = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: true },
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: true },
];
const mockInvalidMultiplePermissions = [
{ action: 'example.read', scope: 'lib:example-org:test-lib', allowed: false },
{ action: 'example.write', scope: 'lib:example-org:test-lib', allowed: false },
];
describe('useUserPermissions', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns allowed true when permission is valid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValueOnce({ data: mockValidSinglePermission }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(true);
});
it('returns allowed false when permission is invalid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockInvalidSinglePermission }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
});
it('returns allowed true when multiple permissions are valid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValueOnce({ data: mockValidMultiplePermissions }),
});
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(true);
expect(result.current.data!.canWrite).toBe(true);
});
it('returns allowed false when multiple permissions are invalid', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockInvalidMultiplePermissions }),
});
const { result } = renderHook(() => useUserPermissions(multiplePermissions), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
expect(result.current.data!.canWrite).toBe(false);
});
it('returns allowed false when the permission is not included in the server response', async () => {
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockResolvedValue({ data: mockEmptyPermissions }),
});
const { result } = renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
await waitFor(() => expect(result.current).toBeDefined());
await waitFor(() => expect(result.current.data).toBeDefined());
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data!.canRead).toBe(false);
});
it('handles error when the API call fails', async () => {
const mockError = new Error('API Error');
getAuthenticatedHttpClient.mockReturnValue({
post: jest.fn().mockRejectedValue(new Error('API Error')),
});
try {
act(() => {
renderHook(() => useUserPermissions(singlePermission), {
wrapper: createWrapper(),
});
});
} catch (error) {
expect(error).toEqual(mockError); // Check for the expected error
}
});
});

View File

@@ -1,36 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
import { validateUserPermissions } from './api';
const adminConsoleQueryKeys = {
all: ['authz'],
permissions: (permissions: PermissionValidationQuery) => [...adminConsoleQueryKeys.all, 'validatePermissions', permissions] as const,
};
/**
* React Query hook to validate if the current user has permissions over a certain object in the instance.
* It helps to:
* - Determine whether the current user can access certain object.
* - Provide role-based rendering logic for UI components.
*
* @param permissions - A key/value map of objects and actions to validate.
* The key is an arbitrary string to identify the permission check,
* and the value is an object containing the action and optional scope.
*
* @example
* const { isLoading, data } = useUserPermissions({
* canRead: {
* action: "content_libraries.view_library",
* scope: "lib:OpenedX:CSPROB"
* }
* });
* if (data.canRead) { ... }
*
*/
export const useUserPermissions = (
permissions: PermissionValidationQuery,
) => useQuery<PermissionValidationAnswer, Error>({
queryKey: adminConsoleQueryKeys.permissions(permissions),
queryFn: () => validateUserPermissions(permissions),
retry: false,
});

View File

@@ -1,4 +0,0 @@
import { getConfig } from '@edx/frontend-platform';
export const getApiUrl = (path: string) => `${getConfig().LMS_BASE_URL}${path || ''}`;
export const getStudioApiUrl = (path: string) => `${getConfig().STUDIO_BASE_URL}${path || ''}`;

View File

@@ -1,16 +0,0 @@
export interface PermissionValidationRequestItem {
action: string;
scope?: string;
}
export interface PermissionValidationResponseItem extends PermissionValidationRequestItem {
allowed: boolean;
}
export interface PermissionValidationQuery {
[permissionKey: string]: PermissionValidationRequestItem;
}
export interface PermissionValidationAnswer {
[permissionKey: string]: boolean;
}

View File

@@ -65,7 +65,7 @@ describe('CertificateDetails', () => {
await user.type(input, newInputValue);
await waitFor(() => {
waitFor(() => {
expect(input.value).toBe(newInputValue);
});
});

View File

@@ -96,7 +96,7 @@ describe('CertificateSignatories', () => {
expect(useCreateSignatory().handleAddSignatory).toHaveBeenCalled();
});
it.skip('calls remove for the correct signatory when delete icon is clicked', async () => {
it('calls remove for the correct signatory when delete icon is clicked', async () => {
const user = userEvent.setup();
const { getAllByRole } = renderComponent(defaultProps);
@@ -105,9 +105,7 @@ describe('CertificateSignatories', () => {
await user.click(deleteIcons[0]);
// FIXME: this isn't called because the whole 'useEditSignatory' hook
// which calls it is mocked out.
await waitFor(() => {
waitFor(() => {
expect(mockArrayHelpers.remove).toHaveBeenCalledWith(0);
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, waitFor } from '@testing-library/react';
import { Provider } from 'react-redux';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -30,7 +30,6 @@ const initialState = {
};
const defaultProps = {
index: 0,
...signatoriesMock[0],
showDeleteButton: true,
isEdit: true,
@@ -63,36 +62,31 @@ describe('Signatory Component', () => {
it('handles input change', async () => {
const user = userEvent.setup();
const handleChange = jest.fn();
renderSignatory({ ...defaultProps, handleChange });
const input = screen.getByPlaceholderText(messages.namePlaceholder.defaultMessage);
const { getByPlaceholderText } = renderSignatory({ ...defaultProps, handleChange });
const input = getByPlaceholderText(messages.namePlaceholder.defaultMessage);
const newInputValue = 'Jane Doe';
expect(handleChange).not.toHaveBeenCalled();
expect(input.value).not.toBe(newInputValue);
await user.type(input, newInputValue);
await user.type(input, newInputValue, { name: 'signatories[0].name' });
await waitFor(() => {
// This is not a great test; handleChange() gets called for each key press:
expect(handleChange).toHaveBeenCalledTimes(newInputValue.length);
// And the input value never actually changes because it's a controlled component
// and we pass the name in as a prop, which hasn't changed.
// expect(input.value).toBe(newInputValue);
waitFor(() => {
expect(handleChange).toHaveBeenCalledWith(expect.anything());
expect(input.value).toBe(newInputValue);
});
});
it('opens image upload modal on button click', async () => {
const user = userEvent.setup();
const { getByRole, queryByTestId } = renderSignatory(defaultProps);
const { getByRole, queryByRole } = renderSignatory(defaultProps);
const replaceButton = getByRole(
'button',
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
);
expect(queryByTestId('dropzone-container')).not.toBeInTheDocument();
expect(queryByRole('presentation')).not.toBeInTheDocument();
await user.click(replaceButton);
expect(queryByTestId('dropzone-container')).toBeInTheDocument();
expect(getByRole('presentation')).toBeInTheDocument();
});
it('shows confirm modal on delete icon click', async () => {

View File

@@ -62,7 +62,7 @@ describe('HeaderButtons Component', () => {
const dropdownButton = getByRole('button', { name: certificatesDataMock.courseModes[0] });
await user.click(dropdownButton);
const verifiedMode = getByRole('button', { name: certificatesDataMock.courseModes[1] });
const verifiedMode = await getByRole('button', { name: certificatesDataMock.courseModes[1] });
await user.click(verifiedMode);
await waitFor(() => {

View File

@@ -7,7 +7,6 @@ export const STATEFUL_BUTTON_STATES = {
default: 'default',
pending: 'pending',
error: 'error',
disable: 'disable',
};
export const USER_ROLES = {
@@ -62,8 +61,6 @@ export const COURSE_BLOCK_NAMES = ({
libraryContent: { id: 'library_content', name: 'Library content' },
splitTest: { id: 'split_test', name: 'Split Test' },
component: { id: 'component', name: 'Component' },
itembank: { id: 'itembank', name: 'Problem Bank' },
legacyLibraryContent: { id: 'library_content', name: 'Randomized Content Block' },
});
export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel';
@@ -110,9 +107,3 @@ export const iframeMessageTypes = {
xblockEvent: 'xblock-event',
xblockScroll: 'xblock-scroll',
};
export const BROKEN = 'broken';
export const LOCKED = 'locked';
export const MANUAL = 'manual';

View File

@@ -1,26 +0,0 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import { Stack } from '@openedx/paragon';
import messages from './messages';
interface Props {
title: React.ReactNode;
children: React.ReactNode;
side: 'Before' | 'After';
}
const ChildrenPreview = ({ title, children, side }: Props) => {
const intl = useIntl();
const sideTitle = side === 'Before'
? intl.formatMessage(messages.diffBeforeTitle)
: intl.formatMessage(messages.diffAfterTitle);
return (
<Stack direction="vertical">
<span className="text-center">{sideTitle}</span>
<span className="mt-2 mb-3 text-md text-gray-800">{title}</span>
{children}
</Stack>
);
};
export default ChildrenPreview;

View File

@@ -1,162 +0,0 @@
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types';
import { getLibraryContainerApiUrl } from '@src/library-authoring/data/api';
import { mockGetContainerChildren, mockGetContainerMetadata } from '@src/library-authoring/data/api.mocks';
import { initializeMocks, render, screen } from '@src/testUtils';
import { CompareContainersWidget } from './CompareContainersWidget';
import { mockGetCourseContainerChildren } from './data/api.mock';
mockGetCourseContainerChildren.applyMock();
mockGetContainerChildren.applyMock();
let axiosMock: MockAdapter;
describe('CompareContainersWidget', () => {
beforeEach(() => {
({ axiosMock } = initializeMocks());
});
test('renders the component with a title', async () => {
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId);
axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' });
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId={mockGetCourseContainerChildren.sectionId}
/>);
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
expect((await screen.findAllByText('subsection block 0')).length).toEqual(1);
expect((await screen.findAllByText('subsection block 00')).length).toEqual(1);
expect((await screen.findAllByText('This subsection will be modified')).length).toEqual(3);
expect((await screen.findAllByText('This subsection was modified')).length).toEqual(3);
expect((await screen.findAllByText('subsection block 1')).length).toEqual(1);
expect((await screen.findAllByText('subsection block 2')).length).toEqual(1);
expect((await screen.findAllByText('subsection block 11')).length).toEqual(1);
expect((await screen.findAllByText('subsection block 22')).length).toEqual(1);
expect(screen.queryByText(
/the only change is to text block which has been edited in this course\. accepting will not remove local edits\./i,
)).not.toBeInTheDocument();
});
test('renders loading spinner when data is pending', async () => {
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionIdLoading);
axiosMock.onGet(url).reply(() => new Promise(() => {}));
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionIdLoading}
downstreamBlockId={mockGetCourseContainerChildren.sectionIdLoading}
/>);
const spinner = await screen.findAllByRole('status');
expect(spinner.length).toEqual(4);
expect(spinner[0].textContent).toEqual('Loading...');
expect(spinner[1].textContent).toEqual('Loading...');
expect(spinner[2].textContent).toEqual('Loading...');
expect(spinner[3].textContent).toEqual('Loading...');
});
test('calls onRowClick when a row is clicked and updates diff view', async () => {
// mocks title
axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { publishedDisplayName: 'Test Title' });
axiosMock.onGet(
getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'),
).reply(200, { publishedDisplayName: 'subsection block 0' });
const user = userEvent.setup();
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId={mockGetCourseContainerChildren.sectionId}
/>);
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
// left i.e. before side block
let block = await screen.findByText('subsection block 00');
await user.click(block);
// Breadcrumbs - shows old and new name
expect(await screen.findByRole('button', { name: 'subsection block 00' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'subsection block 0' })).toBeInTheDocument();
// Back breadcrumb
const backbtns = await screen.findAllByRole('button', { name: 'Back' });
expect(backbtns.length).toEqual(2);
// Go back
await user.click(backbtns[0]);
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
// right i.e. after side block
block = await screen.findByText('subsection block 0');
// After side click also works
await user.click(block);
expect(await screen.findByRole('button', { name: 'subsection block 00' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'subsection block 0' })).toBeInTheDocument();
});
test('should show removed container diff state', async () => {
// mocks title
axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { publishedDisplayName: 'Test Title' });
axiosMock.onGet(
getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'),
).reply(200, { publishedDisplayName: 'subsection block 0' });
const user = userEvent.setup();
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId={mockGetCourseContainerChildren.sectionId}
/>);
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
// left i.e. before side block
const block = await screen.findByText('subsection block 00');
await user.click(block);
const removedRows = await screen.findAllByText('This unit was removed');
await user.click(removedRows[0]);
expect(await screen.findByText('This unit has been removed')).toBeInTheDocument();
});
test('should show new added container diff state', async () => {
// mocks title
axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { publishedDisplayName: 'Test Title' });
axiosMock.onGet(
getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'),
).reply(200, { publishedDisplayName: 'subsection block 0' });
const user = userEvent.setup();
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId="block-v1:UNIX+UX1+2025_T3+type@section+block@0-new"
/>);
const blocks = await screen.findAllByText('This subsection will be added in the new version');
await user.click(blocks[0]);
expect(await screen.findByText(/this subsection is new/i)).toBeInTheDocument();
});
test('should show alert if the only change is a single text component with local overrides', async () => {
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId);
axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' });
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId={mockGetCourseContainerChildren.sectionShowsAlertSingleText}
/>);
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
expect(screen.getByText(
/the only change is to text block which has been edited in this course\. accepting will not remove local edits\./i,
)).toBeInTheDocument();
expect(screen.getByText(/Html block 11/i)).toBeInTheDocument();
});
test('should show alert if the only changes is multiple text components with local overrides', async () => {
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId);
axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' });
render(<CompareContainersWidget
upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId={mockGetCourseContainerChildren.sectionShowsAlertMultipleText}
/>);
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
expect(screen.getByText(
/the only change is to which have been edited in this course\. accepting will not remove local edits\./i,
)).toBeInTheDocument();
expect(screen.getByText(/2 text blocks/i)).toBeInTheDocument();
});
});

View File

@@ -1,300 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import {
Alert,
Breadcrumb, Button, Card, Icon, Stack,
} from '@openedx/paragon';
import { ArrowBack, Add, Delete } from '@openedx/paragon/icons';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { ContainerType, getBlockType } from '@src/generic/key-utils';
import ErrorAlert from '@src/generic/alert-error';
import { LoadingSpinner } from '@src/generic/Loading';
import { useContainer, useContainerChildren } from '@src/library-authoring/data/apiHooks';
import { BoldText } from '@src/utils';
import { Container, LibraryBlockMetadata } from '@src/library-authoring/data/api';
import ChildrenPreview from './ChildrenPreview';
import ContainerRow from './ContainerRow';
import { useCourseContainerChildren } from './data/apiHooks';
import {
ContainerChild, ContainerChildBase, ContainerState, WithState,
} from './types';
import { diffPreviewContainerChildren, isRowClickable } from './utils';
import messages from './messages';
interface ContainerInfoProps {
upstreamBlockId: string;
downstreamBlockId: string;
isReadyToSyncIndividually?: boolean;
}
interface Props extends ContainerInfoProps {
parent: ContainerInfoProps[];
onRowClick: (row: WithState<ContainerChild>) => void;
onBackBtnClick: () => void;
state?: ContainerState;
// This two props are used to show an alert for the changes to text components with local overrides.
// They may be removed in the future.
localUpdateAlertCount: number;
localUpdateAlertBlockName: string;
}
/**
* Actual implementation of the displaying diff between children of containers.
*/
const CompareContainersWidgetInner = ({
upstreamBlockId,
downstreamBlockId,
parent,
state,
onRowClick,
onBackBtnClick,
localUpdateAlertCount,
localUpdateAlertBlockName,
}: Props) => {
const intl = useIntl();
const { data, isError, error } = useCourseContainerChildren(downstreamBlockId, parent.length === 0);
// There is the case in which the item is removed, but it still exists
// in the library, for that case, we avoid bringing the children.
const {
data: libData,
isError: isLibError,
error: libError,
} = useContainerChildren<Container | LibraryBlockMetadata>(state === 'removed' ? undefined : upstreamBlockId, true);
const {
data: containerData,
isError: isContainerTitleError,
error: containerTitleError,
} = useContainer(upstreamBlockId);
const result = useMemo(() => {
if ((!data || !libData) && !['added', 'removed'].includes(state || '')) {
return [undefined, undefined];
}
return diffPreviewContainerChildren(data?.children || [], libData as ContainerChildBase[] || []);
}, [data, libData]);
const renderBeforeChildren = useCallback(() => {
if (!result[0] && state !== 'added') {
return <div className="m-auto"><LoadingSpinner /></div>;
}
if (state === 'added') {
return (
<Stack className="align-items-center justify-content-center bg-light-200 text-gray-800">
<Icon src={Add} className="big-icon" />
<FormattedMessage
{...messages.newContainer}
values={{
containerType: getBlockType(upstreamBlockId),
}}
/>
</Stack>
);
}
return result[0]?.map((child) => (
<ContainerRow
key={child.id}
title={child.name}
containerType={child.blockType}
state={child.state}
originalName={child.originalName}
side="Before"
onClick={() => onRowClick(child)}
/>
));
}, [result]);
const renderAfterChildren = useCallback(() => {
if (!result[1] && state !== 'removed') {
return <div className="m-auto"><LoadingSpinner /></div>;
}
if (state === 'removed') {
return (
<Stack className="align-items-center justify-content-center bg-light-200 text-gray-800">
<Icon src={Delete} className="big-icon" />
<FormattedMessage
{...messages.deletedContainer}
values={{
containerType: getBlockType(upstreamBlockId),
}}
/>
</Stack>
);
}
return result[1]?.map((child) => (
<ContainerRow
key={child.id}
title={child.name}
containerType={child.blockType}
state={child.state}
side="After"
onClick={() => onRowClick(child)}
/>
));
}, [result]);
const getTitleComponent = useCallback((title?: string | null) => {
if (!title) {
return <div className="m-auto"><LoadingSpinner /></div>;
}
if (parent.length === 0) {
return title;
}
return (
<Breadcrumb
ariaLabel={intl.formatMessage(messages.breadcrumbAriaLabel)}
links={[
{
// This raises failed prop-type error as label expects a string but it works without any issues
label: <Stack direction="horizontal" gap={1}><Icon size="xs" src={ArrowBack} />Back</Stack>,
onClick: onBackBtnClick,
variant: 'link',
className: 'px-0 text-gray-900',
},
{
label: title,
variant: 'link',
className: 'px-0 text-gray-900',
disabled: true,
},
]}
linkAs={Button}
/>
);
}, [parent]);
let beforeTitle: string | undefined | null = data?.displayName;
let afterTitle = containerData?.publishedDisplayName;
if (!data && state === 'added') {
beforeTitle = containerData?.publishedDisplayName;
}
if (!containerData && state === 'removed') {
afterTitle = data?.displayName;
}
if (isError || (isLibError && state !== 'removed') || (isContainerTitleError && state !== 'removed')) {
return <ErrorAlert error={error || libError || containerTitleError} />;
}
return (
<div className="compare-changes-widget row justify-content-center">
{localUpdateAlertCount > 0 && (
<Alert variant="info">
<FormattedMessage
{...messages.localChangeInTextAlert}
values={{
blockName: localUpdateAlertBlockName,
count: localUpdateAlertCount,
b: BoldText,
}}
/>
</Alert>
)}
<div className="col col-6 p-1">
<Card className="compare-card p-4">
<ChildrenPreview title={getTitleComponent(beforeTitle)} side="Before">
{renderBeforeChildren()}
</ChildrenPreview>
</Card>
</div>
<div className="col col-6 p-1">
<Card className="compare-card p-4">
<ChildrenPreview title={getTitleComponent(afterTitle)} side="After">
{renderAfterChildren()}
</ChildrenPreview>
</Card>
</div>
</div>
);
};
/**
* CompareContainersWidget component. Displays a diff of set of child containers from two different sources
* and allows the user to select the container to view. This is a wrapper component that maintains current
* source state. Actual implementation of the diff view is done by CompareContainersWidgetInner.
*/
export const CompareContainersWidget = ({
upstreamBlockId,
downstreamBlockId,
isReadyToSyncIndividually = false,
}: ContainerInfoProps) => {
const [currentContainerState, setCurrentContainerState] = useState<ContainerInfoProps & {
state?: ContainerState;
parent:(ContainerInfoProps & { state?: ContainerState })[];
}>({
upstreamBlockId,
downstreamBlockId,
parent: [],
state: 'modified',
});
const { data } = useCourseContainerChildren(downstreamBlockId, true);
let localUpdateAlertBlockName = '';
let localUpdateAlertCount = 0;
// Show this alert if the only change is text components with local overrides.
// We decided not to put this in `CompareContainersWidgetInner` because if you enter a child,
// the alert would disappear. By keeping this call in CompareContainersWidget,
// the alert remains in the modal regardless of whether you navigate within the children.
if (!isReadyToSyncIndividually && data?.upstreamReadyToSyncChildrenInfo
&& data.upstreamReadyToSyncChildrenInfo.every(value => value.downstreamCustomized.length > 0 && value.blockType === 'html')
) {
localUpdateAlertCount = data.upstreamReadyToSyncChildrenInfo.length;
if (localUpdateAlertCount === 1) {
localUpdateAlertBlockName = data.upstreamReadyToSyncChildrenInfo[0].name;
}
}
const onRowClick = (row: WithState<ContainerChild>) => {
if (!isRowClickable(row.state, row.blockType as ContainerType)) {
return;
}
setCurrentContainerState((prev) => ({
upstreamBlockId: row.id!,
downstreamBlockId: row.downstreamId!,
state: row.state,
parent: [...prev.parent, {
upstreamBlockId: prev.upstreamBlockId,
downstreamBlockId: prev.downstreamBlockId,
state: prev.state,
}],
}));
};
const onBackBtnClick = () => {
setCurrentContainerState((prev) => {
// istanbul ignore if: this should never happen
if (prev.parent.length < 1) {
return prev;
}
const prevParent = prev.parent[prev.parent.length - 1];
return {
upstreamBlockId: prevParent!.upstreamBlockId,
downstreamBlockId: prevParent!.downstreamBlockId,
state: prevParent!.state,
parent: prev.parent.slice(0, -1),
};
});
};
return (
<CompareContainersWidgetInner
upstreamBlockId={currentContainerState.upstreamBlockId}
downstreamBlockId={currentContainerState.downstreamBlockId}
parent={currentContainerState.parent}
state={currentContainerState.state}
onRowClick={onRowClick}
onBackBtnClick={onBackBtnClick}
localUpdateAlertCount={localUpdateAlertCount}
localUpdateAlertBlockName={localUpdateAlertBlockName}
/>
);
};

View File

@@ -1,96 +0,0 @@
import userEvent from '@testing-library/user-event';
import {
fireEvent, initializeMocks, render, screen,
} from '../testUtils';
import ContainerRow from './ContainerRow';
import messages from './messages';
describe('<ContainerRow />', () => {
beforeEach(() => {
initializeMocks();
});
test('renders with default props', async () => {
render(<ContainerRow title="Test title" containerType="subsection" side="Before" />);
expect(await screen.findByText('Test title')).toBeInTheDocument();
});
test('renders with modified state', async () => {
render(<ContainerRow title="Test title" containerType="subsection" side="Before" state="modified" />);
expect(await screen.findByText(
messages.modifiedDiffBeforeMessage.defaultMessage.replace('{blockType}', 'subsection'),
)).toBeInTheDocument();
});
test('renders with removed state', async () => {
render(<ContainerRow title="Test title" containerType="subsection" side="After" state="removed" />);
expect(await screen.findByText(
messages.removedDiffAfterMessage.defaultMessage.replace('{blockType}', 'subsection'),
)).toBeInTheDocument();
});
test('calls onClick when clicked', async () => {
const onClick = jest.fn();
const user = userEvent.setup();
render(<ContainerRow
title="Test title"
containerType="subsection"
side="Before"
state="modified"
onClick={onClick}
/>);
const titleDiv = await screen.findByText('Test title');
const card = titleDiv.closest('.clickable');
expect(card).not.toBe(null);
await user.click(card!);
expect(onClick).toHaveBeenCalled();
});
test('calls onClick when pressed enter or space', async () => {
const onClick = jest.fn();
const user = userEvent.setup();
render(<ContainerRow
title="Test title"
containerType="subsection"
side="Before"
state="modified"
onClick={onClick}
/>);
const titleDiv = await screen.findByText('Test title');
const card = titleDiv.closest('.clickable');
expect(card).not.toBe(null);
fireEvent.select(card!);
await user.keyboard('{enter}');
expect(onClick).toHaveBeenCalled();
});
test('renders with originalName', async () => {
render(<ContainerRow title="Test title" containerType="subsection" side="Before" state="locallyRenamed" originalName="Modified name" />);
expect(await screen.findByText(messages.renamedDiffBeforeMessage.defaultMessage.replace('{name}', 'Modified name'))).toBeInTheDocument();
});
test('renders with local content update', async () => {
render(<ContainerRow title="Test title" containerType="html" side="Before" state="locallyContentUpdated" />);
expect(await screen.findByText(messages.locallyContentUpdatedBeforeMessage.defaultMessage.replace('{blockType}', 'html'))).toBeInTheDocument();
});
test('renders with rename and local content update', async () => {
render(<ContainerRow title="Test title" containerType="html" side="Before" state="locallyRenamedAndContentUpdated" originalName="Modified name" />);
expect(await screen.findByText(messages.renamedDiffBeforeMessage.defaultMessage.replace('{name}', 'Modified name'))).toBeInTheDocument();
expect(await screen.findByText(messages.locallyContentUpdatedBeforeMessage.defaultMessage.replace('{blockType}', 'html'))).toBeInTheDocument();
});
test('renders with moved state', async () => {
render(<ContainerRow title="Test title" containerType="subsection" side="After" state="moved" />);
expect(await screen.findByText(
messages.movedDiffAfterMessage.defaultMessage.replace('{blockType}', 'subsection'),
)).toBeInTheDocument();
});
test('renders with added state', async () => {
render(<ContainerRow title="Test title" containerType="subsection" side="After" state="added" />);
expect(await screen.findByText(
messages.addedDiffAfterMessage.defaultMessage.replace('{blockType}', 'subsection'),
)).toBeInTheDocument();
});
});

View File

@@ -1,132 +0,0 @@
import {
ActionRow, Card, Icon, Stack,
} from '@openedx/paragon';
import type { MessageDescriptor } from 'react-intl';
import { useMemo } from 'react';
import {
Cached, ChevronRight, Delete, Done, Plus,
} from '@openedx/paragon/icons';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { getItemIcon } from '@src/generic/block-type-utils';
import { ContainerType } from '@src/generic/key-utils';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
import messages from './messages';
import { ContainerState } from './types';
import { isRowClickable } from './utils';
export interface ContainerRowProps {
title: string;
containerType: ContainerType | keyof typeof COMPONENT_TYPES | string;
state?: ContainerState;
side: 'Before' | 'After';
originalName?: string;
onClick?: () => void;
}
interface StateContext {
className: string;
icon: React.ComponentType;
message?: MessageDescriptor;
message2?: MessageDescriptor;
}
const ContainerRow = ({
title, containerType, state, side, originalName, onClick,
}: ContainerRowProps) => {
const isClickable = isRowClickable(state, containerType as ContainerType);
const stateContext: StateContext = useMemo(() => {
let message: MessageDescriptor | undefined;
let message2: MessageDescriptor | undefined;
switch (state) {
case 'added':
message = side === 'Before' ? messages.addedDiffBeforeMessage : messages.addedDiffAfterMessage;
return { className: 'text-white bg-success-500', icon: Plus, message };
case 'modified':
message = side === 'Before' ? messages.modifiedDiffBeforeMessage : messages.modifiedDiffAfterMessage;
return { className: 'text-white bg-warning-900', icon: Cached, message };
case 'removed':
message = side === 'Before' ? messages.removedDiffBeforeMessage : messages.removedDiffAfterMessage;
return { className: 'text-white bg-danger-600', icon: Delete, message };
case 'locallyRenamed':
message = side === 'Before' ? messages.renamedDiffBeforeMessage : messages.renamedUpdatedDiffAfterMessage;
return { className: 'bg-light-300 text-light-300 ', icon: Done, message };
case 'locallyContentUpdated':
message = side === 'Before' ? messages.locallyContentUpdatedBeforeMessage : messages.locallyContentUpdatedAfterMessage;
return { className: 'bg-light-300 text-light-300 ', icon: Done, message };
case 'locallyRenamedAndContentUpdated':
message = side === 'Before' ? messages.renamedDiffBeforeMessage : messages.renamedUpdatedDiffAfterMessage;
message2 = side === 'Before' ? messages.locallyContentUpdatedBeforeMessage : messages.locallyContentUpdatedAfterMessage;
return {
className: 'bg-light-300 text-light-300 ', icon: Done, message, message2,
};
case 'moved':
message = side === 'Before' ? messages.movedDiffBeforeMessage : messages.movedDiffAfterMessage;
return { className: 'bg-light-300 text-light-300', icon: Done, message };
default:
return { className: 'bg-light-300 text-light-300', icon: Done, message };
}
}, [state, side]);
return (
<Card
isClickable={isClickable}
onClick={onClick}
onKeyDown={(e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
onClick?.();
}
}}
className="mb-2 rounded shadow-sm border border-light-100"
>
<Stack direction="horizontal" gap={0}>
<div
className={`px-1 align-self-stretch align-content-center rounded-left ${stateContext.className}`}
>
<Icon size="sm" src={stateContext.icon} />
</div>
<ActionRow className="p-2">
<Stack direction="vertical" gap={2}>
<Stack direction="horizontal" gap={2}>
<Icon
src={getItemIcon(containerType)}
screenReaderText={containerType}
title={title}
/>
<span className="small font-weight-bold">{title}</span>
</Stack>
{stateContext.message ? (
<div className="d-flex flex-column">
<span className="micro">
<FormattedMessage
{...stateContext.message}
values={{
blockType: containerType,
name: originalName,
}}
/>
</span>
{stateContext.message2 && (
<span className="micro">
<FormattedMessage
{...stateContext.message2}
values={{
blockType: containerType,
name: originalName,
}}
/>
</span>
)}
</div>
) : (
<span className="micro">&nbsp;</span>
)}
</Stack>
<ActionRow.Spacer />
{isClickable && <Icon size="md" src={ChevronRight} />}
</ActionRow>
</Stack>
</Card>
);
};
export default ContainerRow;

View File

@@ -1,116 +0,0 @@
/* istanbul ignore file */
import { CourseContainerChildrenData, type UpstreamReadyToSyncChildrenInfo } from '@src/course-unit/data/types';
import * as unitApi from '@src/course-unit/data/api';
/**
* Mock for `getLibraryContainerChildren()`
*
* This mock returns a fixed response for the given container ID.
*/
export async function mockGetCourseContainerChildren(containerId: string): Promise<CourseContainerChildrenData> {
let numChildren: number = 3;
let blockType: string;
let displayName: string;
let upstreamReadyToSyncChildrenInfo: UpstreamReadyToSyncChildrenInfo[] = [];
switch (containerId) {
case mockGetCourseContainerChildren.unitId:
blockType = 'text';
displayName = 'unit block 00';
break;
case mockGetCourseContainerChildren.sectionId:
blockType = 'subsection';
displayName = 'Test Title';
break;
case mockGetCourseContainerChildren.subsectionId:
blockType = 'unit';
displayName = 'subsection block 00';
break;
case mockGetCourseContainerChildren.sectionShowsAlertSingleText:
blockType = 'subsection';
displayName = 'Test Title';
upstreamReadyToSyncChildrenInfo = [{
id: 'block-v1:UNIX+UX1+2025_T3+type@html+block@1',
name: 'Html block 11',
blockType: 'html',
downstreamCustomized: ['display_name'],
upstream: 'upstream-id',
}];
break;
case mockGetCourseContainerChildren.sectionShowsAlertMultipleText:
blockType = 'subsection';
displayName = 'Test Title';
upstreamReadyToSyncChildrenInfo = [
{
id: 'block-v1:UNIX+UX1+2025_T3+type@html+block@1',
name: 'Html block 11',
blockType: 'html',
downstreamCustomized: ['display_name'],
upstream: 'upstream-id',
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@html+block@2',
name: 'Html block 22',
blockType: 'html',
downstreamCustomized: ['display_name'],
upstream: 'upstream-id',
},
];
break;
case mockGetCourseContainerChildren.unitIdLoading:
case mockGetCourseContainerChildren.sectionIdLoading:
case mockGetCourseContainerChildren.subsectionIdLoading:
return new Promise(() => { });
default:
blockType = 'section';
displayName = 'section block 00';
numChildren = 0;
break;
}
const children = Array(numChildren).fill(mockGetCourseContainerChildren.childTemplate).map((child, idx) => (
{
...child,
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests
id: `block-v1:UNIX+UX1+2025_T3+type@${blockType}+block@${idx}`,
name: `${blockType} block ${idx}${idx}`,
blockType,
upstreamLink: {
upstreamRef: `lct:org1:Demo_course_generated:${blockType}:${blockType}-${idx}`,
versionSynced: 1,
versionAvailable: 2,
versionDeclined: null,
downstreamCustomized: [],
},
}
));
return Promise.resolve({
canPasteComponent: true,
isPublished: false,
children,
displayName,
upstreamReadyToSyncChildrenInfo,
});
}
mockGetCourseContainerChildren.unitId = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0';
mockGetCourseContainerChildren.subsectionId = 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0';
mockGetCourseContainerChildren.sectionId = 'block-v1:UNIX+UX1+2025_T3+type@section+block@0';
mockGetCourseContainerChildren.sectionShowsAlertSingleText = 'block-v1:UNIX+UX1+2025_T3+type@section2+block@0';
mockGetCourseContainerChildren.sectionShowsAlertMultipleText = 'block-v1:UNIX+UX1+2025_T3+type@section3+block@0';
mockGetCourseContainerChildren.unitIdLoading = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@loading';
mockGetCourseContainerChildren.subsectionIdLoading = 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@loading';
mockGetCourseContainerChildren.sectionIdLoading = 'block-v1:UNIX+UX1+2025_T3+type@section+block@loading';
mockGetCourseContainerChildren.childTemplate = {
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@1',
name: 'Unit 1 remote edit - local edit',
blockType: 'unit',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
versionSynced: 1,
versionAvailable: 2,
versionDeclined: null,
downstreamCustomized: [],
},
};
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockGetCourseContainerChildren.applyMock = () => {
jest.spyOn(unitApi, 'getCourseContainerChildren').mockImplementation(mockGetCourseContainerChildren);
};

View File

@@ -1,30 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { getCourseContainerChildren } from '@src/course-unit/data/api';
import { getCourseKey } from '@src/generic/key-utils';
export const containerComparisonQueryKeys = {
all: ['containerComparison'],
/**
* Base key for a course
*/
course: (courseKey: string) => [...containerComparisonQueryKeys.all, courseKey],
/**
* Key for a single container
*/
container: (getUpstreamInfo: boolean, usageKey?: string) => {
if (usageKey === undefined) {
return [undefined, undefined, getUpstreamInfo.toString()];
}
const courseKey = getCourseKey(usageKey);
return [...containerComparisonQueryKeys.course(courseKey), usageKey, getUpstreamInfo.toString()];
},
};
export const useCourseContainerChildren = (usageKey?: string, getUpstreamInfo?: boolean) => (
useQuery({
enabled: !!usageKey,
queryFn: () => getCourseContainerChildren(usageKey!, getUpstreamInfo),
// If we first get data with a valid `usageKey` and then the `usageKey` changes to undefined, an error occurs.
queryKey: containerComparisonQueryKeys.container(getUpstreamInfo || false, usageKey),
})
);

View File

@@ -1,10 +0,0 @@
.compare-changes-widget {
.compare-card {
min-height: 350px;
}
.big-icon {
height: 68px;
width: 68px;
}
}

View File

@@ -1,101 +0,0 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
error: {
id: 'course-authoring.container-comparison.diff.error.message',
defaultMessage: 'Unexpected error: Failed to fetch container data',
description: 'Generic error message',
},
removedDiffBeforeMessage: {
id: 'course-authoring.container-comparison.diff.before.removed-message',
defaultMessage: 'This {blockType} will be removed in the new version',
description: 'Description for removed component in before section of diff preview',
},
removedDiffAfterMessage: {
id: 'course-authoring.container-comparison.diff.after.removed-message',
defaultMessage: 'This {blockType} was removed',
description: 'Description for removed component in after section of diff preview',
},
modifiedDiffBeforeMessage: {
id: 'course-authoring.container-comparison.diff.before.modified-message',
defaultMessage: 'This {blockType} will be modified',
description: 'Description for modified component in before section of diff preview',
},
modifiedDiffAfterMessage: {
id: 'course-authoring.container-comparison.diff.after.modified-message',
defaultMessage: 'This {blockType} was modified',
description: 'Description for modified component in after section of diff preview',
},
addedDiffBeforeMessage: {
id: 'course-authoring.container-comparison.diff.before.added-message',
defaultMessage: 'This {blockType} will be added in the new version',
description: 'Description for added component in before section of diff preview',
},
addedDiffAfterMessage: {
id: 'course-authoring.container-comparison.diff.after.added-message',
defaultMessage: 'This {blockType} was added',
description: 'Description for added component in after section of diff preview',
},
renamedDiffBeforeMessage: {
id: 'course-authoring.container-comparison.diff.before.locally-updated-message',
defaultMessage: 'Library Name: {name}',
description: 'Description for locally updated component in before section of diff preview',
},
renamedUpdatedDiffAfterMessage: {
id: 'course-authoring.container-comparison.diff.after.locally-updated-message',
defaultMessage: 'Library name remains overwritten',
description: 'Description for locally updated component in after section of diff preview',
},
locallyContentUpdatedBeforeMessage: {
id: 'course-authoring.container-comparison.diff.before.locally-content-updated-message',
defaultMessage: 'This {blockType} was edited locally',
description: 'Description for locally content updated component in before section of diff preview',
},
locallyContentUpdatedAfterMessage: {
id: 'course-authoring.container-comparison.diff.after.locally-content-updated-message',
defaultMessage: 'Local edit will remain',
description: 'Description for locally content updated component in after section of diff preview',
},
movedDiffBeforeMessage: {
id: 'course-authoring.container-comparison.diff.before.moved-message',
defaultMessage: 'This {blockType} will be moved in the new version',
description: 'Description for moved component in before section of diff preview',
},
movedDiffAfterMessage: {
id: 'course-authoring.container-comparison.diff.after.moved-message',
defaultMessage: 'This {blockType} was moved',
description: 'Description for moved component in after section of diff preview',
},
breadcrumbAriaLabel: {
id: 'course-authoring.container-comparison.diff.breadcrumb.ariaLabel',
defaultMessage: 'Title breadcrumb',
description: 'Aria label text for breadcrumb in diff preview',
},
diffBeforeTitle: {
id: 'course-authoring.container-comparison.diff.before.title',
defaultMessage: 'Before',
description: 'Before section title text',
},
diffAfterTitle: {
id: 'course-authoring.container-comparison.diff.after.title',
defaultMessage: 'After',
description: 'After section title text',
},
localChangeInTextAlert: {
id: 'course-authoring.container-comparison.text-with-local-change.alert',
defaultMessage: 'The only change is to {count, plural, one {text block <b>{blockName}</b> which has been edited} other {<b>{count} text blocks</b> which have been edited}} in this course. Accepting will not remove local edits.',
description: 'Alert to show if the only change is on text components with local overrides.',
},
newContainer: {
id: 'course-authoring.container-comparison.new-container.text',
defaultMessage: 'This {containerType} is new',
description: 'Text to show in the comparison when a container is new.',
},
deletedContainer: {
id: 'course-authoring.container-comparison.deleted-container.text',
defaultMessage: 'This {containerType} has been removed',
description: 'Text to show in the comparison when a container is removed.',
},
});
export default messages;

View File

@@ -1,31 +0,0 @@
import { UpstreamInfo } from '@src/data/types';
export type ContainerState = 'removed' | 'added' | 'modified' | 'childrenModified' | 'locallyContentUpdated' | 'locallyRenamed' | 'locallyRenamedAndContentUpdated' | 'moved';
export type WithState<T> = T & { state?: ContainerState, originalName?: string };
export type WithIndex<T> = T & { index: number };
export type CourseContainerChildBase = {
name: string;
id: string;
upstreamLink: UpstreamInfo;
blockType: string;
};
export type ContainerChildBase = {
displayName: string;
id: string;
containerType?: string;
blockType?: string;
} & ({
containerType: string;
} | {
blockType: string;
});
export type ContainerChild = {
name: string;
id?: string;
downstreamId?: string;
blockType: string;
};

View File

@@ -1,359 +0,0 @@
import { ContainerChildBase, CourseContainerChildBase } from './types';
import { diffPreviewContainerChildren } from './utils';
export const getMockCourseContainerData = (
type: 'added|deleted' | 'moved|deleted' | 'all' | 'locallyEdited',
): [CourseContainerChildBase[], ContainerChildBase[]] => {
switch (type) {
case 'moved|deleted':
return [
[
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
name: 'Unit 1 remote edit - local edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
versionSynced: 11,
versionAvailable: 11,
versionDeclined: null,
downstreamCustomized: ['display_name'],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
name: 'New unit remote edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
versionSynced: 7,
versionAvailable: 7,
versionDeclined: null,
downstreamCustomized: [],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
name: 'Unit with tags',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
versionSynced: 2,
versionAvailable: 2,
versionDeclined: null,
downstreamCustomized: [],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@4',
name: 'One more unit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:one-more-unit-745176',
versionSynced: 1,
versionAvailable: 1,
versionDeclined: null,
downstreamCustomized: [],
},
},
],
[
{
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
displayName: 'Unit with tags',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
displayName: 'Unit 1 remote edit 2',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:one-more-unit-745176',
displayName: 'One more unit',
containerType: 'unit',
},
],
] as [CourseContainerChildBase[], ContainerChildBase[]];
case 'added|deleted':
return [
[
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
name: 'Unit 1 remote edit - local edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
versionSynced: 11,
versionAvailable: 11,
versionDeclined: null,
downstreamCustomized: ['display_name'],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
name: 'New unit remote edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
versionSynced: 7,
versionAvailable: 7,
versionDeclined: null,
downstreamCustomized: [],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
name: 'Unit with tags',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
versionSynced: 2,
versionAvailable: 2,
versionDeclined: null,
downstreamCustomized: [],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@4',
name: 'One more unit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:one-more-unit-745176',
versionSynced: 1,
versionAvailable: 1,
versionDeclined: null,
downstreamCustomized: [],
},
},
],
[
{
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
displayName: 'Unit 1 remote edit 2',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
displayName: 'Unit with tags',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:added-unit-1',
displayName: 'Added unit',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:one-more-unit-745176',
displayName: 'One more unit',
containerType: 'unit',
},
],
] as [CourseContainerChildBase[], ContainerChildBase[]];
case 'all':
return [
[
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
name: 'Unit 1 remote edit - local edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
versionSynced: 11,
versionAvailable: 11,
versionDeclined: null,
downstreamCustomized: ['display_name'],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
name: 'New unit remote edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
versionSynced: 7,
versionAvailable: 7,
versionDeclined: null,
downstreamCustomized: [],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
name: 'Unit with tags',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
versionSynced: 2,
versionAvailable: 2,
versionDeclined: null,
downstreamCustomized: [],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@4',
name: 'One more unit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:one-more-unit-745176',
versionSynced: 1,
versionAvailable: 1,
versionDeclined: null,
downstreamCustomized: [],
},
},
],
[
{
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
displayName: 'Unit with tags',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:added-unit-1',
displayName: 'Added unit',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:one-more-unit-745176',
displayName: 'One more unit',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
displayName: 'Unit 1 remote edit 2',
containerType: 'unit',
},
],
] as [CourseContainerChildBase[], ContainerChildBase[]];
case 'locallyEdited':
return [
[
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@1',
name: 'Unit 1 remote edit - local edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-1-2a1741',
versionSynced: 11,
versionAvailable: 11,
versionDeclined: null,
downstreamCustomized: ['display_name'],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@2',
name: 'New unit remote edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
versionSynced: 7,
versionAvailable: 7,
versionDeclined: null,
downstreamCustomized: ['data'],
},
},
{
id: 'block-v1:UNIX+UX1+2025_T3+type@vertical+block@3',
name: 'Unit with tags - local edit',
blockType: 'vertical',
upstreamLink: {
upstreamRef: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
versionSynced: 2,
versionAvailable: 2,
versionDeclined: null,
downstreamCustomized: ['display_name', 'data'],
},
},
],
[
{
id: 'lct:UNIX:CS1:unit:unit-1-2a1741',
displayName: 'Unit 1 remote edit - remote edit',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:new-unit-remote-7eb9d1',
displayName: 'New unit remote edit',
containerType: 'unit',
},
{
id: 'lct:UNIX:CS1:unit:unit-with-tags-bec5f9',
displayName: 'Unit with tags - remote edit',
containerType: 'unit',
},
],
] as [CourseContainerChildBase[], ContainerChildBase[]];
default:
throw new Error();
}
};
describe('diffPreviewContainerChildren', () => {
it('should handle moved and deleted', () => {
const [a, b] = getMockCourseContainerData('moved|deleted');
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
expect(result[0].length).toEqual(result[1].length);
// renamed takes precendence over moved
expect(result[0][0].state).toEqual('locallyRenamed');
expect(result[1][2].state).toEqual('locallyRenamed');
expect(result[0][1].state).toEqual('removed');
expect(result[1][1].state).toEqual('removed');
expect(result[1][2].name).toEqual(a[0].name);
});
it('should handle add and delete', () => {
const [a, b] = getMockCourseContainerData('added|deleted');
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
expect(result[0].length).toEqual(result[1].length);
// No change, state=undefined
expect(result[0][0].state).toEqual('locallyRenamed');
expect(result[0][0].originalName).toEqual(b[0].displayName);
expect(result[1][0].state).toEqual('locallyRenamed');
// Deleted entry
expect(result[0][1].state).toEqual('removed');
expect(result[1][1].state).toEqual('removed');
expect(result[1][0].name).toEqual(a[0].name);
expect(result[0][3].name).toEqual(result[1][3].name);
expect(result[0][3].state).toEqual('added');
expect(result[1][3].state).toEqual('added');
});
it('should handle add, delete and moved', () => {
const [a, b] = getMockCourseContainerData('all');
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
expect(result[0].length).toEqual(result[1].length);
// renamed takes precendence over moved
expect(result[0][0].state).toEqual('locallyRenamed');
expect(result[1][4].state).toEqual('locallyRenamed');
expect(result[1][4].id).toEqual(result[0][0].id);
// Deleted entry
expect(result[0][1].state).toEqual('removed');
expect(result[1][1].state).toEqual('removed');
expect(result[1][1].name).toEqual(result[0][1].name);
// added entry
expect(result[0][2].state).toEqual('added');
expect(result[1][2].state).toEqual('added');
expect(result[1][2].id).toEqual(result[0][2].id);
});
it('should handle locally edited content', () => {
const [a, b] = getMockCourseContainerData('locallyEdited');
const result = diffPreviewContainerChildren(a as CourseContainerChildBase[], b);
expect(result[0].length).toEqual(result[1].length);
// renamed
expect(result[0][0].state).toEqual('locallyRenamed');
expect(result[1][0].state).toEqual('locallyRenamed');
expect(result[1][0].id).toEqual(result[0][0].id);
// content updated
expect(result[0][1].state).toEqual('locallyContentUpdated');
expect(result[1][1].state).toEqual('locallyContentUpdated');
expect(result[1][1].id).toEqual(result[0][1].id);
// renamed and content updated
expect(result[0][2].state).toEqual('locallyRenamedAndContentUpdated');
expect(result[1][2].state).toEqual('locallyRenamedAndContentUpdated');
expect(result[1][2].id).toEqual(result[0][2].id);
});
});

View File

@@ -1,143 +0,0 @@
import { UpstreamInfo } from '@src/data/types';
import { ContainerType, normalizeContainerType } from '@src/generic/key-utils';
import {
ContainerChild,
ContainerChildBase,
ContainerState,
CourseContainerChildBase,
WithIndex,
WithState,
} from './types';
export function checkIsReadyToSync(link: UpstreamInfo): boolean {
return (link.versionSynced < (link.versionAvailable || 0))
|| (link.versionSynced < (link.versionDeclined || 0))
|| ((link.readyToSyncChildren?.length || 0) > 0);
}
/**
* Compares two arrays of container children (`a` and `b`) to determine the differences between them.
* It generates two lists indicating which elements have been added, modified, moved, or removed.
*/
export function diffPreviewContainerChildren<A extends CourseContainerChildBase, B extends ContainerChildBase>(
a: A[],
b: B[],
idKey: string = 'id',
): [WithState<ContainerChild>[], WithState<ContainerChild>[]] {
const mapA = new Map<any, WithIndex<A>>();
const mapB = new Map<any, WithIndex<ContainerChild>>();
for (let index = 0; index < a.length; index++) {
const element = a[index];
mapA.set(element.upstreamLink?.upstreamRef, { ...element, index });
}
const updatedA: WithState<ContainerChild>[] = Array(a.length);
const addedA: Array<WithIndex<ContainerChild>> = [];
const updatedB: WithState<ContainerChild>[] = [];
for (let index = 0; index < b.length; index++) {
const newVersion = b[index];
const oldVersion = mapA.get(newVersion.id);
if (!oldVersion) {
// This is a newly added component
addedA.push({
id: newVersion.id,
name: newVersion.displayName,
blockType: (newVersion.containerType || newVersion.blockType)!,
index,
});
updatedB.push({
name: newVersion.displayName,
blockType: (newVersion.blockType || newVersion.containerType)!,
id: newVersion.id,
state: 'added',
});
} else {
// It was present in previous version
let state: ContainerState | undefined;
const displayName = oldVersion.upstreamLink.downstreamCustomized.includes('display_name') ? oldVersion.name : newVersion.displayName;
let originalName: string | undefined;
// FIXME: This logic doesn't work when the content is updated locally and the upstream display name is updated.
// `isRenamed` becomes true.
// We probably need to differentiate between `contentModified` and `rename` in the backend or
// send `downstream_customized` field to the frontend and use it here.
const isRenamed = displayName !== newVersion.displayName && displayName === oldVersion.name;
const isContentModified = oldVersion.upstreamLink.downstreamCustomized.includes('data');
if (index !== oldVersion.index) {
// has moved from its position
state = 'moved';
}
if ((oldVersion.upstreamLink.downstreamCustomized.length || 0) > 0) {
if (isRenamed) {
state = 'locallyRenamed';
originalName = newVersion.displayName;
}
if (isContentModified) {
state = 'locallyContentUpdated';
}
if (isRenamed && isContentModified) {
state = 'locallyRenamedAndContentUpdated';
}
} else if (checkIsReadyToSync(oldVersion.upstreamLink)) {
// has a new version ready to sync
state = 'modified';
}
// Insert in its original index
updatedA.splice(oldVersion.index, 1, {
name: oldVersion.name,
blockType: normalizeContainerType(oldVersion.blockType),
id: oldVersion.upstreamLink.upstreamRef,
downstreamId: oldVersion.id,
state,
originalName,
});
updatedB.push({
name: displayName,
blockType: (newVersion.blockType || newVersion.containerType)!,
id: newVersion.id,
downstreamId: oldVersion.id,
state,
});
// Delete it from mapA as it is processed.
mapA.delete(newVersion.id);
}
}
// If there are remaining items in mapA, it means they were deleted in newVersion;
mapA.forEach((oldVersion) => {
updatedA.splice(oldVersion.index, 1, {
name: oldVersion.name,
blockType: normalizeContainerType(oldVersion.blockType),
id: oldVersion.upstreamLink.upstreamRef,
downstreamId: oldVersion.id,
state: 'removed',
});
updatedB.splice(oldVersion.index, 0, {
id: oldVersion.upstreamLink.upstreamRef,
name: oldVersion.name,
blockType: normalizeContainerType(oldVersion.blockType),
downstreamId: oldVersion.id,
state: 'removed',
});
});
// Create a map for id with index of newly updatedB array
for (let index = 0; index < updatedB.length; index++) {
const element = updatedB[index];
mapB.set(element[idKey], { ...element, index });
}
// Use new mapB for getting new index for added elements
addedA.forEach((addedRow) => {
updatedA.splice(mapB.get(addedRow.id)?.index!, 0, { ...addedRow, state: 'added' });
});
return [updatedA, updatedB];
}
export function isRowClickable(state?: ContainerState, blockType?: ContainerType) {
return state && blockType && ['modified', 'added', 'removed'].includes(state) && [
ContainerType.Section,
ContainerType.Subsection,
ContainerType.Unit,
].includes(blockType);
}

View File

@@ -435,8 +435,8 @@ const ContentTagsCollapsible = ({
onKeyDown={handleSelectOnKeyDown}
ref={/** @type {React.RefObject} */(selectRef)}
isMulti
isLoading={updateTags.isPending}
isDisabled={updateTags.isPending}
isLoading={updateTags.isLoading}
isDisabled={updateTags.isLoading}
name="tags-select"
placeholder={intl.formatMessage(messages.collapsibleAddTagsPlaceholderText)}
isSearchable

View File

@@ -37,9 +37,3 @@
min-height: 100vh;
}
}
// Fix a bug with a toast on edit tags sheet component: can't click on close toast button
// https://github.com/openedx/frontend-app-authoring/issues/1898
#toast-root[data-focus-on-hidden] {
pointer-events: initial !important;
}

View File

@@ -719,16 +719,14 @@ describe('<ContentTagsDrawer />', () => {
await waitFor(() => expect(axiosMock.history.put[0].url).toEqual(url));
expect(mockInvalidateQueries).toHaveBeenCalledTimes(5);
expect(mockInvalidateQueries).toHaveBeenNthCalledWith(5, {
queryKey: [
'contentLibrary',
'lib:org:lib',
'content',
'container',
containerId,
'children',
],
});
expect(mockInvalidateQueries).toHaveBeenNthCalledWith(5, [
'contentLibrary',
'lib:org:lib',
'content',
'container',
containerId,
'children',
]);
});
});

View File

@@ -1,3 +1,4 @@
// @ts-check
import { useMemo } from 'react';
import { getConfig } from '@edx/frontend-platform';
import {
@@ -7,7 +8,6 @@ import {
useQueryClient,
} from '@tanstack/react-query';
import { useParams } from 'react-router';
import { TagData, TagListData } from '@src/taxonomy/data/types';
import {
getTaxonomyTagsData,
getContentTaxonomyTagsData,
@@ -17,16 +17,18 @@ import {
} from './api';
import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
import { getLibraryId } from '../../generic/key-utils';
import { UpdateTagsData } from './types';
/** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */
/** @typedef {import("../../taxonomy/data/types.js").TagData} TagData */
/**
* Builds the query to get the taxonomy tags
* @param taxonomyId The id of the taxonomy to fetch tags for
* @param parentTag The tag whose children we're loading, if any
* @param searchTerm The term passed in to perform search on tags
* @param numPages How many pages of tags to load at this level
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
* @param {string|null} parentTag The tag whose children we're loading, if any
* @param {string} searchTerm The term passed in to perform search on tags
* @param {number} numPages How many pages of tags to load at this level
*/
export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null = null, numPages = 1, searchTerm = '') => {
export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1, searchTerm = '') => {
const queryClient = useQueryClient();
const queryFn = async ({ queryKey }) => {
@@ -34,7 +36,8 @@ export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null
return getTaxonomyTagsData(taxonomyId, { parentTag: parentTag || '', searchTerm, page });
};
const queries: { queryKey: any[]; queryFn: typeof queryFn; staleTime: number }[] = [];
/** @type {{queryKey: any[], queryFn: typeof queryFn, staleTime: number}[]} */
const queries = [];
for (let page = 1; page <= numPages; page++) {
queries.push(
{ queryKey: ['taxonomyTags', taxonomyId, parentTag, page, searchTerm], queryFn, staleTime: Infinity },
@@ -51,7 +54,8 @@ export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null
const preLoadedData = new Map();
const newTags = dataPages.map(result => {
const simplifiedTagsList: TagData[] = [];
/** @type {TagData[]} */
const simplifiedTagsList = [];
result.data?.results?.forEach((tag) => {
if (tag.parentValue === parentTag) {
@@ -69,7 +73,8 @@ export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null
// Store the pre-loaded descendants into the query cache:
preLoadedData.forEach((tags, parentValue) => {
const queryKey = ['taxonomyTags', taxonomyId, parentValue, 1, searchTerm];
const cachedData: TagListData = {
/** @type {TagListData} */
const cachedData = {
next: '',
previous: '',
count: tags.length,
@@ -96,9 +101,9 @@ export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null
/**
* Builds the query to get the taxonomy tags applied to the content object
* @param contentId The ID of the content object to fetch the applied tags for (e.g. an XBlock usage key)
* @param {string} contentId The ID of the content object to fetch the applied tags for (e.g. an XBlock usage key)
*/
export const useContentTaxonomyTagsData = (contentId: string) => (
export const useContentTaxonomyTagsData = (contentId) => (
useQuery({
queryKey: ['contentTaxonomyTags', contentId],
queryFn: () => getContentTaxonomyTagsData(contentId),
@@ -107,30 +112,37 @@ export const useContentTaxonomyTagsData = (contentId: string) => (
/**
* Builds the query to get meta data about the content object
* @param contentId The id of the content object
* @param enabled Flag to enable/disable the query
* @param {string} contentId The id of the content object
* @param {boolean} enabled Flag to enable/disable the query
*/
export const useContentData = (contentId: string, enabled: boolean) => (
export const useContentData = (contentId, enabled) => (
useQuery({
queryKey: ['contentData', contentId],
queryFn: () => getContentData(contentId),
queryFn: enabled ? () => getContentData(contentId) : undefined,
enabled,
})
);
/**
* Builds the mutation to update the tags applied to the content object
* @param contentId The id of the content object to update tags for
* @param {string} contentId The id of the content object to update tags for
*/
export const useContentTaxonomyTagsUpdater = (contentId: string) => {
export const useContentTaxonomyTagsUpdater = (contentId) => {
const queryClient = useQueryClient();
const unitIframe = window.frames['xblock-iframe'];
const { containerId } = useParams();
return useMutation({
mutationFn: ({ tagsData }: { tagsData: Promise<UpdateTagsData[]> }) => (
updateContentTaxonomyTags(contentId, tagsData)
),
/**
* @type {import("@tanstack/react-query").MutateFunction<
* any,
* any,
* {
* tagsData: Promise<import("./types.js").UpdateTagsData[]>
* }
* >}
*/
mutationFn: ({ tagsData }) => updateContentTaxonomyTags(contentId, tagsData),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
/// Invalidate query with pattern on course outline
@@ -145,13 +157,13 @@ export const useContentTaxonomyTagsUpdater = (contentId: string) => {
// Obtain library id from contentId
const libraryId = getLibraryId(contentId);
// Invalidate component metadata to update tags count
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentMetadata(contentId) });
queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId));
// Invalidate content search to update tags count
queryClient.invalidateQueries({ queryKey: ['content_search'], predicate: (query) => libraryQueryPredicate(query, libraryId) });
queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) });
// If the tags for an item were edited from a container page (Unit, Subsection, Section),
// invalidate children query to fetch count again.
if (containerId) {
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerChildren(containerId) });
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(containerId));
}
}
},

View File

@@ -114,7 +114,7 @@ describe('<CourseLibraries />', () => {
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
await user.click(dismissBtn);
expect(allTab).toHaveAttribute('aria-selected', 'true');
await waitFor(() => expect(alert).not.toBeInTheDocument());
waitFor(() => expect(alert).not.toBeInTheDocument());
// review updates button
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
await user.click(reviewActionBtn);
@@ -327,19 +327,4 @@ describe('<CourseLibraries ReviewTab />', () => {
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
});
it('should show sync modal with local changes', async () => {
const itemIndex = 3;
const user = userEvent.setup();
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(7);
await user.click(previewBtns[itemIndex]);
expect(screen.getByText('This library content has local edits.')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /course content/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /published library content/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /update to published library content/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /keep course content/i })).toBeInTheDocument();
});
});

View File

@@ -32,14 +32,14 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
onReview,
}) => {
const intl = useIntl();
const { data, isPending } = useEntityLinksSummaryByDownstreamContext(courseId);
const { data, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
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}`;
useEffect(() => {
if (isPending) {
if (isLoading) {
return;
}
if (outOfSyncCount === 0) {
@@ -50,7 +50,7 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
const dismissedAlertDate = parseInt(localStorage.getItem(alertKey) ?? '0', 10);
setShowAlert((lastPublishedDate ?? 0) > dismissedAlertDate);
}, [outOfSyncCount, lastPublishedDate, isPending, data]);
}, [outOfSyncCount, lastPublishedDate, isLoading, data]);
const dismissAlert = () => {
setShowAlert(false);

View File

@@ -144,7 +144,7 @@ const ItemReviewList = ({
const {
hits,
isPending: isIndexDataPending,
isLoading: isIndexDataLoading,
hasError,
hasNextPage,
isFetchingNextPage,
@@ -173,8 +173,6 @@ const ItemReviewList = ({
upstreamBlockId: outOfSyncItemsByKey[info.usageKey].upstreamKey,
upstreamBlockVersionSynced: outOfSyncItemsByKey[info.usageKey].versionSynced,
isContainer: info.blockType === 'vertical' || info.blockType === 'sequential' || info.blockType === 'chapter',
blockType: info.blockType,
isLocallyModified: outOfSyncItemsByKey[info.usageKey].downstreamIsModified,
});
}, [outOfSyncItemsByKey]);
@@ -215,10 +213,7 @@ const ItemReviewList = ({
const updateBlock = useCallback(async (info: ContentHit) => {
try {
await acceptChangesMutation.mutateAsync({
blockId: info.usageKey,
overrideCustomizations: info.blockType === 'html' && outOfSyncItemsByKey[info.usageKey].downstreamIsModified,
});
await acceptChangesMutation.mutateAsync(info.usageKey);
reloadLinks(info.usageKey);
showToast(intl.formatMessage(
messages.updateSingleBlockSuccess,
@@ -235,9 +230,7 @@ const ItemReviewList = ({
return;
}
try {
await ignoreChangesMutation.mutateAsync({
blockId: blockData.downstreamBlockId,
});
await ignoreChangesMutation.mutateAsync(blockData.downstreamBlockId);
reloadLinks(blockData.downstreamBlockId);
showToast(intl.formatMessage(
messages.ignoreSingleBlockSuccess,
@@ -250,7 +243,7 @@ const ItemReviewList = ({
}
}, [blockData]);
if (isIndexDataPending) {
if (isIndexDataLoading) {
return <Loading />;
}
@@ -321,7 +314,7 @@ const ReviewTabContent = ({ courseId }: Props) => {
const intl = useIntl();
const {
data: outOfSyncItems,
isPending: isSyncItemsLoading,
isLoading: isSyncItemsLoading,
isError,
error,
} = useEntityLinks({

View File

@@ -11,7 +11,6 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -27,7 +26,6 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -43,7 +41,6 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 16,
"versionDeclined": null,
"downstreamIsModified": true,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -59,7 +56,6 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -75,7 +71,6 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -91,7 +86,6 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},

View File

@@ -29,7 +29,6 @@ export interface BasePublishableEntityLink {
created: string;
updated: string;
readyToSync: boolean;
downstreamIsModified: boolean;
}
export interface ComponentPublishableEntityLink extends BasePublishableEntityLink {

View File

@@ -32,7 +32,7 @@ const messages = defineMessages({
description: 'Tab title for review tab',
},
reviewTabDescriptionEmpty: {
id: 'course-authoring.course-libraries.tab.review.description-no-links',
id: 'course-authoring.course-libraries.tab.home.description-no-links',
defaultMessage: 'All components are up to date',
description: 'Description text for home tab',
},

View File

@@ -33,10 +33,10 @@ jest.mock('react-redux', () => ({
expect(newBtn).toBeInTheDocument();
const useBtn = await screen.findByRole('button', { name: `Use ${containerType} from library` });
expect(useBtn).toBeInTheDocument();
await userEvent.click(newBtn);
await waitFor(() => expect(newClickHandler).toHaveBeenCalled());
await userEvent.click(useBtn);
await waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
userEvent.click(newBtn);
waitFor(() => expect(newClickHandler).toHaveBeenCalled());
userEvent.click(useBtn);
waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
});
});
});

View File

@@ -7,7 +7,6 @@ import {
import CardHeader from './CardHeader';
import TitleButton from './TitleButton';
import messages from './messages';
import { RequestStatus } from '../../data/constants';
const onExpandMock = jest.fn();
const onClickMenuButtonMock = jest.fn();
@@ -233,6 +232,16 @@ describe('<CardHeader />', () => {
});
});
it('check is field disabled when isDisabledEditField is true', async () => {
renderComponent({
...cardHeaderProps,
isFormOpen: true,
isDisabledEditField: true,
});
expect(await screen.findByTestId('subsection-edit-field')).toBeDisabled();
});
it('check editing is enabled when isDisabledEditField is false', async () => {
renderComponent({ ...cardHeaderProps });
@@ -245,8 +254,8 @@ describe('<CardHeader />', () => {
expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled');
});
it('check editing is disabled when saving is in progress', async () => {
renderComponent({ ...cardHeaderProps, savingStatus: RequestStatus.IN_PROGRESS });
it('check editing is disabled when isDisabledEditField is true', async () => {
renderComponent({ ...cardHeaderProps, isDisabledEditField: true });
expect(await screen.findByTestId('subsection-edit-button')).toBeDisabled();

View File

@@ -24,7 +24,6 @@ import { ContentTagsDrawerSheet } from '@src/content-tags-drawer';
import TagCount from '@src/generic/tag-count';
import { useEscapeClick } from '@src/hooks';
import { XBlockActions } from '@src/data/types';
import { RequestStatus, RequestStatusType } from '@src/data/constants';
import { ITEM_BADGE_STATUS } from '../constants';
import { scrollToElement } from '../utils';
import CardStatus from './CardStatus';
@@ -42,6 +41,7 @@ interface CardHeaderProps {
isFormOpen: boolean;
onEditSubmit: (titleValue: string) => void;
closeForm: () => void;
isDisabledEditField: boolean;
onClickDelete: () => void;
onClickUnlink: () => void;
onClickDuplicate: () => void;
@@ -69,7 +69,6 @@ interface CardHeaderProps {
extraActionsComponent?: ReactNode,
onClickSync?: () => void;
readyToSync?: boolean;
savingStatus?: RequestStatusType;
}
const CardHeader = ({
@@ -84,6 +83,7 @@ const CardHeader = ({
isFormOpen,
onEditSubmit,
closeForm,
isDisabledEditField,
onClickDelete,
onClickUnlink,
onClickDuplicate,
@@ -103,7 +103,6 @@ const CardHeader = ({
extraActionsComponent,
onClickSync,
readyToSync,
savingStatus,
}: CardHeaderProps) => {
const intl = useIntl();
const [searchParams] = useSearchParams();
@@ -120,7 +119,6 @@ const CardHeader = ({
|| status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
const { data: contentTagCount } = useContentTagsCount(cardId);
const isSaving = savingStatus === RequestStatus.IN_PROGRESS;
useEffect(() => {
const locatorId = searchParams.get('show');
@@ -174,7 +172,7 @@ const CardHeader = ({
onEditSubmit(titleValue);
}
}}
disabled={isSaving}
disabled={isDisabledEditField}
/>
</Form.Group>
) : (
@@ -188,7 +186,7 @@ const CardHeader = ({
iconAs={EditIcon}
onClick={onClickEdit}
// @ts-ignore
disabled={isSaving}
disabled={isDisabledEditField}
/>
</>
)}
@@ -240,7 +238,7 @@ const CardHeader = ({
</Dropdown.Item>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-configure-button`}
disabled={isSaving}
disabled={isDisabledEditField}
onClick={onClickConfigure}
>
{intl.formatMessage(messages.menuConfigure)}
@@ -248,7 +246,7 @@ const CardHeader = ({
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
disabled={isSaving}
disabled={isDisabledEditField}
onClick={openManageTagsDrawer}
>
{intl.formatMessage(messages.menuManageTags)}

View File

@@ -22,7 +22,7 @@ export const useCreateCourseBlock = (
) => useMutation({
mutationFn: createCourseXblock,
onSettled: async (data) => {
callback?.(data?.locator, data.parent_locator);
callback?.(data.locator, data.parent_locator);
},
});

View File

@@ -62,7 +62,7 @@ import {
* @param {string} courseId - ID of the course
* @returns {Object} - Object containing fetch course outline index query success or failure status
*/
export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) => Promise<void> {
export function fetchCourseOutlineIndexQuery(courseId: string): object {
return async (dispatch) => {
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));

View File

@@ -3,7 +3,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useToggle } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useQueryClient } from '@tanstack/react-query';
import moment from 'moment';
import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors';
@@ -65,11 +64,9 @@ import {
} from './data/thunk';
import { useCreateCourseBlock } from './data/apiHooks';
import { getCourseItem } from './data/api';
import { containerComparisonQueryKeys } from '../container-comparison/data/apiHooks';
const useCourseOutline = ({ courseId }) => {
const dispatch = useDispatch();
const queryClient = useQueryClient();
const navigate = useNavigate();
const waffleFlags = useWaffleFlags(courseId);
@@ -248,8 +245,6 @@ const useCourseOutline = ({ courseId }) => {
const handleEditSubmit = (itemId, sectionId, displayName) => {
dispatch(editCourseItemQuery(itemId, sectionId, displayName));
// Invalidate container diff queries to update sync diff preview
queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) });
};
const handleDeleteItemSubmit = () => {

View File

@@ -1,31 +1,30 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { uniqBy } from 'lodash';
import { getConfig } from '@edx/frontend-platform';
import { useDispatch, useSelector } from 'react-redux';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Campaign as CampaignIcon,
InfoOutline as InfoOutlineIcon,
Warning as WarningIcon,
Error as ErrorIcon,
} from '@openedx/paragon/icons';
import {
Alert, Button, Hyperlink, Truncate,
} from '@openedx/paragon';
import {
Campaign as CampaignIcon,
Error as ErrorIcon,
InfoOutline as InfoOutlineIcon,
Warning as WarningIcon,
} from '@openedx/paragon/icons';
import { uniqBy } from 'lodash';
import PropTypes from 'prop-types';
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Link, useNavigate } from 'react-router-dom';
import CourseOutlinePageAlertsSlot from '../../plugin-slots/CourseOutlinePageAlertsSlot';
import advancedSettingsMessages from '../../advanced-settings/messages';
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
import { RequestStatus } from '../../data/constants';
import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
import { RequestStatus } from '../../data/constants';
import AlertMessage from '../../generic/alert-message';
import AlertProctoringError from '../../generic/AlertProctoringError';
import { API_ERROR_TYPES } from '../constants';
import messages from './messages';
import advancedSettingsMessages from '../../advanced-settings/messages';
import { getPasteFileNotices } from '../data/selectors';
import { dismissError, removePasteFileNotices } from '../data/slice';
import messages from './messages';
import { API_ERROR_TYPES } from '../constants';
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
const PageAlerts = ({
courseId,
@@ -438,7 +437,6 @@ const PageAlerts = ({
{conflictingFilesPasteAlert()}
{newFilesPasteAlert()}
{renderOutOfSyncAlert()}
<CourseOutlinePageAlertsSlot />
</>
);
};

View File

@@ -17,11 +17,11 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
}));
const unit = {
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
id: 'unit-1',
};
const subsection = {
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
id: '123',
displayName: 'Subsection Name',
category: 'sequential',
published: true,
@@ -43,7 +43,7 @@ const subsection = {
} satisfies Partial<XBlock> as XBlock;
const section = {
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
id: '123',
displayName: 'Section Name',
category: 'chapter',
published: true,
@@ -71,10 +71,7 @@ const section = {
readyToSync: true,
upstreamRef: 'lct:org1:lib1:section:1',
versionSynced: 1,
versionAvailable: 2,
versionDeclined: null,
errorMessage: null,
downstreamCustomized: [] as string[],
},
} satisfies Partial<XBlock> as XBlock;
@@ -91,6 +88,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
onOpenDeleteModal={jest.fn()}
onOpenUnlinkModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
savingStatus=""
onEditSectionSubmit={onEditSectionSubmit}
onDuplicateSubmit={jest.fn()}
isSectionsExpanded
@@ -189,9 +187,7 @@ describe('<SectionCard />', () => {
const collapsedSections = { ...section };
// @ts-ignore-next-line
collapsedSections.isSectionsExpanded = false;
// url encode subsection.id
const subsectionIdUrl = encodeURIComponent(subsection.id);
renderComponent(collapsedSections, `/course/:courseId?show=${subsectionIdUrl}`);
renderComponent(collapsedSections, `/course/:courseId?show=${subsection.id}`);
const cardSubsections = await screen.findByTestId('section-card__subsections');
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
@@ -203,9 +199,7 @@ describe('<SectionCard />', () => {
const collapsedSections = { ...section };
// @ts-ignore-next-line
collapsedSections.isSectionsExpanded = false;
// url encode subsection.id
const unitIdUrl = encodeURIComponent(unit.id);
renderComponent(collapsedSections, `/course/:courseId?show=${unitIdUrl}`);
renderComponent(collapsedSections, `/course/:courseId?show=${unit.id}`);
const cardSubsections = await screen.findByTestId('section-card__subsections');
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
@@ -237,6 +231,7 @@ describe('<SectionCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: section name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
// Click on accept changes
const acceptChangesButton = screen.getByText(/accept changes/i);
@@ -256,6 +251,7 @@ describe('<SectionCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: section name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
// Click on ignore changes
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });

View File

@@ -11,7 +11,7 @@ import classNames from 'classnames';
import { useQueryClient } from '@tanstack/react-query';
import { setCurrentItem, setCurrentSection } from '@src/course-outline/data/slice';
import { RequestStatus, RequestStatusType } from '@src/data/constants';
import { RequestStatus } from '@src/data/constants';
import CardHeader from '@src/course-outline/card-header/CardHeader';
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
@@ -39,7 +39,7 @@ interface SectionCardProps {
onOpenPublishModal: () => void,
onOpenConfigureModal: () => void,
onEditSectionSubmit: (itemId: string, sectionId: string, displayName: string) => void,
savingStatus?: RequestStatusType,
savingStatus: string,
onOpenDeleteModal: () => void,
onOpenUnlinkModal: () => void,
onDuplicateSubmit: () => void,
@@ -144,9 +144,7 @@ const SectionCard = ({
downstreamBlockId: id,
upstreamBlockId: upstreamInfo.upstreamRef,
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
isContainer: true,
blockType: 'section',
};
}, [upstreamInfo]);
@@ -303,7 +301,7 @@ const SectionCard = ({
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
savingStatus={savingStatus}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}

View File

@@ -52,7 +52,7 @@ const unit = {
};
const subsection: XBlock = {
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
id: '123',
displayName: 'Subsection Name',
category: 'sequential',
published: true,
@@ -75,15 +75,12 @@ const subsection: XBlock = {
readyToSync: true,
upstreamRef: 'lct:org1:lib1:subsection:1',
versionSynced: 1,
versionAvailable: 2,
versionDeclined: null,
errorMessage: null,
downstreamCustomized: [] as string[],
},
} satisfies Partial<XBlock> as XBlock;
const section: XBlock = {
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
id: '123',
displayName: 'Section Name',
published: true,
visibilityState: 'live',
@@ -118,6 +115,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
onNewUnitSubmit={jest.fn()}
onAddUnitFromLibrary={handleOnAddUnitFromLibrary}
isCustomRelativeDatesActive={false}
savingStatus=""
onEditSubmit={onEditSubectionSubmit}
onDuplicateSubmit={jest.fn()}
onOpenConfigureModal={jest.fn()}
@@ -324,7 +322,7 @@ describe('<SubsectionCard />', () => {
expect(handleOnAddUnitFromLibrary).toHaveBeenCalled();
expect(handleOnAddUnitFromLibrary).toHaveBeenCalledWith({
type: COMPONENT_TYPES.libraryV2,
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
parentLocator: '123',
category: 'vertical',
libraryContentKey: containerKey,
});
@@ -341,6 +339,7 @@ describe('<SubsectionCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: subsection name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
// Click on accept changes
const acceptChangesButton = screen.getByText(/accept changes/i);
@@ -360,6 +359,7 @@ describe('<SubsectionCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: subsection name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
// Click on ignore changes
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });

View File

@@ -11,7 +11,7 @@ import { isEmpty } from 'lodash';
import CourseOutlineSubsectionCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot';
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice';
import { RequestStatus, RequestStatusType } from '@src/data/constants';
import { RequestStatus } from '@src/data/constants';
import CardHeader from '@src/course-outline/card-header/CardHeader';
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
@@ -40,7 +40,7 @@ interface SubsectionCardProps {
isCustomRelativeDatesActive: boolean,
onOpenPublishModal: () => void,
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
savingStatus?: RequestStatusType,
savingStatus: string,
onOpenDeleteModal: () => void,
onOpenUnlinkModal: () => void,
onDuplicateSubmit: () => void,
@@ -126,9 +126,7 @@ const SubsectionCard = ({
downstreamBlockId: id,
upstreamBlockId: upstreamInfo.upstreamRef,
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
isContainer: true,
blockType: 'subsection',
};
}, [upstreamInfo]);
@@ -305,7 +303,7 @@ const SubsectionCard = ({
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
savingStatus={savingStatus}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}

View File

@@ -19,7 +19,7 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
}));
const section = {
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
id: '1',
displayName: 'Section Name',
published: true,
visibilityState: 'live',
@@ -34,7 +34,7 @@ const section = {
} satisfies Partial<XBlock> as XBlock;
const subsection = {
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
id: '12',
displayName: 'Subsection Name',
published: true,
visibilityState: 'live',
@@ -48,7 +48,7 @@ const subsection = {
} satisfies Partial<XBlock> as XBlock;
const unit = {
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
id: '123',
displayName: 'unit Name',
category: 'vertical',
published: true,
@@ -65,10 +65,7 @@ const unit = {
readyToSync: true,
upstreamRef: 'lct:org1:lib1:unit:1',
versionSynced: 1,
versionAvailable: 2,
versionDeclined: null,
errorMessage: null,
downstreamCustomized: [] as string[],
},
} satisfies Partial<XBlock> as XBlock;
@@ -84,6 +81,7 @@ const renderComponent = (props?: object) => render(
onOpenDeleteModal={jest.fn()}
onOpenUnlinkModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
savingStatus=""
onEditSubmit={jest.fn()}
onDuplicateSubmit={jest.fn()}
getTitleLink={(id) => `/some/${id}`}
@@ -110,10 +108,7 @@ describe('<UnitCard />', () => {
const { findByTestId } = renderComponent();
expect(await findByTestId('unit-card-header')).toBeInTheDocument();
expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute(
'href',
'/some/block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
);
expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute('href', '/some/123');
});
it('hides header based on isHeaderVisible flag', async () => {
@@ -204,6 +199,7 @@ describe('<UnitCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
// Click on accept changes
const acceptChangesButton = screen.getByText(/accept changes/i);
@@ -223,6 +219,7 @@ describe('<UnitCard />', () => {
// Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
// Click on ignore changes
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });

View File

@@ -13,7 +13,8 @@ import { useQueryClient } from '@tanstack/react-query';
import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot';
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice';
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
import { RequestStatus, RequestStatusType } from '@src/data/constants';
import { RequestStatus } from '@src/data/constants';
import { isUnitReadOnly } from '@src/course-unit/data/utils';
import CardHeader from '@src/course-outline/card-header/CardHeader';
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
import TitleLink from '@src/course-outline/card-header/TitleLink';
@@ -32,7 +33,7 @@ interface UnitCardProps {
onOpenPublishModal: () => void;
onOpenConfigureModal: () => void;
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
savingStatus?: RequestStatusType;
savingStatus: string;
onOpenDeleteModal: () => void;
onOpenUnlinkModal: () => void;
onDuplicateSubmit: () => void;
@@ -103,12 +104,12 @@ const UnitCard = ({
downstreamBlockId: id,
upstreamBlockId: upstreamInfo.upstreamRef,
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
isContainer: true,
blockType: 'unit',
};
}, [upstreamInfo]);
const readOnly = isUnitReadOnly(unit);
// re-create actions object for customizations
const actions = { ...unitActions };
// add actions to control display of move up & down menu buton.
@@ -246,7 +247,7 @@ const UnitCard = ({
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
savingStatus={savingStatus}
isDisabledEditField={readOnly || savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}

View File

@@ -50,7 +50,6 @@ const CourseUnit = ({ courseId }) => {
isUnitVerticalType,
isUnitLibraryType,
isSplitTestType,
isProblemBankType,
staticFileNotices,
currentlyVisibleToStudents,
unitXBlockActions,
@@ -220,7 +219,6 @@ const CourseUnit = ({ courseId }) => {
parentLocator={blockId}
isSplitTestType={isSplitTestType}
isUnitVerticalType={isUnitVerticalType}
isProblemBankType={isProblemBankType}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
addComponentTemplateData={addComponentTemplateData}
/>

View File

@@ -2218,7 +2218,7 @@ describe('<CourseUnit />', () => {
const currentSubSectionName = courseSectionVerticalMock.xblock_info.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';
await waitFor(() => {
waitFor(() => {
const unitHeaderTitle = screen.getByTestId('unit-header-title');
expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
@@ -2291,17 +2291,19 @@ describe('<CourseUnit />', () => {
});
it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => {
render(<RootWrapper />);
const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock));
// Convert the second child from drag and drop to HTML:
const targetChild = updatedCourseVerticalChildrenMock.children[1];
targetChild.block_type = 'html';
targetChild.name = 'Test HTML Block';
targetChild.block_id = 'block-v1:OpenedX+L153+3T2023+type@html+block@test123original';
const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id;
updatedCourseVerticalChildrenMock.children = updatedCourseVerticalChildrenMock.children
.map((child) => (child.block_id === targetBlockId
? { ...child, block_type: 'html' }
: child));
axiosMock
.onPost(postXBlockBaseApiUrl({
parent_locator: blockId,
duplicate_source_locator: targetChild.block_id,
duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id,
}))
.replyOnce(200, { locator: '1234567890' });
@@ -2309,20 +2311,21 @@ describe('<CourseUnit />', () => {
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, updatedCourseVerticalChildrenMock);
render(<RootWrapper />);
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
await waitFor(() => {
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toBeInTheDocument();
simulatePostMessageEvent(messageTypes.currentXBlockId, {
id: targetBlockId,
});
});
// After duplicating, the editor modal will open:
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
simulatePostMessageEvent(messageTypes.duplicateXBlock, { usageId: targetChild.block_id });
simulatePostMessageEvent(messageTypes.newXBlockEditor, { blockType: 'html', usageId: targetChild.block_id });
await waitFor(() => {
expect(screen.queryByRole('dialog')).toBeInTheDocument();
waitFor(() => {
simulatePostMessageEvent(messageTypes.duplicateXBlock, {});
simulatePostMessageEvent(messageTypes.newXBlockEditor, {});
expect(mockedUsedNavigate)
.toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true });
});
});
@@ -2350,14 +2353,14 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(/this unit can only be edited from the \./i)).toBeInTheDocument();
// Edit button should be enabled even for library imported units
// Disable the "Edit" button
const unitHeaderTitle = screen.getByTestId('unit-header-title');
const editButton = within(unitHeaderTitle).getByRole(
'button',
{ name: 'Edit' },
);
expect(editButton).toBeInTheDocument();
expect(editButton).toBeEnabled();
expect(editButton).toBeDisabled();
// The "Publish" button should still be enabled
const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
@@ -2368,6 +2371,14 @@ describe('<CourseUnit />', () => {
expect(publishButton).toBeInTheDocument();
expect(publishButton).toBeEnabled();
// Disable the "Manage Tags" button
const manageTagsButton = screen.getByRole(
'button',
{ name: tagsDrawerMessages.manageTagsButton.defaultMessage },
);
expect(manageTagsButton).toBeInTheDocument();
expect(manageTagsButton).toBeDisabled();
// Does not render the "Add Components" section
expect(screen.queryByText(addComponentMessages.title.defaultMessage)).not.toBeInTheDocument();
});

View File

@@ -1,4 +1,5 @@
import { useCallback, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { getConfig } from '@edx/frontend-platform';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
@@ -6,64 +7,28 @@ import {
ActionRow, Button, StandardModal, useToggle,
} from '@openedx/paragon';
import { useWaffleFlags } from '@src/data/apiHooks';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
import { ComponentPicker } from '@src/library-authoring/component-picker';
import { ContentType } from '@src/library-authoring/routes';
import { useIframe } from '@src/generic/hooks/context/hooks';
import { useEventListener } from '@src/generic/hooks';
import VideoSelectorPage from '@src/editors/VideoSelectorPage';
import EditorPage from '@src/editors/EditorPage';
import { SelectedComponent } from '@src/library-authoring';
import { fetchCourseSectionVerticalData } from '../data/thunk';
import { messageTypes } from '../constants';
import messages from './messages';
import AddComponentButton from './add-component-btn';
import ComponentModalView from './add-component-modals/ComponentModalView';
import { getCourseSectionVertical, getCourseUnitData } from '../data/selectors';
type ComponentTemplateData = {
displayName: string,
category?: string,
type: string,
beta?: boolean,
templates: Array<{
boilerplateName?: string,
category?: string,
displayName: string,
supportLevel?: string | boolean,
}>,
supportLegend: {
allowUnsupportedXblocks?: boolean,
documentationLabel?: string,
showLegend?: boolean,
},
};
export interface AddComponentProps {
isSplitTestType?: boolean,
isUnitVerticalType?: boolean,
parentLocator: string,
handleCreateNewCourseXBlock: (
args: object,
callback?: (args: { courseKey: string, locator: string }) => void
) => void,
isProblemBankType?: boolean,
addComponentTemplateData?: {
blockId: string,
parentLocator?: string,
model: ComponentTemplateData,
},
}
import { useWaffleFlags } from '../../data/apiHooks';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import ComponentModalView from './add-component-modals/ComponentModalView';
import AddComponentButton from './add-component-btn';
import messages from './messages';
import { ComponentPicker } from '../../library-authoring/component-picker';
import { ContentType } from '../../library-authoring/routes';
import { messageTypes } from '../constants';
import { useIframe } from '../../generic/hooks/context/hooks';
import { useEventListener } from '../../generic/hooks';
import VideoSelectorPage from '../../editors/VideoSelectorPage';
import EditorPage from '../../editors/EditorPage';
import { fetchCourseSectionVerticalData } from '../data/thunk';
const AddComponent = ({
parentLocator,
isSplitTestType,
isUnitVerticalType,
isProblemBankType,
addComponentTemplateData,
handleCreateNewCourseXBlock,
}: AddComponentProps) => {
}) => {
const intl = useIntl();
const dispatch = useDispatch();
@@ -71,16 +36,16 @@ const AddComponent = ({
const [isOpenHtml, openHtml, closeHtml] = useToggle(false);
const [isOpenOpenAssessment, openOpenAssessment, closeOpenAssessment] = useToggle(false);
const { componentTemplates = {} } = useSelector(getCourseSectionVertical);
const blockId = addComponentTemplateData?.parentLocator || parentLocator;
const blockId = addComponentTemplateData.parentLocator || parentLocator;
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle();
const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle();
const [blockType, setBlockType] = useState<string | null>(null);
const [courseId, setCourseId] = useState<string | null>(null);
const [newBlockId, setNewBlockId] = useState<string | null>(null);
const [blockType, setBlockType] = useState(null);
const [courseId, setCourseId] = useState(null);
const [newBlockId, setNewBlockId] = useState(null);
const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle();
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
const [selectedComponents, setSelectedComponents] = useState([]);
const [usageId, setUsageId] = useState(null);
const { sendMessageToIframe } = useIframe();
const { useVideoGalleryFlow } = useWaffleFlags(courseId ?? undefined);
@@ -119,7 +84,7 @@ const AddComponent = ({
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
}, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe, blockId, sequenceId]);
const handleLibraryV2Selection = useCallback((selection: SelectedComponent) => {
const handleLibraryV2Selection = useCallback((selection) => {
handleCreateNewCourseXBlock({
type: COMPONENT_TYPES.libraryV2,
category: selection.blockType,
@@ -129,7 +94,7 @@ const AddComponent = ({
closeAddLibraryContentModal();
}, [usageId]);
const handleCreateNewXBlock = (type: string, moduleName?: string) => {
const handleCreateNewXBlock = (type, moduleName) => {
switch (type) {
case COMPONENT_TYPES.discussion:
case COMPONENT_TYPES.dragAndDrop:
@@ -191,16 +156,16 @@ const AddComponent = ({
}
};
if (isUnitVerticalType || isSplitTestType || isProblemBankType) {
if (isUnitVerticalType || isSplitTestType) {
return (
<div className="py-4">
{Object.keys(componentTemplates).length && isUnitVerticalType ? (
<>
<h5 className="h3 mb-4 text-center">{intl.formatMessage(messages.title)}</h5>
<ul className="new-component-type list-unstyled m-0 d-flex flex-wrap justify-content-center">
{componentTemplates.map((component: ComponentTemplateData) => {
{componentTemplates.map((component) => {
const { type, displayName, beta } = component;
let modalParams: { open: () => void, close: () => void, isOpen: boolean };
let modalParams;
if (!component.templates.length) {
return null;
@@ -303,7 +268,7 @@ const AddComponent = ({
/>
</div>
</StandardModal>
{isXBlockEditorModalOpen && courseId && blockType && newBlockId && (
{isXBlockEditorModalOpen && (
<div className="editor-page">
<EditorPage
courseId={courseId}
@@ -323,4 +288,32 @@ const AddComponent = ({
return null;
};
AddComponent.propTypes = {
isSplitTestType: PropTypes.bool.isRequired,
isUnitVerticalType: PropTypes.bool.isRequired,
parentLocator: PropTypes.string.isRequired,
handleCreateNewCourseXBlock: PropTypes.func.isRequired,
addComponentTemplateData: {
blockId: PropTypes.string.isRequired,
model: PropTypes.shape({
displayName: PropTypes.string.isRequired,
category: PropTypes.string,
type: PropTypes.string.isRequired,
templates: PropTypes.arrayOf(
PropTypes.shape({
boilerplateName: PropTypes.string,
category: PropTypes.string,
displayName: PropTypes.string.isRequired,
supportLevel: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
}),
),
supportLegend: PropTypes.shape({
allowUnsupportedXblocks: PropTypes.bool,
documentationLabel: PropTypes.string,
showLegend: PropTypes.bool,
}),
}),
},
};
export default AddComponent;

View File

@@ -14,7 +14,7 @@ import { fetchCourseSectionVerticalData } from '../data/thunk';
import { getCourseSectionVerticalApiUrl } from '../data/api';
import { courseSectionVerticalMock } from '../__mocks__';
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
import AddComponent, { AddComponentProps } from './AddComponent';
import AddComponent from './AddComponent';
import messages from './messages';
import { IframeProvider } from '../../generic/hooks/context/iFrameContext';
import { messageTypes } from '../constants';
@@ -56,11 +56,13 @@ jest.mock('../../generic/hooks/context/hooks', () => ({
}),
}));
const renderComponent = (props?: AddComponentProps) => render(
const renderComponent = (props) => render(
<IframeProvider>
<AddComponent
blockId={blockId}
isUnitVerticalType
parentLocator={blockId}
addComponentTemplateData={{}}
handleCreateNewCourseXBlock={handleCreateNewCourseXBlockMock}
{...props}
/>
@@ -92,7 +94,7 @@ describe('<AddComponent />', () => {
),
});
expect(btn).toBeInTheDocument();
if (componentTemplates[component].beta) {
if (component.beta) {
expect(within(btn).queryByText('Beta')).toBeInTheDocument();
}
});

View File

@@ -41,7 +41,6 @@ export const getXBlockSupportMessages = (intl) => ({
export const messageTypes = {
refreshXBlock: 'refreshXBlock',
refreshIframe: 'refreshIframe',
showMoveXBlockModal: 'showMoveXBlockModal',
completeXBlockMoving: 'completeXBlockMoving',
rollbackMovedXBlock: 'rollbackMovedXBlock',

View File

@@ -10,13 +10,13 @@ const UnitButton = ({
unitId,
className,
showTitle,
isActive, // passed from parent (SequenceNavigationTabs)
}) => {
const courseId = useSelector(getCourseId);
const sequenceId = useSelector(getSequenceId);
const unit = useSelector((state) => state.models.units[unitId]);
const { title, contentType } = unit || {};
const { title, contentType, isActive } = unit || {};
return (
<Button
@@ -37,13 +37,11 @@ UnitButton.propTypes = {
className: PropTypes.string,
showTitle: PropTypes.bool,
unitId: PropTypes.string.isRequired,
isActive: PropTypes.bool,
};
UnitButton.defaultProps = {
className: undefined,
showTitle: false,
isActive: false,
};
export default UnitButton;

218
src/course-unit/data/api.js Normal file
View File

@@ -0,0 +1,218 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { PUBLISH_TYPES } from '../constants';
import { isUnitReadOnly, normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils';
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getXBlockBaseApiUrl = (itemId) => `${getStudioBaseUrl()}/xblock/${itemId}`;
export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`;
export const getCourseOutlineInfoUrl = (courseId) => `${getStudioBaseUrl()}/course/${courseId}?format=concise`;
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
export const libraryBlockChangesUrl = (blockId) => `${getStudioBaseUrl()}/api/contentstore/v2/downstreams/${blockId}/sync`;
/**
* Edit course unit display name.
* @param {string} unitId
* @param {string} displayName
* @returns {Promise<Object>}
*/
export async function editUnitDisplayName(unitId, displayName) {
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(unitId), {
metadata: {
display_name: displayName,
},
});
return data;
}
/**
* Fetch vertical block data from the container_handler endpoint.
* @param {string} unitId
* @returns {Promise<Object>}
*/
export async function getVerticalData(unitId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseSectionVerticalApiUrl(unitId));
const courseSectionVerticalData = normalizeCourseSectionVerticalData(data);
courseSectionVerticalData.xblockInfo.readOnly = isUnitReadOnly(courseSectionVerticalData.xblockInfo);
return courseSectionVerticalData;
}
/**
* Creates a new course XBlock.
* @param {Object} options - The options for creating the XBlock.
* @param {string} options.type - The type of the XBlock.
* @param {string} [options.category] - The category of the XBlock. Defaults to the type if not provided.
* @param {string} options.parentLocator - The parent locator.
* @param {string} [options.displayName] - The display name.
* @param {string} [options.boilerplate] - The boilerplate.
* @param {string} [options.stagedContent] - The staged content.
* @param {string} [options.libraryContentKey] - component key from library if being imported.
*/
export async function createCourseXblock({
type,
category,
parentLocator,
displayName,
boilerplate,
stagedContent,
libraryContentKey,
}) {
const body = {
type,
boilerplate,
category: category || type,
parent_locator: parentLocator,
display_name: displayName,
staged_content: stagedContent,
library_content_key: libraryContentKey,
};
const { data } = await getAuthenticatedHttpClient()
.post(postXBlockBaseApiUrl(), body);
return data;
}
/**
* Handles the visibility and data of a course unit, such as publishing, resetting to default values,
* and toggling visibility to students.
* @param {string} unitId - The ID of the course unit.
* @param {string} type - The action type (e.g., PUBLISH_TYPES.discardChanges).
* @param {boolean} isVisible - The visibility status for students.
* @param {boolean} groupAccess - Access group key set.
* @param {boolean} isDiscussionEnabled - Indicates whether the discussion feature is enabled.
* @returns {Promise<any>} A promise that resolves with the response data.
*/
export async function handleCourseUnitVisibilityAndData(unitId, type, isVisible, groupAccess, isDiscussionEnabled) {
const body = {
publish: groupAccess ? null : type,
...(type === PUBLISH_TYPES.republish ? {
metadata: {
visible_to_staff_only: isVisible ? true : null,
group_access: groupAccess || null,
discussion_enabled: isDiscussionEnabled,
},
} : {}),
};
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(unitId), body);
return camelCaseObject(data);
}
/**
* Get an object containing course section vertical children data.
* @param {string} itemId
* @returns {Promise<Object>}
*/
export async function getCourseVerticalChildren(itemId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseVerticalChildrenApiUrl(itemId));
const camelCaseData = camelCaseObject(data);
return updateXBlockBlockIdToId(camelCaseData);
}
/**
* Delete a unit item.
* @param {string} itemId
* @returns {Promise<Object>}
*/
export async function deleteUnitItem(itemId) {
const { data } = await getAuthenticatedHttpClient()
.delete(getXBlockBaseApiUrl(itemId));
return data;
}
/**
* Duplicate a unit item.
* @param {string} itemId
* @param {string} XBlockId
* @returns {Promise<Object>}
*/
export async function duplicateUnitItem(itemId, XBlockId) {
const { data } = await getAuthenticatedHttpClient()
.post(postXBlockBaseApiUrl(), {
parent_locator: itemId,
duplicate_source_locator: XBlockId,
});
return data;
}
/**
* @typedef {Object} courseOutline
* @property {string} id - The unique identifier of the course.
* @property {string} displayName - The display name of the course.
* @property {string} category - The category of the course (e.g., "course").
* @property {boolean} hasChildren - Whether the course has child items.
* @property {boolean} unitLevelDiscussions - Indicates if unit-level discussions are available.
* @property {Object} childInfo - Information about the child elements of the course.
* @property {string} childInfo.category - The category of the child (e.g., "chapter").
* @property {string} childInfo.display_name - The display name of the child element.
* @property {Array<Object>} childInfo.children - List of children within the child_info (could be empty).
*/
/**
* Get an object containing course outline data.
* @param {string} courseId - The identifier of the course.
* @returns {Promise<courseOutline>} - The course outline data.
*/
export async function getCourseOutlineInfo(courseId) {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseOutlineInfoUrl(courseId));
return camelCaseObject(data);
}
/**
* @typedef {Object} moveInfo
* @property {string} moveSourceLocator - The locator of the source block being moved.
* @property {string} parentLocator - The locator of the parent block where the source is being moved to.
* @property {number} sourceIndex - The index position of the source block.
*/
/**
* Move a unit item to new unit.
* @param {string} sourceLocator - The ID of the item to be moved.
* @param {string} targetParentLocator - The ID of the XBlock associated with the item.
* @returns {Promise<moveInfo>} - The move information.
*/
export async function patchUnitItem(sourceLocator, targetParentLocator) {
const { data } = await getAuthenticatedHttpClient()
.patch(postXBlockBaseApiUrl(), {
parent_locator: targetParentLocator,
move_source_locator: sourceLocator,
});
return camelCaseObject(data);
}
/**
* Accept the changes from upstream library block in course
* @param {string} blockId - The ID of the item to be updated from library.
*/
export async function acceptLibraryBlockChanges(blockId) {
await getAuthenticatedHttpClient()
.post(libraryBlockChangesUrl(blockId));
}
/**
* Ignore the changes from upstream library block in course
* @param {string} blockId - The ID of the item to be updated from library.
*/
export async function ignoreLibraryBlockChanges(blockId) {
await getAuthenticatedHttpClient()
.delete(libraryBlockChangesUrl(blockId));
}

View File

@@ -1,188 +0,0 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { PUBLISH_TYPES } from '../constants';
import { CourseContainerChildrenData, CourseOutlineData, MoveInfoData } from './types';
import { isUnitImportedFromLib, normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils';
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getXBlockBaseApiUrl = (itemId: string) => `${getStudioBaseUrl()}/xblock/${itemId}`;
export const getCourseSectionVerticalApiUrl = (itemId: string) => `${getStudioBaseUrl()}/api/contentstore/v1/container_handler/${itemId}`;
export const getCourseVerticalChildrenApiUrl = (itemId: string, getUpstreamInfo: boolean = false) => `${getStudioBaseUrl()}/api/contentstore/v1/container/${itemId}/children?get_upstream_info=${getUpstreamInfo}`;
export const getCourseOutlineInfoUrl = (courseId: string) => `${getStudioBaseUrl()}/course/${courseId}?format=concise`;
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
export const libraryBlockChangesUrl = (blockId: string) => `${getStudioBaseUrl()}/api/contentstore/v2/downstreams/${blockId}/sync`;
/**
* Edit course unit display name.
*/
export async function editUnitDisplayName(unitId: string, displayName: string): Promise<object> {
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(unitId), {
metadata: {
display_name: displayName,
},
});
return data;
}
/**
* Fetch vertical block data from the container_handler endpoint.
*/
export async function getVerticalData(unitId: string): Promise<object> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseSectionVerticalApiUrl(unitId));
const courseSectionVerticalData = normalizeCourseSectionVerticalData(data);
courseSectionVerticalData.xblockInfo.readOnly = isUnitImportedFromLib(courseSectionVerticalData.xblockInfo);
return courseSectionVerticalData;
}
/**
* Creates a new course XBlock.
*/
export async function createCourseXblock({
type,
category,
parentLocator,
displayName,
boilerplate,
stagedContent,
libraryContentKey,
}: {
type: string,
category?: string, // The category of the XBlock. Defaults to the type if not provided.
parentLocator: string,
displayName?: string,
boilerplate?: string,
stagedContent?: string,
libraryContentKey?: string, // component key from library if being imported.
}) {
const body = {
type,
boilerplate,
category: category || type,
parent_locator: parentLocator,
display_name: displayName,
staged_content: stagedContent,
library_content_key: libraryContentKey,
};
const { data } = await getAuthenticatedHttpClient()
.post(postXBlockBaseApiUrl(), body);
return data;
}
/**
* Handles the visibility and data of a course unit, such as publishing, resetting to default values,
* and toggling visibility to students.
*/
export async function handleCourseUnitVisibilityAndData(
unitId: string,
type: string, // The action type (e.g., PUBLISH_TYPES.discardChanges).
isVisible: boolean, // The visibility status for students.
groupAccess: boolean,
isDiscussionEnabled: boolean,
): Promise<object> {
const body = {
publish: groupAccess ? null : type,
...(type === PUBLISH_TYPES.republish ? {
metadata: {
visible_to_staff_only: isVisible ? true : null,
group_access: groupAccess || null,
discussion_enabled: isDiscussionEnabled,
},
} : {}),
};
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(unitId), body);
return camelCaseObject(data);
}
/**
* Get an object containing course vertical children data.
*/
export async function getCourseContainerChildren(
itemId: string,
getUpstreamInfo: boolean = false,
): Promise<CourseContainerChildrenData> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseVerticalChildrenApiUrl(itemId, getUpstreamInfo));
const camelCaseData = camelCaseObject(data);
return updateXBlockBlockIdToId(camelCaseData) as CourseContainerChildrenData;
}
/**
* Delete a unit item.
*/
export async function deleteUnitItem(itemId: string): Promise<object> {
const { data } = await getAuthenticatedHttpClient()
.delete(getXBlockBaseApiUrl(itemId));
return data;
}
/**
* Duplicate a unit item.
*/
export async function duplicateUnitItem(itemId: string, XBlockId: string): Promise<object> {
const { data } = await getAuthenticatedHttpClient()
.post(postXBlockBaseApiUrl(), {
parent_locator: itemId,
duplicate_source_locator: XBlockId,
});
return data;
}
/**
* Get an object containing course outline data.
*/
export async function getCourseOutlineInfo(courseId: string): Promise<CourseOutlineData> {
const { data } = await getAuthenticatedHttpClient()
.get(getCourseOutlineInfoUrl(courseId));
return camelCaseObject(data);
}
/**
* Move a unit item to new unit.
*/
export async function patchUnitItem(sourceLocator: string, targetParentLocator: string): Promise<MoveInfoData> {
const { data } = await getAuthenticatedHttpClient()
.patch(postXBlockBaseApiUrl(), {
parent_locator: targetParentLocator,
move_source_locator: sourceLocator,
});
return camelCaseObject(data);
}
/**
* Accept the changes from upstream library block in course
*/
export async function acceptLibraryBlockChanges({
blockId,
overrideCustomizations = false,
}: {
blockId: string,
overrideCustomizations?: boolean,
}) {
await getAuthenticatedHttpClient()
.post(libraryBlockChangesUrl(blockId), { override_customizations: overrideCustomizations });
}
/**
* Ignore the changes from upstream library block in course
*/
export async function ignoreLibraryBlockChanges({ blockId } : { blockId: string }) {
await getAuthenticatedHttpClient()
.delete(libraryBlockChangesUrl(blockId));
}

View File

@@ -3,17 +3,17 @@ import { camelCaseObject } from '@edx/frontend-platform';
import {
hideProcessingNotification,
showProcessingNotification,
} from '@src/generic/processing-notification/data/slice';
import { handleResponseErrors } from '@src/generic/saving-error-alert';
import { RequestStatus } from '@src/data/constants';
import { NOTIFICATION_MESSAGES } from '@src/constants';
import { updateModel, updateModels } from '@src/generic/model-store';
} from '../../generic/processing-notification/data/slice';
import { handleResponseErrors } from '../../generic/saving-error-alert';
import { RequestStatus } from '../../data/constants';
import { NOTIFICATION_MESSAGES } from '../../constants';
import { updateModel, updateModels } from '../../generic/model-store';
import { messageTypes } from '../constants';
import {
editUnitDisplayName,
getVerticalData,
createCourseXblock,
getCourseContainerChildren,
getCourseVerticalChildren,
handleCourseUnitVisibilityAndData,
deleteUnitItem,
duplicateUnitItem,
@@ -126,7 +126,7 @@ export function editCourseUnitVisibilityAndData(
}
const courseSectionVerticalData = await getVerticalData(blockId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
const courseVerticalChildrenData = await getCourseContainerChildren(blockId);
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
@@ -163,7 +163,7 @@ export function createNewCourseXBlock(body, callback, blockId, sendMessageToIfra
localStorage.removeItem('staticFileNotices');
}
}
const courseVerticalChildrenData = await getCourseContainerChildren(blockId);
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
if (callback) {
@@ -190,11 +190,11 @@ export function fetchCourseVerticalChildrenData(itemId, isSplitTestType, skipPag
}
try {
const courseVerticalChildrenData = await getCourseContainerChildren(itemId);
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
if (isSplitTestType) {
const blockIds = courseVerticalChildrenData.children.map(child => child.blockId);
const childrenDataArray = await Promise.all(
blockIds.map(blockId => getCourseContainerChildren(blockId)),
blockIds.map(blockId => getCourseVerticalChildren(blockId)),
);
const allChildren = childrenDataArray.reduce(
(acc, data) => acc.concat(data.children || []),
@@ -239,7 +239,7 @@ export function duplicateUnitItemQuery(itemId, xblockId, callback) {
callback(courseKey, locator);
const courseSectionVerticalData = await getVerticalData(itemId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
const courseVerticalChildrenData = await getCourseContainerChildren(itemId);
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));

View File

@@ -1,55 +0,0 @@
import { UpstreamInfo, XBlock } from '@src/data/types';
import { ContainerType } from '@src/generic/key-utils';
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
export interface MoveInfoData {
/**
* The locator of the source block being moved.
*/
moveSourceLocator: string;
/**
* The locator of the parent block where the source is being moved to.
*/
parentLocator: string;
/**
* The index position of the source block.
*/
sourceIndex: number;
}
export interface CourseOutlineData {
id: string;
displayName: string;
category: string;
hasChildren: boolean;
unitLevelDiscussions: boolean;
childInfo: {
category: string;
displayName: string;
children: XBlock[];
}
}
export interface ContainerChildData {
blockId: string;
blockType: ContainerType | keyof typeof COMPONENT_TYPES;
id: string;
name: string;
upstreamLink: UpstreamInfo;
}
export interface UpstreamReadyToSyncChildrenInfo {
id: string;
name: string;
upstream: string;
blockType: string;
downstreamCustomized: string[];
}
export interface CourseContainerChildrenData {
canPasteComponent: boolean;
children: ContainerChildData[];
isPublished: boolean;
displayName: string;
upstreamReadyToSyncChildrenInfo: UpstreamReadyToSyncChildrenInfo[];
}

View File

@@ -102,7 +102,7 @@ export const updateXBlockBlockIdToId = (data: object): object => {
* @param unit - uses the 'upstreamInfo' object if found.
* @returns True if readOnly, False if editable.
*/
export const isUnitImportedFromLib = ({ upstreamInfo }: XBlock): boolean => (
export const isUnitReadOnly = ({ upstreamInfo }: XBlock): boolean => (
!!upstreamInfo
&& !!upstreamInfo.upstreamRef
&& upstreamInfo.upstreamRef.startsWith('lct:')

View File

@@ -34,6 +34,8 @@ const HeaderTitle = ({
COURSE_BLOCK_NAMES.component.id,
].includes(currentItemData.category);
const readOnly = !!currentItemData.readOnly;
const onConfigureSubmit = (...arg) => {
handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal);
};
@@ -80,6 +82,7 @@ const HeaderTitle = ({
className="ml-1 flex-shrink-0"
iconAs={EditIcon}
onClick={handleTitleEdit}
disabled={readOnly}
/>
<IconButton
alt={intl.formatMessage(messages.altButtonSettings)}

View File

@@ -76,7 +76,7 @@ describe('<HeaderTitle />', () => {
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});
it('Units sourced from upstream show a enabled edit button', async () => {
it('Units sourced from upstream show a disabled edit button', async () => {
// Override mock unit with one sourced from an upstream library
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock
@@ -95,7 +95,7 @@ describe('<HeaderTitle />', () => {
const { getByRole } = renderComponent();
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled();
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeDisabled();
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});

View File

@@ -24,7 +24,6 @@ import {
fetchCourseVerticalChildrenData,
getCourseOutlineInfoQuery,
patchUnitItemQuery,
updateCourseUnitSidebar,
} from './data/thunk';
import {
getCanEdit,
@@ -73,10 +72,6 @@ export const useCourseUnit = ({ courseId, blockId }) => {
const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id;
const isUnitLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id;
const isSplitTestType = unitCategory === COURSE_BLOCK_NAMES.splitTest.id;
const isProblemBankType = [
COURSE_BLOCK_NAMES.legacyLibraryContent.id,
COURSE_BLOCK_NAMES.itembank.id,
].includes(unitCategory);
const headerNavigationsActions = {
handleViewLive: () => {
@@ -232,7 +227,8 @@ export const useCourseUnit = ({ courseId, blockId }) => {
// edits the component using editor which has a separate store
/* istanbul ignore next */
if (event.key === 'courseRefreshTriggerOnComponentEditSave') {
dispatch(updateCourseUnitSidebar(blockId));
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
localStorage.removeItem(event.key);
}
};
@@ -258,7 +254,6 @@ export const useCourseUnit = ({ courseId, blockId }) => {
isUnitVerticalType,
isUnitLibraryType,
isSplitTestType,
isProblemBankType,
sharedClipboardData,
showPasteXBlock,
showPasteUnit,

View File

@@ -1,10 +1,4 @@
.lib-preview-xblock-changes-modal {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
.preview-title {
span {
margin: 0 10px;
}
}
}

View File

@@ -1,32 +1,29 @@
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types';
import {
act,
render as baseRender,
screen,
initializeMocks,
waitFor,
} from '@src/testUtils';
import { ToastActionData } from '@src/generic/toast-context';
} from '../../testUtils';
import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.';
import { messageTypes } from '../constants';
import { libraryBlockChangesUrl } from '../data/api';
import { ToastActionData } from '../../generic/toast-context';
const usageKey = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@1';
const usageKey = 'some-id';
const defaultEventData: LibraryChangesMessageData = {
displayName: 'Test block',
downstreamBlockId: usageKey,
upstreamBlockId: 'lct:org:lib1:unit:1',
upstreamBlockVersionSynced: 1,
isContainer: false,
isLocallyModified: false,
blockType: 'html',
};
const mockSendMessageToIframe = jest.fn();
jest.mock('@src/generic/hooks/context/hooks', () => ({
jest.mock('../../generic/hooks/context/hooks', () => ({
useIframe: () => ({
iframeRef: { current: { contentWindow: {} as HTMLIFrameElement } },
setIframeRef: () => {},
@@ -63,6 +60,7 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Accept changes' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Ignore changes' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'New version' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Old version' })).toBeInTheDocument();
});
@@ -134,59 +132,4 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
});
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
});
it('should render modal of text with local changes', async () => {
render({ ...defaultEventData, isLocallyModified: true });
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
expect(screen.getByText('This library content has local edits.')).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Update to published library content' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Keep course content' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Course content' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Published library content' })).toBeInTheDocument();
});
it('update changes works', async () => {
const user = userEvent.setup();
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
render({ ...defaultEventData, isLocallyModified: true });
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
const acceptBtn = await screen.findByRole('button', { name: 'Update to published library content' });
await user.click(acceptBtn);
const confirmBtn = await screen.findByRole('button', { name: 'Discard local edits and update' });
await user.click(confirmBtn);
await waitFor(() => {
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
messageTypes.completeXBlockEditing,
{ locator: usageKey },
);
expect(axiosMock.history.post.length).toEqual(1);
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
});
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
});
it('keep changes work', async () => {
const user = userEvent.setup();
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(200, {});
render({ ...defaultEventData, isLocallyModified: true });
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
const ignoreBtn = await screen.findByRole('button', { name: 'Keep course content' });
await user.click(ignoreBtn);
const ignoreConfirmBtn = (await screen.findAllByRole('button', { name: 'Keep course content' }))[0];
await user.click(ignoreConfirmBtn);
await waitFor(() => {
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
messageTypes.completeXBlockEditing,
{ locator: usageKey },
);
expect(axiosMock.history.delete.length).toEqual(1);
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
});
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
});
});

View File

@@ -1,111 +1,28 @@
import { useCallback, useContext, useState } from 'react';
import {
useCallback, useContext, useMemo, useState,
} from 'react';
import {
ActionRow, Button, Icon, ModalDialog, useToggle,
ActionRow, Button, ModalDialog, useToggle,
} from '@openedx/paragon';
import { Info } from '@openedx/paragon/icons';
import { Warning } from '@openedx/paragon/icons';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { ToastContext } from '@src/generic/toast-context';
import Loading from '@src/generic/Loading';
import CompareChangesWidget from '@src/library-authoring/component-comparison/CompareChangesWidget';
import AlertMessage from '@src/generic/alert-message';
import LoadingButton from '@src/generic/loading-button';
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
import { useIframe } from '@src/generic/hooks/context/hooks';
import { useEventListener } from '@src/generic/hooks';
import { getItemIcon } from '@src/generic/block-type-utils';
import { CompareContainersWidget } from '@src/container-comparison/CompareContainersWidget';
import { useEventListener } from '../../generic/hooks';
import { messageTypes } from '../constants';
import CompareChangesWidget from '../../library-authoring/component-comparison/CompareChangesWidget';
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks';
import AlertMessage from '../../generic/alert-message';
import { useIframe } from '../../generic/hooks/context/hooks';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import messages from './messages';
type ConfirmationModalType = 'ignore' | 'update' | 'keep' | undefined;
const ConfirmationModal = ({
modalType,
onClose,
updateAndRefresh,
}: {
modalType: ConfirmationModalType,
onClose: () => void,
updateAndRefresh: (accept: boolean, overrideCustomizations: boolean) => void,
}) => {
const intl = useIntl();
const {
title,
description,
btnLabel,
btnVariant,
accept,
overrideCustomizations,
} = useMemo(() => {
let resultTitle: string | undefined;
let resultDescription: string | undefined;
let resutlBtnLabel: string | undefined;
let resultAccept: boolean = false;
let resultOverrideCustomizations: boolean = false;
let resultBtnVariant: 'danger' | 'primary' = 'danger';
switch (modalType) {
case 'ignore':
resultTitle = intl.formatMessage(messages.confirmationTitle);
resultDescription = intl.formatMessage(messages.confirmationDescription);
resutlBtnLabel = intl.formatMessage(messages.confirmationConfirmBtn);
break;
case 'update':
resultTitle = intl.formatMessage(messages.updateToPublishedLibraryContentTitle);
resultDescription = intl.formatMessage(messages.updateToPublishedLibraryContentBody);
resutlBtnLabel = intl.formatMessage(messages.updateToPublishedLibraryContentConfirm);
resultAccept = true;
resultOverrideCustomizations = true;
break;
case 'keep':
resultTitle = intl.formatMessage(messages.keepCourseContentTitle);
resultDescription = intl.formatMessage(messages.keepCourseContentBody);
resutlBtnLabel = intl.formatMessage(messages.keepCourseContentButton);
resultBtnVariant = 'primary';
break;
default:
break;
}
return {
title: resultTitle,
description: resultDescription,
btnLabel: resutlBtnLabel,
accept: resultAccept,
btnVariant: resultBtnVariant,
overrideCustomizations: resultOverrideCustomizations,
};
}, [modalType]);
return (
<DeleteModal
isOpen={modalType !== undefined}
close={onClose}
variant="warning"
title={title}
description={description}
onDeleteSubmit={() => updateAndRefresh(accept, overrideCustomizations)}
btnLabel={btnLabel}
buttonVariant={btnVariant}
/>
);
};
import { ToastContext } from '../../generic/toast-context';
import LoadingButton from '../../generic/loading-button';
import Loading from '../../generic/Loading';
export interface LibraryChangesMessageData {
displayName: string,
downstreamBlockId: string,
upstreamBlockId: string,
upstreamBlockVersionSynced: number,
isLocallyModified?: boolean,
isContainer: boolean,
blockType?: string | null,
isReadyToSyncIndividually?: boolean,
}
export interface PreviewLibraryXBlockChangesProps {
@@ -128,41 +45,27 @@ export const PreviewLibraryXBlockChanges = ({
const { showToast } = useContext(ToastContext);
const intl = useIntl();
const [confirmationModalType, setConfirmationModalType] = useState<ConfirmationModalType>();
// ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const isTextWithLocalChanges = (blockData.blockType === 'html' && blockData.isLocallyModified);
const getBody = useCallback(() => {
if (!blockData) {
return <Loading />;
}
if (blockData.isContainer) {
return (
<CompareContainersWidget
upstreamBlockId={blockData.upstreamBlockId}
downstreamBlockId={blockData.downstreamBlockId}
isReadyToSyncIndividually={blockData.isReadyToSyncIndividually}
/>
);
}
return (
<CompareChangesWidget
usageKey={blockData.upstreamBlockId}
oldUsageKey={blockData.downstreamBlockId}
oldTitle={isTextWithLocalChanges ? blockData.displayName : undefined}
oldVersion={blockData.upstreamBlockVersionSynced || 'published'}
newVersion="published"
hasLocalChanges={isTextWithLocalChanges}
showNewTitle={isTextWithLocalChanges}
isContainer={blockData.isContainer}
/>
);
}, [blockData, isTextWithLocalChanges]);
}, [blockData]);
const updateAndRefresh = useCallback(async (accept: boolean, overrideCustomizations: boolean) => {
const updateAndRefresh = useCallback(async (accept: boolean) => {
// istanbul ignore if: this should never happen
if (!blockData) {
return;
@@ -172,10 +75,7 @@ export const PreviewLibraryXBlockChanges = ({
const failureMsg = accept ? messages.acceptChangesFailure : messages.ignoreChangesFailure;
try {
await mutation.mutateAsync({
blockId: blockData.downstreamBlockId,
overrideCustomizations,
});
await mutation.mutateAsync(blockData.downstreamBlockId);
postChange(accept);
} catch (e) {
showToast(intl.formatMessage(failureMsg));
@@ -184,46 +84,21 @@ export const PreviewLibraryXBlockChanges = ({
}
}, [blockData]);
const itemIcon = getItemIcon(blockData.blockType || '');
// Build title
const defaultTitle = intl.formatMessage(
blockData.isContainer
? messages.defaultContainerTitle
: messages.defaultComponentTitle,
{
itemIcon: <Icon size="lg" src={itemIcon} />,
},
);
const title = blockData.displayName
? intl.formatMessage(messages.title, {
blockTitle: blockData?.displayName,
blockIcon: <Icon size="lg" src={itemIcon} />,
})
? intl.formatMessage(messages.title, { blockTitle: blockData?.displayName })
: defaultTitle;
// Build aria label
const defaultAriaLabel = intl.formatMessage(
blockData.isContainer
? messages.defaultContainerTitle
: messages.defaultComponentTitle,
{
itemIcon: '',
},
);
const ariaLabel = blockData.displayName
? intl.formatMessage(messages.title, {
blockTitle: blockData?.displayName,
blockIcon: '',
})
: defaultAriaLabel;
return (
<ModalDialog
isOpen={isModalOpen}
onClose={closeModal}
size="xl"
title={ariaLabel}
title={title}
className="lib-preview-xblock-changes-modal"
hasCloseButton
isFullscreenOnMobile
@@ -231,57 +106,43 @@ export const PreviewLibraryXBlockChanges = ({
>
<ModalDialog.Header>
<ModalDialog.Title>
<div className="d-flex preview-title">
{title}
</div>
{title}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{isTextWithLocalChanges && (
<AlertMessage
show
variant="info"
icon={Info}
title={intl.formatMessage(messages.localEditsAlert)}
/>
)}
<AlertMessage
show
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
/>
{getBody()}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
{isTextWithLocalChanges ? (
<Button
variant="tertiary"
onClick={() => setConfirmationModalType('update')}
>
<FormattedMessage {...messages.updateToPublishedLibraryContentButton} />
</Button>
) : (
<LoadingButton
onClick={() => updateAndRefresh(true, false)}
label={intl.formatMessage(messages.acceptChangesBtn)}
/>
)}
{isTextWithLocalChanges ? (
<Button
onClick={() => setConfirmationModalType('keep')}
>
<FormattedMessage {...messages.keepCourseContentButton} />
</Button>
) : (
<Button
variant="tertiary"
onClick={() => setConfirmationModalType('ignore')}
>
<FormattedMessage {...messages.ignoreChangesBtn} />
</Button>
)}
<LoadingButton
onClick={() => updateAndRefresh(true)}
label={intl.formatMessage(messages.acceptChangesBtn)}
/>
<Button
variant="tertiary"
onClick={openConfirmModal}
>
<FormattedMessage {...messages.ignoreChangesBtn} />
</Button>
<ModalDialog.CloseButton variant="tertiary">
<FormattedMessage {...messages.cancelBtn} />
</ModalDialog.CloseButton>
</ActionRow>
</ModalDialog.Footer>
<ConfirmationModal
modalType={confirmationModalType}
onClose={() => setConfirmationModalType(undefined)}
updateAndRefresh={updateAndRefresh}
<DeleteModal
isOpen={isConfirmModalOpen}
close={closeConfirmModal}
variant="warning"
title={intl.formatMessage(messages.confirmationTitle)}
description={intl.formatMessage(messages.confirmationDescription)}
onDeleteSubmit={() => updateAndRefresh(false)}
btnLabel={intl.formatMessage(messages.confirmationConfirmBtn)}
/>
</ModalDialog>
);

View File

@@ -3,17 +3,17 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title: {
id: 'authoring.course-unit.preview-changes.modal-title',
defaultMessage: 'Preview changes: {blockIcon} {blockTitle}',
defaultMessage: 'Preview changes: {blockTitle}',
description: 'Preview changes modal title text',
},
defaultContainerTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-unit-title',
defaultMessage: 'Preview changes: {itemIcon} Container',
defaultMessage: 'Preview changes: Container',
description: 'Preview changes modal default title text for containers',
},
defaultComponentTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-component-title',
defaultMessage: 'Preview changes: {itemIcon} Component',
defaultMessage: 'Preview changes: Component',
description: 'Preview changes modal default title text for components',
},
acceptChangesBtn: {
@@ -36,6 +36,11 @@ const messages = defineMessages({
defaultMessage: 'Failed to ignore changes',
description: 'Toast message to display when ignore changes call fails',
},
cancelBtn: {
id: 'authoring.course-unit.preview-changes.cancel-btn',
defaultMessage: 'Cancel',
description: 'Preview changes modal cancel button text.',
},
confirmationTitle: {
id: 'authoring.course-unit.preview-changes.confirmation-dialog-title',
defaultMessage: 'Ignore these changes?',
@@ -51,45 +56,10 @@ const messages = defineMessages({
defaultMessage: 'Ignore',
description: 'Preview changes confirmation dialog confirm button text when user clicks on ignore changes.',
},
localEditsAlert: {
id: 'course-authoring.review-tab.preview.loal-edits-alert',
defaultMessage: 'This library content has local edits.',
description: 'Alert message stating that the content has local edits',
},
updateToPublishedLibraryContentButton: {
id: 'course-authoring.review-tab.preview.update-to-published.button.text',
defaultMessage: 'Update to published library content',
description: 'Label of the button to update a content to the published library content',
},
updateToPublishedLibraryContentTitle: {
id: 'course-authoring.review-tab.preview.update-to-published.modal.title',
defaultMessage: 'Update to published library content?',
description: 'Title of the modal to update a content to the published library content',
},
updateToPublishedLibraryContentBody: {
id: 'course-authoring.review-tab.preview.update-to-published.modal.body',
defaultMessage: 'Updating this block will discard local changes. Any edits made within this course will be discarded, and cannot be recovered',
description: 'Body of the modal to update a content to the published library content',
},
updateToPublishedLibraryContentConfirm: {
id: 'course-authoring.review-tab.preview.update-to-published.modal.confirm',
defaultMessage: 'Discard local edits and update',
description: 'Label of the button in the modal to update a content to the published library content',
},
keepCourseContentButton: {
id: 'course-authoring.review-tab.preview.keep-course-content.button.text',
defaultMessage: 'Keep course content',
description: 'Label of the button to keep the content of a course component',
},
keepCourseContentTitle: {
id: 'course-authoring.review-tab.preview.keep-course-content.modal.title',
defaultMessage: 'Keep course content?',
description: 'Title of the modal to keep the content of a course component',
},
keepCourseContentBody: {
id: 'course-authoring.review-tab.preview.keep-course-content.modal.body',
defaultMessage: 'This will keep the locally edited course content. If the component is published again in its library, you can choose to update to published library content',
description: 'Body of the modal to keep the content of a course component',
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',
},
});

View File

@@ -15,7 +15,6 @@ export type UseMessageHandlersTypes = {
handleOpenManageTagsModal: (id: string) => void;
handleShowProcessingNotification: (variant: string) => void;
handleHideProcessingNotification: () => void;
handleRefreshIframe: () => void;
};
export type MessageHandlersTypes = Record<string, (payload: any) => void>;

View File

@@ -31,7 +31,6 @@ export const useMessageHandlers = ({
handleShowProcessingNotification,
handleHideProcessingNotification,
handleEditXBlock,
handleRefreshIframe,
}: UseMessageHandlersTypes): MessageHandlersTypes => {
const { copyToClipboard } = useClipboard();
@@ -51,7 +50,6 @@ export const useMessageHandlers = ({
[messageTypes.saveEditedXBlockData]: handleSaveEditedXBlockData,
[messageTypes.studioAjaxError]: ({ error }) => handleResponseErrors(error, dispatch, updateSavingStatus),
[messageTypes.refreshPositions]: handleFinishXBlockDragging,
[messageTypes.refreshIframe]: handleRefreshIframe,
[messageTypes.openManageTags]: (payload) => handleOpenManageTagsModal(payload.contentId),
[messageTypes.addNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.adding),
[messageTypes.pasteNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.pasting),

View File

@@ -46,8 +46,6 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
const intl = useIntl();
const dispatch = useDispatch();
// Useful to reload iframe
const [iframeKey, setIframeKey] = useState(0);
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false);
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
@@ -184,12 +182,6 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
dispatch(hideProcessingNotification());
};
const handleRefreshIframe = () => {
// Updating iframeKey forces the iframe to re-render.
/* istanbul ignore next */
setIframeKey((prev) => prev + 1);
};
const messageHandlers = useMessageHandlers({
courseId,
dispatch,
@@ -207,7 +199,6 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
handleShowProcessingNotification,
handleHideProcessingNotification,
handleEditXBlock,
handleRefreshIframe,
});
useIframeMessages(messageHandlers);
@@ -277,7 +268,6 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
/>
) : null}
<iframe
key={iframeKey}
ref={iframeRef}
title={intl.formatMessage(messages.xblockIframeTitle)}
name="xblock-iframe"

View File

@@ -100,7 +100,7 @@ describe('CustomPages', () => {
it('should update page order on drag', async () => {
renderComponent();
await mockStore(RequestStatus.SUCCESSFUL);
const buttons = screen.queryAllByRole('button');
const buttons = await screen.queryAllByRole('button');
const draggableButton = buttons[9];
expect(draggableButton).toBeVisible();
await act(async () => {

View File

@@ -32,7 +32,6 @@ export async function getCourseDetail(courseId: string, username: string) {
*/
export const waffleFlagDefaults = {
enableCourseOptimizer: false,
enableCourseOptimizerCheckPrevRunLinks: false,
useNewHomePage: true,
useNewCustomPages: true,
useNewScheduleDetailsPage: true,

View File

@@ -8,7 +8,7 @@ import { getWaffleFlags, waffleFlagDefaults } from './api';
export const useWaffleFlags = (courseId?: string) => {
const queryClient = useQueryClient();
const { data, isPending: isLoading, isError } = useQuery({
const { data, isLoading, isError } = useQuery({
queryKey: ['waffleFlags', courseId],
queryFn: () => getWaffleFlags(courseId),
// Waffle flags change rarely, so never bother refetching them:

View File

@@ -48,23 +48,11 @@ export interface XBlockPrereqs {
blockDisplayName: string;
}
export interface UpstreamChildrenInfo {
name: string;
upstream: string;
id: string;
}
export interface UpstreamInfo {
readyToSync: boolean,
upstreamRef: string,
versionSynced: number,
versionAvailable: number | null,
versionDeclined: number | null,
errorMessage: string | null,
downstreamCustomized: string[],
hasTopLevelParent?: boolean,
readyToSyncChildren?: UpstreamChildrenInfo[],
isReadyToSyncIndividually?: boolean,
}
export interface XBlock {

View File

@@ -18,7 +18,6 @@ import { FeedbackBox } from './components/Feedback';
import * as hooks from './hooks';
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
import ExpandableTextArea from '../../../../../sharedComponents/ExpandableTextArea';
import { answerRangeFormatRegex } from '../../../data/OLXParser';
const AnswerOption = ({
answer,
@@ -49,11 +48,6 @@ const AnswerOption = ({
? `${getConfig().STUDIO_BASE_URL}/library_assets/blocks/${blockId}/`
: undefined;
const validateAnswerRange = (value) => {
const cleanedValue = value.replace(/^\s+|\s+$/g, '');
return !cleanedValue.length || answerRangeFormatRegex.test(cleanedValue);
};
const getInputArea = () => {
if ([ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType)) {
return (
@@ -83,9 +77,8 @@ const AnswerOption = ({
);
}
// Return Answer Range View
const isValidValue = validateAnswerRange(answer.title);
return (
<Form.Group isInvalid={!isValidValue}>
<div>
<Form.Control
as="textarea"
className="answer-option-textarea text-gray-500 small"
@@ -95,15 +88,10 @@ const AnswerOption = ({
onChange={setAnswerTitle}
placeholder={intl.formatMessage(messages.answerRangeTextboxPlaceholder)}
/>
{!isValidValue && (
<Form.Control.Feedback type="invalid">
<FormattedMessage {...messages.answerRangeErrorText} />
</Form.Control.Feedback>
)}
<div className="pgn__form-switch-helper-text">
<FormattedMessage {...messages.answerRangeHelperText} />
</div>
</Form.Group>
</div>
);
};

View File

@@ -77,11 +77,6 @@ const messages = defineMessages({
defaultMessage: 'Enter min and max values separated by a comma. Use a bracket to include the number next to it in the range, or a parenthesis to exclude the number. For example, to identify the correct answers as 5, 6, or 7, but not 8, specify [5,8).',
description: 'Helper text describing usage of answer ranges',
},
answerRangeErrorText: {
id: 'authoring.answerwidget.answer.answerRangeErrorText',
defaultMessage: 'Error: Invalid range format. Use brackets or parentheses with values separated by a comma.',
description: 'Error text describing wrong format of answer ranges',
},
});
export default messages;

View File

@@ -1,31 +0,0 @@
import React from 'react';
type ProblemEditorRef = React.MutableRefObject<unknown> | React.RefObject<unknown> | null;
export interface ProblemEditorContextValue {
editorRef: ProblemEditorRef;
}
export type ProblemEditorContextInit = {
editorRef?: ProblemEditorRef;
};
const context = React.createContext<ProblemEditorContextValue | undefined>(undefined);
export function useProblemEditorContext() {
const ctx = React.useContext(context);
if (ctx === undefined) {
/* istanbul ignore next */
throw new Error('This component needs to be wrapped in <ProblemEditorContextProvider>');
}
return ctx;
}
export const ProblemEditorContextProvider: React.FC<{ children: React.ReactNode; } & ProblemEditorContextInit> = ({
children,
editorRef = null,
}) => {
const ctx: ProblemEditorContextValue = React.useMemo(() => ({ editorRef }), [editorRef]);
return <context.Provider value={ctx}>{children}</context.Provider>;
};

View File

@@ -45,7 +45,6 @@ const SettingsWidget = ({
isMarkdownEditorEnabledForContext,
} = useEditorContext();
const rawMarkdown = useSelector(selectors.problem.rawMarkdown);
const isMarkdownEditorEnabled = useSelector(selectors.problem.isMarkdownEditorEnabled);
const showMarkdownEditorButton = isMarkdownEditorEnabledForContext && rawMarkdown;
const { isAdvancedCardsVisible, showAdvancedCards } = showAdvancedSettingsCards();
const feedbackCard = () => {
@@ -162,7 +161,7 @@ const SettingsWidget = ({
<div className="my-3">
<SwitchEditorCard problemType={problemType} editorType="advanced" />
</div>
{ (showMarkdownEditorButton && !isMarkdownEditorEnabled) // Only show button if not already in markdown editor
{ showMarkdownEditorButton
&& (
<div className="my-3">
<SwitchEditorCard problemType={problemType} editorType="markdown" />

View File

@@ -2,11 +2,10 @@ import React from 'react';
import { ProblemTypeKeys } from '@src/editors/data/constants/problem';
import { screen, initializeMocks } from '@src/testUtils';
import { editorRender, type PartialEditorState } from '@src/editors/editorTestRender';
import { editorRender } from '@src/editors/editorTestRender';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import * as hooks from './hooks';
import { SettingsWidgetInternal as SettingsWidget } from '.';
import { ProblemEditorContextProvider } from '../ProblemEditorContext';
jest.mock('./settingsComponents/GeneralFeedback', () => 'GeneralFeedback');
jest.mock('./settingsComponents/GroupFeedback', () => 'GroupFeedback');
@@ -24,6 +23,7 @@ describe('SettingsWidget', () => {
const showAdvancedSettingsCardsBaseProps = {
isAdvancedCardsVisible: false,
showAdvancedCards: jest.fn().mockName('showAdvancedSettingsCards.showAdvancedCards'),
setResetTrue: jest.fn().mockName('showAdvancedSettingsCards.setResetTrue'),
};
const props = {
@@ -49,18 +49,6 @@ describe('SettingsWidget', () => {
};
const editorRef = { current: null };
const renderSettingsWidget = (
overrideProps = {},
options = {},
) => editorRender(
<ProblemEditorContextProvider editorRef={editorRef}>
<SettingsWidget {...props} {...overrideProps} />
</ProblemEditorContextProvider>,
options,
);
beforeEach(() => {
initializeMocks();
});
@@ -68,7 +56,7 @@ describe('SettingsWidget', () => {
describe('behavior', () => {
it('calls showAdvancedSettingsCards when initialized', () => {
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps);
renderSettingsWidget();
editorRender(<SettingsWidget {...props} />);
expect(hooks.showAdvancedSettingsCards).toHaveBeenCalled();
});
});
@@ -76,7 +64,7 @@ describe('SettingsWidget', () => {
describe('renders', () => {
test('renders Settings widget page', () => {
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps);
renderSettingsWidget();
editorRender(<SettingsWidget {...props} />);
expect(screen.getByText('Show advanced settings')).toBeInTheDocument();
});
@@ -86,7 +74,7 @@ describe('SettingsWidget', () => {
isAdvancedCardsVisible: true,
};
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
const { container } = renderSettingsWidget();
const { container } = editorRender(<SettingsWidget {...props} />);
expect(screen.queryByText('Show advanced settings')).not.toBeInTheDocument();
expect(container.querySelector('showanswercard')).toBeInTheDocument();
expect(container.querySelector('resetcard')).toBeInTheDocument();
@@ -98,49 +86,12 @@ describe('SettingsWidget', () => {
isAdvancedCardsVisible: true,
};
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
const { container } = renderSettingsWidget({ problemType: ProblemTypeKeys.ADVANCED });
const { container } = editorRender(
<SettingsWidget {...props} problemType={ProblemTypeKeys.ADVANCED} />,
);
expect(container.querySelector('randomization')).toBeInTheDocument();
});
});
describe('SwitchEditorCard rendering (markdown vs advanced)', () => {
test('shows two SwitchEditorCard components when markdown is available and not currently enabled', () => {
const showAdvancedSettingsCardsProps = {
...showAdvancedSettingsCardsBaseProps,
isAdvancedCardsVisible: true,
};
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
const modifiedInitialState: PartialEditorState = {
problem: {
problemType: null, // non-advanced problem
isMarkdownEditorEnabled: false, // currently in advanced/raw (or standard) editor
rawOLX: '<problem></problem>',
rawMarkdown: '## Problem', // markdown content exists so button should appear
isDirty: false,
},
};
const { container } = renderSettingsWidget({}, { initialState: modifiedInitialState });
expect(container.querySelectorAll('switcheditorcard')).toHaveLength(2);
});
test('shows only the advanced SwitchEditorCard when already in markdown mode', () => {
const showAdvancedSettingsCardsProps = {
...showAdvancedSettingsCardsBaseProps,
isAdvancedCardsVisible: true,
};
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
const modifiedInitialState: PartialEditorState = {
problem: {
problemType: null,
isMarkdownEditorEnabled: true, // already in markdown editor, so markdown button hidden
rawOLX: '<problem></problem>',
rawMarkdown: '## Problem',
isDirty: false,
},
};
const { container } = renderSettingsWidget({}, { initialState: modifiedInitialState });
expect(container.querySelectorAll('switcheditorcard')).toHaveLength(1);
});
});
describe('isLibrary', () => {
const libraryProps = {
@@ -149,7 +100,7 @@ describe('SettingsWidget', () => {
};
test('renders Settings widget page', () => {
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps);
const { container } = renderSettingsWidget(libraryProps);
const { container } = editorRender(<SettingsWidget {...libraryProps} />);
expect(container.querySelector('timercard')).not.toBeInTheDocument();
expect(container.querySelector('resetcard')).not.toBeInTheDocument();
expect(container.querySelector('typecard')).toBeInTheDocument();
@@ -163,7 +114,7 @@ describe('SettingsWidget', () => {
isAdvancedCardsVisible: true,
};
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
const { container } = renderSettingsWidget(libraryProps);
const { container } = editorRender(<SettingsWidget {...libraryProps} />);
expect(screen.queryByText('Show advanced settings')).not.toBeInTheDocument();
expect(container.querySelector('showanswearscard')).not.toBeInTheDocument();
expect(container.querySelector('resetcard')).not.toBeInTheDocument();
@@ -177,7 +128,7 @@ describe('SettingsWidget', () => {
isAdvancedCardsVisible: true,
};
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
const { container } = renderSettingsWidget({ ...libraryProps, problemType: ProblemTypeKeys.ADVANCED });
const { container } = editorRender(<SettingsWidget {...libraryProps} problemType={ProblemTypeKeys.ADVANCED} />);
expect(container.querySelector('randomization')).toBeInTheDocument();
});
});

View File

@@ -169,13 +169,13 @@ const messages = defineMessages({
},
'ConfirmSwitchMessage-advanced': {
id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchMessage.advanced',
defaultMessage: 'If you use the advanced editor, this problem will be converted to OLX. Depending on what edits you make to the OLX, you may not be able to return to the simple editor.',
defaultMessage: 'If you use the advanced editor, this problem will be converted to OLX and you will not be able to return to the simple editor.',
description: 'message to confirm that a user wants to use the advanced editor',
},
'ConfirmSwitchMessage-markdown': {
id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchMessage.markdown',
defaultMessage: 'Some edits that are possible with the markdown editor are not supported by the simple editor, so you may not be able to change back to the simple editor.',
description: 'message to confirm that a user wants to use the markdown editor',
defaultMessage: 'If you use the markdown editor, this problem will be converted to markdown and you will not be able to return to the simple editor.',
description: 'message to confirm that a user wants to use the advanced editor',
},
'ConfirmSwitchMessageTitle-advanced': {
id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchMessageTitle.advanced',

View File

@@ -1,24 +1,27 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Card } from '@openedx/paragon';
import PropTypes from 'prop-types';
import { thunkActions } from '@src/editors/data/redux';
import { useEditorContext } from '@src/editors/EditorContext';
import { selectors, thunkActions } from '@src/editors/data/redux';
import BaseModal from '@src/editors/sharedComponents/BaseModal';
import Button from '@src/editors/sharedComponents/Button';
import { ProblemTypeKeys } from '@src/editors/data/constants/problem';
import messages from '../messages';
import { handleConfirmEditorSwitch } from '../hooks';
import { useProblemEditorContext } from '../../ProblemEditorContext';
const SwitchEditorCard = ({
editorType,
problemType,
}) => {
const [isConfirmOpen, setConfirmOpen] = React.useState(false);
const { isMarkdownEditorEnabledForContext } = useEditorContext();
const isMarkdownEditorEnabled = useSelector(selectors.problem.isMarkdownEditorEnabled);
const dispatch = useDispatch();
const { editorRef } = useProblemEditorContext();
if (problemType === ProblemTypeKeys.ADVANCED) { return null; }
const isMarkdownEditorActive = isMarkdownEditorEnabled && isMarkdownEditorEnabledForContext;
if (isMarkdownEditorActive || problemType === ProblemTypeKeys.ADVANCED) { return null; }
return (
<Card className="border border-light-700 shadow-none">
@@ -30,7 +33,7 @@ const SwitchEditorCard = ({
<Button
/* istanbul ignore next */
onClick={() => handleConfirmEditorSwitch({
switchEditor: () => dispatch(thunkActions.problem.switchEditor(editorType, editorRef)),
switchEditor: () => dispatch(thunkActions.problem.switchEditor(editorType)),
setConfirmOpen,
})}
variant="primary"

View File

@@ -4,7 +4,6 @@ import userEvent from '@testing-library/user-event';
import { editorRender } from '@src/editors/editorTestRender';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import { thunkActions } from '@src/editors/data/redux';
import { ProblemEditorContextProvider } from '../../ProblemEditorContext';
import SwitchEditorCard from './SwitchEditorCard';
const switchEditorSpy = jest.spyOn(thunkActions.problem, 'switchEditor');
@@ -14,13 +13,6 @@ describe('SwitchEditorCard - markdown', () => {
problemType: 'stringresponse',
editorType: 'markdown',
};
const editorRef = { current: null };
const renderSwitchEditorCard = (overrideProps = {}) => editorRender(
<ProblemEditorContextProvider editorRef={editorRef}>
<SwitchEditorCard {...baseProps} {...overrideProps} />
</ProblemEditorContextProvider>,
);
beforeEach(() => {
initializeMocks();
@@ -31,7 +23,7 @@ describe('SwitchEditorCard - markdown', () => {
mockWaffleFlags({ useReactMarkdownEditor: true });
// The markdown editor is not currently active (default)
renderSwitchEditorCard();
editorRender(<SwitchEditorCard {...baseProps} />);
const user = userEvent.setup();
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
const switchButton = screen.getByRole('button', { name: 'Switch to markdown editor' });
@@ -46,7 +38,7 @@ describe('SwitchEditorCard - markdown', () => {
mockWaffleFlags({ useReactMarkdownEditor: true });
// The markdown editor is not currently active (default)
renderSwitchEditorCard();
editorRender(<SwitchEditorCard {...baseProps} />);
const user = userEvent.setup();
const switchButton = screen.getByRole('button', { name: 'Switch to markdown editor' });
expect(switchButton).toBeInTheDocument();
@@ -57,12 +49,28 @@ describe('SwitchEditorCard - markdown', () => {
expect(confirmButton).toBeInTheDocument();
expect(switchEditorSpy).not.toHaveBeenCalled();
await user.click(confirmButton);
expect(switchEditorSpy).toHaveBeenCalledWith('markdown', editorRef);
expect(switchEditorSpy).toHaveBeenCalledWith('markdown');
// Markdown editor would now be active.
});
test('renders nothing for advanced problemType', () => {
const { container } = renderSwitchEditorCard({ problemType: 'advanced' });
const { container } = editorRender(<SwitchEditorCard {...baseProps} problemType="advanced" />);
const reduxWrapper = (container.firstChild as HTMLElement | null);
expect(reduxWrapper?.innerHTML).toBe('');
});
test('returns null when editor is already Markdown', () => {
// Markdown Editor support is on for this course:
mockWaffleFlags({ useReactMarkdownEditor: true });
// The markdown editor *IS* currently active (default)
const { container } = editorRender(<SwitchEditorCard {...baseProps} />, {
initialState: {
problem: {
isMarkdownEditorEnabled: true,
},
},
});
const reduxWrapper = (container.firstChild as HTMLElement | null);
expect(reduxWrapper?.innerHTML).toBe('');
});

View File

@@ -90,10 +90,7 @@ export const parseState = ({
return {
settings: {
...settings,
// If the save action isnt triggered from the Markdown editor, the Markdown content might be outdated. Since the
// Markdown editor shouldn't be displayed in future in this case, were sending `null` instead.
// TODO: Implement OLX-to-Markdown conversion to properly handle this scenario.
markdown: isMarkdownEditorEnabled ? contentString : null,
...(isMarkdownEditorEnabled && { markdown: contentString }),
markdown_edited: isMarkdownEditorEnabled,
},
olx: isAdvanced || isMarkdownEditorEnabled ? rawOLX : reactBuiltOlx,

View File

@@ -165,7 +165,6 @@ describe('EditProblemView hooks parseState', () => {
assets: {},
})();
expect(res.olx).toBe(mockRawOLX);
expect(res.settings.markdown).toBe(null);
});
it('markdown problem', () => {
const res = hooks.parseState({
@@ -307,8 +306,6 @@ describe('EditProblemView hooks parseState', () => {
show_reset_button: false,
submission_wait_seconds: 0,
attempts_before_showanswer_button: 0,
markdown: null,
markdown_edited: false,
};
const openSaveWarningModal = jest.fn();
@@ -316,7 +313,6 @@ describe('EditProblemView hooks parseState', () => {
const problem = { ...problemState, problemType: ProblemTypeKeys.NUMERIC, answers: [{ id: 'A', title: 'problem', correct: true }] };
const content = hooks.getContent({
isAdvancedProblemType: false,
isMarkdownEditorEnabled: false,
problemState: problem,
editorRef,
assets,
@@ -343,7 +339,6 @@ describe('EditProblemView hooks parseState', () => {
};
const { settings } = hooks.getContent({
isAdvancedProblemType: false,
isMarkdownEditorEnabled: false,
problemState: problem,
editorRef,
assets,
@@ -358,15 +353,12 @@ describe('EditProblemView hooks parseState', () => {
attempts_before_showanswer_button: 0,
submission_wait_seconds: 0,
weight: 1,
markdown: null,
markdown_edited: false,
});
});
it('default advanced save and returns parseState data', () => {
const content = hooks.getContent({
isAdvancedProblemType: true,
isMarkdownEditorEnabled: false,
problemState,
editorRef,
assets,

View File

@@ -28,7 +28,6 @@ import ExplanationWidget from './ExplanationWidget';
import { saveBlock } from '../../../../hooks';
import { selectors } from '../../../../data/redux';
import { ProblemEditorContextProvider } from './ProblemEditorContext';
const EditProblemView = ({ returnFunction }) => {
const intl = useIntl();
@@ -59,87 +58,85 @@ const EditProblemView = ({ returnFunction }) => {
};
return (
<ProblemEditorContextProvider editorRef={editorRef}>
<EditorContainer
getContent={() => getContent({
problemState,
openSaveWarningModal,
isAdvancedProblemType,
isMarkdownEditorEnabled,
editorRef,
lmsEndpointUrl,
})}
isDirty={checkIfDirty}
returnFunction={returnFunction}
>
<AlertModal
title={isAdvancedProblemType
? intl.formatMessage(messages.olxSettingDiscrepancyTitle)
: intl.formatMessage(messages.noAnswerTitle)}
isOpen={isSaveWarningModalOpen}
onClose={closeSaveWarningModal}
footerNode={(
<ActionRow>
<Button variant="tertiary" onClick={closeSaveWarningModal}>
<FormattedMessage {...messages.saveWarningModalCancelButtonLabel} />
</Button>
<Button
onClick={() => saveBlock({
content: parseState({
problem: problemState,
isAdvanced: isAdvancedProblemType,
isMarkdown: isMarkdownEditorEnabled,
ref: editorRef,
lmsEndpointUrl,
})(),
returnFunction,
destination: returnUrl,
dispatch,
analytics,
})}
>
<FormattedMessage {...messages.saveWarningModalSaveButtonLabel} />
</Button>
</ActionRow>
<EditorContainer
getContent={() => getContent({
problemState,
openSaveWarningModal,
isAdvancedProblemType,
isMarkdownEditorEnabled,
editorRef,
lmsEndpointUrl,
})}
isDirty={checkIfDirty}
returnFunction={returnFunction}
>
<AlertModal
title={isAdvancedProblemType
? intl.formatMessage(messages.olxSettingDiscrepancyTitle)
: intl.formatMessage(messages.noAnswerTitle)}
isOpen={isSaveWarningModalOpen}
onClose={closeSaveWarningModal}
footerNode={(
<ActionRow>
<Button variant="tertiary" onClick={closeSaveWarningModal}>
<FormattedMessage {...messages.saveWarningModalCancelButtonLabel} />
</Button>
<Button
onClick={() => saveBlock({
content: parseState({
problem: problemState,
isAdvanced: isAdvancedProblemType,
isMarkdown: isMarkdownEditorEnabled,
ref: editorRef,
lmsEndpointUrl,
})(),
returnFunction,
destination: returnUrl,
dispatch,
analytics,
})}
>
<FormattedMessage {...messages.saveWarningModalSaveButtonLabel} />
</Button>
</ActionRow>
)}
>
{isAdvancedProblemType ? (
<FormattedMessage {...messages.olxSettingDiscrepancyBodyExplanation} />
) : (
<>
<div>
<FormattedMessage {...messages.saveWarningModalBodyQuestion} />
</div>
<div>
<FormattedMessage {...messages.noAnswerBodyExplanation} />
</div>
</>
)}
</AlertModal>
>
{isAdvancedProblemType ? (
<FormattedMessage {...messages.olxSettingDiscrepancyBodyExplanation} />
) : (
<>
<div>
<FormattedMessage {...messages.saveWarningModalBodyQuestion} />
</div>
<div>
<FormattedMessage {...messages.noAnswerBodyExplanation} />
</div>
</>
)}
</AlertModal>
<div className="editProblemView d-flex flex-row flex-nowrap justify-content-end">
{isAdvancedProblemType || isMarkdownEditorEnabled ? (
<Container fluid className="advancedEditorTopMargin p-0">
<RawEditor
editorRef={editorRef}
lang={isMarkdownEditorEnabled ? 'markdown' : 'xml'}
content={isMarkdownEditorEnabled ? problemState.rawMarkdown : problemState.rawOLX}
/>
</Container>
) : (
<span className="flex-grow-1 mb-5">
<QuestionWidget />
<ExplanationWidget />
<AnswerWidget problemType={problemType} />
</span>
)}
<span className="editProblemView-settingsColumn">
<SettingsWidget problemType={problemType} />
<div className="editProblemView d-flex flex-row flex-nowrap justify-content-end">
{isAdvancedProblemType || isMarkdownEditorEnabled ? (
<Container fluid className="advancedEditorTopMargin p-0">
<RawEditor
editorRef={editorRef}
lang={isMarkdownEditorEnabled ? 'markdown' : 'xml'}
content={isMarkdownEditorEnabled ? problemState.rawMarkdown : problemState.rawOLX}
/>
</Container>
) : (
<span className="flex-grow-1 mb-5">
<QuestionWidget />
<ExplanationWidget />
<AnswerWidget problemType={problemType} />
</span>
</div>
</EditorContainer>
</ProblemEditorContextProvider>
)}
<span className="editProblemView-settingsColumn">
<SettingsWidget problemType={problemType} />
</span>
</div>
</EditorContainer>
);
};
EditProblemView.defaultProps = {

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