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 # Fallback in local style files
PARAGON_THEME_URLS={} PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL='' 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 # Fallback in local style files
PARAGON_THEME_URLS={} PARAGON_THEME_URLS={}
COURSE_TEAM_SUPPORT_EMAIL='' 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 ENABLE_TAGGING_TAXONOMY_PAGES=true
BBB_LEARN_MORE_URL='' BBB_LEARN_MORE_URL=''
INVITE_STUDENTS_EMAIL_TO="someone@domain.com" INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
ENABLE_HOME_PAGE_COURSE_API_V2=true
ENABLE_CHECKLIST_QUALITY=true ENABLE_CHECKLIST_QUALITY=true
ENABLE_GRADING_METHOD_IN_PROBLEMS=false ENABLE_GRADING_METHOD_IN_PROBLEMS=false
# "Multi-level" blocks are unsupported in libraries # "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: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- uses: actions/setup-node@v6 - uses: actions/setup-node@v4
with: with:
node-version-file: '.nvmrc' node-version-file: '.nvmrc'
- run: make validate.ci - run: make validate.ci
- name: Archive code coverage results - name: Archive code coverage results
uses: actions/upload-artifact@v5 uses: actions/upload-artifact@v4
with: with:
name: code-coverage-report name: code-coverage-report
path: coverage/*.* path: coverage/*.*
@@ -27,11 +27,9 @@ jobs:
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5
- name: Download code coverage results - name: Download code coverage results
uses: actions/download-artifact@v6 uses: actions/download-artifact@v5
with: with:
pattern: code-coverage-report name: code-coverage-report
path: coverage
merge-multiple: true
- name: Upload coverage - name: Upload coverage
uses: codecov/codecov-action@v5 uses: codecov/codecov-action@v5
with: with:

2
.nvmrc
View File

@@ -1 +1 @@
24 20

View File

@@ -11,5 +11,4 @@ coverage:
ignore: ignore:
- "src/grading-settings/grading-scale/react-ranger.js" - "src/grading-settings/grading-scale/react-ranger.js"
- "src/generic/DraggableList/verticalSortableList.ts" - "src/generic/DraggableList/verticalSortableList.ts"
- "src/container-comparison/data/api.mock.ts"
- "src/index.js" - "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/brand": "npm:@openedx/brand-openedx@^1.2.3",
"@edx/browserslist-config": "1.5.0", "@edx/browserslist-config": "1.5.0",
"@edx/frontend-component-footer": "^14.9.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-enterprise-hotjar": "^7.2.0",
"@edx/frontend-platform": "^8.4.0", "@edx/frontend-platform": "^8.4.0",
"@edx/openedx-atlas": "^0.7.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-teams": "file:plugins/course-apps/teams",
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki", "@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-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/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.5.0", "@openedx/paragon": "^23.5.0",
"@redux-devtools/extension": "^3.3.0", "@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "1.9.7", "@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "5.90.5", "@tanstack/react-query": "4.40.1",
"@tinymce/tinymce-react": "^6.0.0", "@tinymce/tinymce-react": "^3.14.0",
"classnames": "2.5.1", "classnames": "2.5.1",
"codemirror": "^6.0.0", "codemirror": "^6.0.0",
"email-validator": "2.0.4", "email-validator": "2.0.4",
"fast-xml-parser": "^5.0.0", "fast-xml-parser": "^4.0.10",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"formik": "2.4.6", "formik": "2.4.6",
"frontend-components-tinymce-advanced-plugins": "^1.0.3", "frontend-components-tinymce-advanced-plugins": "^1.0.3",
@@ -97,7 +97,7 @@
"redux-thunk": "^2.4.1", "redux-thunk": "^2.4.1",
"reselect": "^4.1.5", "reselect": "^4.1.5",
"tinymce": "^5.10.4", "tinymce": "^5.10.4",
"universal-cookie": "^8.0.0", "universal-cookie": "^4.0.4",
"uuid": "^11.1.0", "uuid": "^11.1.0",
"xmlchecker": "^0.1.0", "xmlchecker": "^0.1.0",
"yup": "0.32.11" "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 () => { it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
await mockStore({ apiStatus: 200, enabled: true });
renderComponent(); renderComponent();
await mockStore({ apiStatus: 200, enabled: true });
const checkbox = await screen.getByRole('checkbox', { name: /Flex Peer Grading/ }); waitFor(() => {
expect(checkbox).toBeChecked();
await waitFor(() => {
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage); const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
const enableBadge = screen.getByTestId('enable-badge'); const enableBadge = screen.getByTestId('enable-badge');

View File

@@ -1,6 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n'; import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({ 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': { 'authoring.proctoring.alert.forbidden': {
id: '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.', 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 clipboardUnit } from './clipboardUnit';
export { default as clipboardSubsection } from './clipboardSubsection'; export { default as clipboardSubsection } from './clipboardSubsection';
export { default as clipboardXBlock } from './clipboardXBlock'; 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 { deprecated, help, displayName } = settingData;
const initialValue = JSON.stringify(settingData.value, null, 4); const initialValue = JSON.stringify(settingData.value, null, 4);
const [isOpen, open, close] = useToggle(false); const [isOpen, open, close] = useToggle(false);
const [target, setTarget] = useState<HTMLButtonElement | null>(null); const [target, setTarget] = useState(null);
const [newValue, setNewValue] = useState(initialValue); const [newValue, setNewValue] = useState(initialValue);
const handleSettingChange = (e) => { const handleSettingChange = (e) => {
@@ -118,7 +118,7 @@ SettingCard.propTypes = {
deprecated: PropTypes.bool, deprecated: PropTypes.bool,
help: PropTypes.string, help: PropTypes.string,
displayName: PropTypes.string, displayName: PropTypes.string,
value: PropTypes.oneOfType([ value: PropTypes.PropTypes.oneOfType([
PropTypes.string, PropTypes.string,
PropTypes.bool, PropTypes.bool,
PropTypes.number, PropTypes.number,

View File

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

View File

@@ -96,7 +96,7 @@ describe('CertificateSignatories', () => {
expect(useCreateSignatory().handleAddSignatory).toHaveBeenCalled(); 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 user = userEvent.setup();
const { getAllByRole } = renderComponent(defaultProps); const { getAllByRole } = renderComponent(defaultProps);
@@ -105,9 +105,7 @@ describe('CertificateSignatories', () => {
await user.click(deleteIcons[0]); await user.click(deleteIcons[0]);
// FIXME: this isn't called because the whole 'useEditSignatory' hook waitFor(() => {
// which calls it is mocked out.
await waitFor(() => {
expect(mockArrayHelpers.remove).toHaveBeenCalledWith(0); 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 { Provider } from 'react-redux';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n'; import { IntlProvider } from '@edx/frontend-platform/i18n';
@@ -30,7 +30,6 @@ const initialState = {
}; };
const defaultProps = { const defaultProps = {
index: 0,
...signatoriesMock[0], ...signatoriesMock[0],
showDeleteButton: true, showDeleteButton: true,
isEdit: true, isEdit: true,
@@ -63,36 +62,31 @@ describe('Signatory Component', () => {
it('handles input change', async () => { it('handles input change', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const handleChange = jest.fn(); const handleChange = jest.fn();
renderSignatory({ ...defaultProps, handleChange }); const { getByPlaceholderText } = renderSignatory({ ...defaultProps, handleChange });
const input = screen.getByPlaceholderText(messages.namePlaceholder.defaultMessage); const input = getByPlaceholderText(messages.namePlaceholder.defaultMessage);
const newInputValue = 'Jane Doe'; const newInputValue = 'Jane Doe';
expect(handleChange).not.toHaveBeenCalled(); await user.type(input, newInputValue, { name: 'signatories[0].name' });
expect(input.value).not.toBe(newInputValue);
await user.type(input, newInputValue);
await waitFor(() => { waitFor(() => {
// This is not a great test; handleChange() gets called for each key press: expect(handleChange).toHaveBeenCalledWith(expect.anything());
expect(handleChange).toHaveBeenCalledTimes(newInputValue.length); expect(input.value).toBe(newInputValue);
// 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);
}); });
}); });
it('opens image upload modal on button click', async () => { it('opens image upload modal on button click', async () => {
const user = userEvent.setup(); const user = userEvent.setup();
const { getByRole, queryByTestId } = renderSignatory(defaultProps); const { getByRole, queryByRole } = renderSignatory(defaultProps);
const replaceButton = getByRole( const replaceButton = getByRole(
'button', 'button',
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) }, { name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
); );
expect(queryByTestId('dropzone-container')).not.toBeInTheDocument(); expect(queryByRole('presentation')).not.toBeInTheDocument();
await user.click(replaceButton); await user.click(replaceButton);
expect(queryByTestId('dropzone-container')).toBeInTheDocument(); expect(getByRole('presentation')).toBeInTheDocument();
}); });
it('shows confirm modal on delete icon click', async () => { 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] }); const dropdownButton = getByRole('button', { name: certificatesDataMock.courseModes[0] });
await user.click(dropdownButton); 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 user.click(verifiedMode);
await waitFor(() => { await waitFor(() => {

View File

@@ -7,7 +7,6 @@ export const STATEFUL_BUTTON_STATES = {
default: 'default', default: 'default',
pending: 'pending', pending: 'pending',
error: 'error', error: 'error',
disable: 'disable',
}; };
export const USER_ROLES = { export const USER_ROLES = {
@@ -62,8 +61,6 @@ export const COURSE_BLOCK_NAMES = ({
libraryContent: { id: 'library_content', name: 'Library content' }, libraryContent: { id: 'library_content', name: 'Library content' },
splitTest: { id: 'split_test', name: 'Split Test' }, splitTest: { id: 'split_test', name: 'Split Test' },
component: { id: 'component', name: 'Component' }, 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'; export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel';
@@ -110,9 +107,3 @@ export const iframeMessageTypes = {
xblockEvent: 'xblock-event', xblockEvent: 'xblock-event',
xblockScroll: 'xblock-scroll', 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} onKeyDown={handleSelectOnKeyDown}
ref={/** @type {React.RefObject} */(selectRef)} ref={/** @type {React.RefObject} */(selectRef)}
isMulti isMulti
isLoading={updateTags.isPending} isLoading={updateTags.isLoading}
isDisabled={updateTags.isPending} isDisabled={updateTags.isLoading}
name="tags-select" name="tags-select"
placeholder={intl.formatMessage(messages.collapsibleAddTagsPlaceholderText)} placeholder={intl.formatMessage(messages.collapsibleAddTagsPlaceholderText)}
isSearchable isSearchable

View File

@@ -37,9 +37,3 @@
min-height: 100vh; 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)); await waitFor(() => expect(axiosMock.history.put[0].url).toEqual(url));
expect(mockInvalidateQueries).toHaveBeenCalledTimes(5); expect(mockInvalidateQueries).toHaveBeenCalledTimes(5);
expect(mockInvalidateQueries).toHaveBeenNthCalledWith(5, { expect(mockInvalidateQueries).toHaveBeenNthCalledWith(5, [
queryKey: [ 'contentLibrary',
'contentLibrary', 'lib:org:lib',
'lib:org:lib', 'content',
'content', 'container',
'container', containerId,
containerId, 'children',
'children', ]);
],
});
}); });
}); });

View File

@@ -1,3 +1,4 @@
// @ts-check
import { useMemo } from 'react'; import { useMemo } from 'react';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { import {
@@ -7,7 +8,6 @@ import {
useQueryClient, useQueryClient,
} from '@tanstack/react-query'; } from '@tanstack/react-query';
import { useParams } from 'react-router'; import { useParams } from 'react-router';
import { TagData, TagListData } from '@src/taxonomy/data/types';
import { import {
getTaxonomyTagsData, getTaxonomyTagsData,
getContentTaxonomyTagsData, getContentTaxonomyTagsData,
@@ -17,16 +17,18 @@ import {
} from './api'; } from './api';
import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks'; import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
import { getLibraryId } from '../../generic/key-utils'; import { getLibraryId } from '../../generic/key-utils';
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 * Builds the query to get the taxonomy tags
* @param taxonomyId The id of the taxonomy to fetch tags for * @param {number} taxonomyId The id of the taxonomy to fetch tags for
* @param parentTag The tag whose children we're loading, if any * @param {string|null} parentTag The tag whose children we're loading, if any
* @param searchTerm The term passed in to perform search on tags * @param {string} searchTerm The term passed in to perform search on tags
* @param numPages How many pages of tags to load at this level * @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 queryClient = useQueryClient();
const queryFn = async ({ queryKey }) => { const queryFn = async ({ queryKey }) => {
@@ -34,7 +36,8 @@ export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null
return getTaxonomyTagsData(taxonomyId, { parentTag: parentTag || '', searchTerm, page }); 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++) { for (let page = 1; page <= numPages; page++) {
queries.push( queries.push(
{ queryKey: ['taxonomyTags', taxonomyId, parentTag, page, searchTerm], queryFn, staleTime: Infinity }, { 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 preLoadedData = new Map();
const newTags = dataPages.map(result => { const newTags = dataPages.map(result => {
const simplifiedTagsList: TagData[] = []; /** @type {TagData[]} */
const simplifiedTagsList = [];
result.data?.results?.forEach((tag) => { result.data?.results?.forEach((tag) => {
if (tag.parentValue === parentTag) { 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: // Store the pre-loaded descendants into the query cache:
preLoadedData.forEach((tags, parentValue) => { preLoadedData.forEach((tags, parentValue) => {
const queryKey = ['taxonomyTags', taxonomyId, parentValue, 1, searchTerm]; const queryKey = ['taxonomyTags', taxonomyId, parentValue, 1, searchTerm];
const cachedData: TagListData = { /** @type {TagListData} */
const cachedData = {
next: '', next: '',
previous: '', previous: '',
count: tags.length, 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 * 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({ useQuery({
queryKey: ['contentTaxonomyTags', contentId], queryKey: ['contentTaxonomyTags', contentId],
queryFn: () => getContentTaxonomyTagsData(contentId), queryFn: () => getContentTaxonomyTagsData(contentId),
@@ -107,30 +112,37 @@ export const useContentTaxonomyTagsData = (contentId: string) => (
/** /**
* Builds the query to get meta data about the content object * Builds the query to get meta data about the content object
* @param contentId The id of the content object * @param {string} contentId The id of the content object
* @param enabled Flag to enable/disable the query * @param {boolean} enabled Flag to enable/disable the query
*/ */
export const useContentData = (contentId: string, enabled: boolean) => ( export const useContentData = (contentId, enabled) => (
useQuery({ useQuery({
queryKey: ['contentData', contentId], queryKey: ['contentData', contentId],
queryFn: () => getContentData(contentId), queryFn: enabled ? () => getContentData(contentId) : undefined,
enabled, enabled,
}) })
); );
/** /**
* Builds the mutation to update the tags applied to the content object * 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 queryClient = useQueryClient();
const unitIframe = window.frames['xblock-iframe']; const unitIframe = window.frames['xblock-iframe'];
const { containerId } = useParams(); const { containerId } = useParams();
return useMutation({ 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: () => { onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] }); queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
/// Invalidate query with pattern on course outline /// Invalidate query with pattern on course outline
@@ -145,13 +157,13 @@ export const useContentTaxonomyTagsUpdater = (contentId: string) => {
// Obtain library id from contentId // Obtain library id from contentId
const libraryId = getLibraryId(contentId); const libraryId = getLibraryId(contentId);
// Invalidate component metadata to update tags count // 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 // 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), // If the tags for an item were edited from a container page (Unit, Subsection, Section),
// invalidate children query to fetch count again. // invalidate children query to fetch count again.
if (containerId) { 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' }); const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
await user.click(dismissBtn); await user.click(dismissBtn);
expect(allTab).toHaveAttribute('aria-selected', 'true'); expect(allTab).toHaveAttribute('aria-selected', 'true');
await waitFor(() => expect(alert).not.toBeInTheDocument()); waitFor(() => expect(alert).not.toBeInTheDocument());
// review updates button // review updates button
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' }); const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
await user.click(reviewActionBtn); await user.click(reviewActionBtn);
@@ -327,19 +327,4 @@ describe('<CourseLibraries ReviewTab />', () => {
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg); expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] }); 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, onReview,
}) => { }) => {
const intl = useIntl(); 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 outOfSyncCount = data?.reduce((count, lib) => count + (lib.readyToSyncCount || 0), 0);
const lastPublishedDate = data?.map(lib => new Date(lib.lastPublishedAt || 0).getTime()) const lastPublishedDate = data?.map(lib => new Date(lib.lastPublishedAt || 0).getTime())
.reduce((acc, lastPublished) => Math.max(lastPublished, acc), 0); .reduce((acc, lastPublished) => Math.max(lastPublished, acc), 0);
const alertKey = `outOfSyncCountAlert-${courseId}`; const alertKey = `outOfSyncCountAlert-${courseId}`;
useEffect(() => { useEffect(() => {
if (isPending) { if (isLoading) {
return; return;
} }
if (outOfSyncCount === 0) { if (outOfSyncCount === 0) {
@@ -50,7 +50,7 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
const dismissedAlertDate = parseInt(localStorage.getItem(alertKey) ?? '0', 10); const dismissedAlertDate = parseInt(localStorage.getItem(alertKey) ?? '0', 10);
setShowAlert((lastPublishedDate ?? 0) > dismissedAlertDate); setShowAlert((lastPublishedDate ?? 0) > dismissedAlertDate);
}, [outOfSyncCount, lastPublishedDate, isPending, data]); }, [outOfSyncCount, lastPublishedDate, isLoading, data]);
const dismissAlert = () => { const dismissAlert = () => {
setShowAlert(false); setShowAlert(false);

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ const messages = defineMessages({
description: 'Tab title for review tab', description: 'Tab title for review tab',
}, },
reviewTabDescriptionEmpty: { 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', defaultMessage: 'All components are up to date',
description: 'Description text for home tab', description: 'Description text for home tab',
}, },

View File

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

View File

@@ -7,7 +7,6 @@ import {
import CardHeader from './CardHeader'; import CardHeader from './CardHeader';
import TitleButton from './TitleButton'; import TitleButton from './TitleButton';
import messages from './messages'; import messages from './messages';
import { RequestStatus } from '../../data/constants';
const onExpandMock = jest.fn(); const onExpandMock = jest.fn();
const onClickMenuButtonMock = 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 () => { it('check editing is enabled when isDisabledEditField is false', async () => {
renderComponent({ ...cardHeaderProps }); renderComponent({ ...cardHeaderProps });
@@ -245,8 +254,8 @@ describe('<CardHeader />', () => {
expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled'); 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 () => { it('check editing is disabled when isDisabledEditField is true', async () => {
renderComponent({ ...cardHeaderProps, savingStatus: RequestStatus.IN_PROGRESS }); renderComponent({ ...cardHeaderProps, isDisabledEditField: true });
expect(await screen.findByTestId('subsection-edit-button')).toBeDisabled(); 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 TagCount from '@src/generic/tag-count';
import { useEscapeClick } from '@src/hooks'; import { useEscapeClick } from '@src/hooks';
import { XBlockActions } from '@src/data/types'; import { XBlockActions } from '@src/data/types';
import { RequestStatus, RequestStatusType } from '@src/data/constants';
import { ITEM_BADGE_STATUS } from '../constants'; import { ITEM_BADGE_STATUS } from '../constants';
import { scrollToElement } from '../utils'; import { scrollToElement } from '../utils';
import CardStatus from './CardStatus'; import CardStatus from './CardStatus';
@@ -42,6 +41,7 @@ interface CardHeaderProps {
isFormOpen: boolean; isFormOpen: boolean;
onEditSubmit: (titleValue: string) => void; onEditSubmit: (titleValue: string) => void;
closeForm: () => void; closeForm: () => void;
isDisabledEditField: boolean;
onClickDelete: () => void; onClickDelete: () => void;
onClickUnlink: () => void; onClickUnlink: () => void;
onClickDuplicate: () => void; onClickDuplicate: () => void;
@@ -69,7 +69,6 @@ interface CardHeaderProps {
extraActionsComponent?: ReactNode, extraActionsComponent?: ReactNode,
onClickSync?: () => void; onClickSync?: () => void;
readyToSync?: boolean; readyToSync?: boolean;
savingStatus?: RequestStatusType;
} }
const CardHeader = ({ const CardHeader = ({
@@ -84,6 +83,7 @@ const CardHeader = ({
isFormOpen, isFormOpen,
onEditSubmit, onEditSubmit,
closeForm, closeForm,
isDisabledEditField,
onClickDelete, onClickDelete,
onClickUnlink, onClickUnlink,
onClickDuplicate, onClickDuplicate,
@@ -103,7 +103,6 @@ const CardHeader = ({
extraActionsComponent, extraActionsComponent,
onClickSync, onClickSync,
readyToSync, readyToSync,
savingStatus,
}: CardHeaderProps) => { }: CardHeaderProps) => {
const intl = useIntl(); const intl = useIntl();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
@@ -120,7 +119,6 @@ const CardHeader = ({
|| status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges; || status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
const { data: contentTagCount } = useContentTagsCount(cardId); const { data: contentTagCount } = useContentTagsCount(cardId);
const isSaving = savingStatus === RequestStatus.IN_PROGRESS;
useEffect(() => { useEffect(() => {
const locatorId = searchParams.get('show'); const locatorId = searchParams.get('show');
@@ -174,7 +172,7 @@ const CardHeader = ({
onEditSubmit(titleValue); onEditSubmit(titleValue);
} }
}} }}
disabled={isSaving} disabled={isDisabledEditField}
/> />
</Form.Group> </Form.Group>
) : ( ) : (
@@ -188,7 +186,7 @@ const CardHeader = ({
iconAs={EditIcon} iconAs={EditIcon}
onClick={onClickEdit} onClick={onClickEdit}
// @ts-ignore // @ts-ignore
disabled={isSaving} disabled={isDisabledEditField}
/> />
</> </>
)} )}
@@ -240,7 +238,7 @@ const CardHeader = ({
</Dropdown.Item> </Dropdown.Item>
<Dropdown.Item <Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-configure-button`} data-testid={`${namePrefix}-card-header__menu-configure-button`}
disabled={isSaving} disabled={isDisabledEditField}
onClick={onClickConfigure} onClick={onClickConfigure}
> >
{intl.formatMessage(messages.menuConfigure)} {intl.formatMessage(messages.menuConfigure)}
@@ -248,7 +246,7 @@ const CardHeader = ({
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<Dropdown.Item <Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`} data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
disabled={isSaving} disabled={isDisabledEditField}
onClick={openManageTagsDrawer} onClick={openManageTagsDrawer}
> >
{intl.formatMessage(messages.menuManageTags)} {intl.formatMessage(messages.menuManageTags)}

View File

@@ -22,7 +22,7 @@ export const useCreateCourseBlock = (
) => useMutation({ ) => useMutation({
mutationFn: createCourseXblock, mutationFn: createCourseXblock,
onSettled: async (data) => { 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 * @param {string} courseId - ID of the course
* @returns {Object} - Object containing fetch course outline index query success or failure status * @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) => { return async (dispatch) => {
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS })); 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 { useNavigate } from 'react-router-dom';
import { useToggle } from '@openedx/paragon'; import { useToggle } from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform'; import { getConfig } from '@edx/frontend-platform';
import { useQueryClient } from '@tanstack/react-query';
import moment from 'moment'; import moment from 'moment';
import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors'; import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors';
@@ -65,11 +64,9 @@ import {
} from './data/thunk'; } from './data/thunk';
import { useCreateCourseBlock } from './data/apiHooks'; import { useCreateCourseBlock } from './data/apiHooks';
import { getCourseItem } from './data/api'; import { getCourseItem } from './data/api';
import { containerComparisonQueryKeys } from '../container-comparison/data/apiHooks';
const useCourseOutline = ({ courseId }) => { const useCourseOutline = ({ courseId }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const waffleFlags = useWaffleFlags(courseId); const waffleFlags = useWaffleFlags(courseId);
@@ -248,8 +245,6 @@ const useCourseOutline = ({ courseId }) => {
const handleEditSubmit = (itemId, sectionId, displayName) => { const handleEditSubmit = (itemId, sectionId, displayName) => {
dispatch(editCourseItemQuery(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 = () => { 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 { getConfig } from '@edx/frontend-platform';
import { useDispatch, useSelector } from 'react-redux';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; 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 { import {
Alert, Button, Hyperlink, Truncate, Alert, Button, Hyperlink, Truncate,
} from '@openedx/paragon'; } 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 { 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 ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
import { RequestStatus } from '../../data/constants';
import AlertMessage from '../../generic/alert-message'; import AlertMessage from '../../generic/alert-message';
import AlertProctoringError from '../../generic/AlertProctoringError'; 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 { getPasteFileNotices } from '../data/selectors';
import { dismissError, removePasteFileNotices } from '../data/slice'; import { dismissError, removePasteFileNotices } from '../data/slice';
import messages from './messages'; import { API_ERROR_TYPES } from '../constants';
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
const PageAlerts = ({ const PageAlerts = ({
courseId, courseId,
@@ -438,7 +437,6 @@ const PageAlerts = ({
{conflictingFilesPasteAlert()} {conflictingFilesPasteAlert()}
{newFilesPasteAlert()} {newFilesPasteAlert()}
{renderOutOfSyncAlert()} {renderOutOfSyncAlert()}
<CourseOutlinePageAlertsSlot />
</> </>
); );
}; };

View File

@@ -17,11 +17,11 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
})); }));
const unit = { const unit = {
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0', id: 'unit-1',
}; };
const subsection = { const subsection = {
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0', id: '123',
displayName: 'Subsection Name', displayName: 'Subsection Name',
category: 'sequential', category: 'sequential',
published: true, published: true,
@@ -43,7 +43,7 @@ const subsection = {
} satisfies Partial<XBlock> as XBlock; } satisfies Partial<XBlock> as XBlock;
const section = { const section = {
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0', id: '123',
displayName: 'Section Name', displayName: 'Section Name',
category: 'chapter', category: 'chapter',
published: true, published: true,
@@ -71,10 +71,7 @@ const section = {
readyToSync: true, readyToSync: true,
upstreamRef: 'lct:org1:lib1:section:1', upstreamRef: 'lct:org1:lib1:section:1',
versionSynced: 1, versionSynced: 1,
versionAvailable: 2,
versionDeclined: null,
errorMessage: null, errorMessage: null,
downstreamCustomized: [] as string[],
}, },
} satisfies Partial<XBlock> as XBlock; } satisfies Partial<XBlock> as XBlock;
@@ -91,6 +88,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
onOpenDeleteModal={jest.fn()} onOpenDeleteModal={jest.fn()}
onOpenUnlinkModal={jest.fn()} onOpenUnlinkModal={jest.fn()}
onOpenConfigureModal={jest.fn()} onOpenConfigureModal={jest.fn()}
savingStatus=""
onEditSectionSubmit={onEditSectionSubmit} onEditSectionSubmit={onEditSectionSubmit}
onDuplicateSubmit={jest.fn()} onDuplicateSubmit={jest.fn()}
isSectionsExpanded isSectionsExpanded
@@ -189,9 +187,7 @@ describe('<SectionCard />', () => {
const collapsedSections = { ...section }; const collapsedSections = { ...section };
// @ts-ignore-next-line // @ts-ignore-next-line
collapsedSections.isSectionsExpanded = false; collapsedSections.isSectionsExpanded = false;
// url encode subsection.id renderComponent(collapsedSections, `/course/:courseId?show=${subsection.id}`);
const subsectionIdUrl = encodeURIComponent(subsection.id);
renderComponent(collapsedSections, `/course/:courseId?show=${subsectionIdUrl}`);
const cardSubsections = await screen.findByTestId('section-card__subsections'); const cardSubsections = await screen.findByTestId('section-card__subsections');
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' }); const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
@@ -203,9 +199,7 @@ describe('<SectionCard />', () => {
const collapsedSections = { ...section }; const collapsedSections = { ...section };
// @ts-ignore-next-line // @ts-ignore-next-line
collapsedSections.isSectionsExpanded = false; collapsedSections.isSectionsExpanded = false;
// url encode subsection.id renderComponent(collapsedSections, `/course/:courseId?show=${unit.id}`);
const unitIdUrl = encodeURIComponent(unit.id);
renderComponent(collapsedSections, `/course/:courseId?show=${unitIdUrl}`);
const cardSubsections = await screen.findByTestId('section-card__subsections'); const cardSubsections = await screen.findByTestId('section-card__subsections');
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' }); const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
@@ -237,6 +231,7 @@ describe('<SectionCard />', () => {
// Should open compare preview modal // Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: section name/i })).toBeInTheDocument(); 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 // Click on accept changes
const acceptChangesButton = screen.getByText(/accept changes/i); const acceptChangesButton = screen.getByText(/accept changes/i);
@@ -256,6 +251,7 @@ describe('<SectionCard />', () => {
// Should open compare preview modal // Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: section name/i })).toBeInTheDocument(); 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 // Click on ignore changes
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i }); 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 { useQueryClient } from '@tanstack/react-query';
import { setCurrentItem, setCurrentSection } from '@src/course-outline/data/slice'; 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 CardHeader from '@src/course-outline/card-header/CardHeader';
import SortableItem from '@src/course-outline/drag-helper/SortableItem'; import SortableItem from '@src/course-outline/drag-helper/SortableItem';
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider'; import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
@@ -39,7 +39,7 @@ interface SectionCardProps {
onOpenPublishModal: () => void, onOpenPublishModal: () => void,
onOpenConfigureModal: () => void, onOpenConfigureModal: () => void,
onEditSectionSubmit: (itemId: string, sectionId: string, displayName: string) => void, onEditSectionSubmit: (itemId: string, sectionId: string, displayName: string) => void,
savingStatus?: RequestStatusType, savingStatus: string,
onOpenDeleteModal: () => void, onOpenDeleteModal: () => void,
onOpenUnlinkModal: () => void, onOpenUnlinkModal: () => void,
onDuplicateSubmit: () => void, onDuplicateSubmit: () => void,
@@ -144,9 +144,7 @@ const SectionCard = ({
downstreamBlockId: id, downstreamBlockId: id,
upstreamBlockId: upstreamInfo.upstreamRef, upstreamBlockId: upstreamInfo.upstreamRef,
upstreamBlockVersionSynced: upstreamInfo.versionSynced, upstreamBlockVersionSynced: upstreamInfo.versionSynced,
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
isContainer: true, isContainer: true,
blockType: 'section',
}; };
}, [upstreamInfo]); }, [upstreamInfo]);
@@ -303,7 +301,7 @@ const SectionCard = ({
isFormOpen={isFormOpen} isFormOpen={isFormOpen}
closeForm={closeForm} closeForm={closeForm}
onEditSubmit={handleEditSubmit} onEditSubmit={handleEditSubmit}
savingStatus={savingStatus} isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit} onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent} titleComponent={titleComponent}
namePrefix={namePrefix} namePrefix={namePrefix}

View File

@@ -52,7 +52,7 @@ const unit = {
}; };
const subsection: XBlock = { const subsection: XBlock = {
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0', id: '123',
displayName: 'Subsection Name', displayName: 'Subsection Name',
category: 'sequential', category: 'sequential',
published: true, published: true,
@@ -75,15 +75,12 @@ const subsection: XBlock = {
readyToSync: true, readyToSync: true,
upstreamRef: 'lct:org1:lib1:subsection:1', upstreamRef: 'lct:org1:lib1:subsection:1',
versionSynced: 1, versionSynced: 1,
versionAvailable: 2,
versionDeclined: null,
errorMessage: null, errorMessage: null,
downstreamCustomized: [] as string[],
}, },
} satisfies Partial<XBlock> as XBlock; } satisfies Partial<XBlock> as XBlock;
const section: XBlock = { const section: XBlock = {
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0', id: '123',
displayName: 'Section Name', displayName: 'Section Name',
published: true, published: true,
visibilityState: 'live', visibilityState: 'live',
@@ -118,6 +115,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
onNewUnitSubmit={jest.fn()} onNewUnitSubmit={jest.fn()}
onAddUnitFromLibrary={handleOnAddUnitFromLibrary} onAddUnitFromLibrary={handleOnAddUnitFromLibrary}
isCustomRelativeDatesActive={false} isCustomRelativeDatesActive={false}
savingStatus=""
onEditSubmit={onEditSubectionSubmit} onEditSubmit={onEditSubectionSubmit}
onDuplicateSubmit={jest.fn()} onDuplicateSubmit={jest.fn()}
onOpenConfigureModal={jest.fn()} onOpenConfigureModal={jest.fn()}
@@ -324,7 +322,7 @@ describe('<SubsectionCard />', () => {
expect(handleOnAddUnitFromLibrary).toHaveBeenCalled(); expect(handleOnAddUnitFromLibrary).toHaveBeenCalled();
expect(handleOnAddUnitFromLibrary).toHaveBeenCalledWith({ expect(handleOnAddUnitFromLibrary).toHaveBeenCalledWith({
type: COMPONENT_TYPES.libraryV2, type: COMPONENT_TYPES.libraryV2,
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0', parentLocator: '123',
category: 'vertical', category: 'vertical',
libraryContentKey: containerKey, libraryContentKey: containerKey,
}); });
@@ -341,6 +339,7 @@ describe('<SubsectionCard />', () => {
// Should open compare preview modal // Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: subsection name/i })).toBeInTheDocument(); 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 // Click on accept changes
const acceptChangesButton = screen.getByText(/accept changes/i); const acceptChangesButton = screen.getByText(/accept changes/i);
@@ -360,6 +359,7 @@ describe('<SubsectionCard />', () => {
// Should open compare preview modal // Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: subsection name/i })).toBeInTheDocument(); 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 // Click on ignore changes
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i }); 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 CourseOutlineSubsectionCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot';
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice'; 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 CardHeader from '@src/course-outline/card-header/CardHeader';
import SortableItem from '@src/course-outline/drag-helper/SortableItem'; import SortableItem from '@src/course-outline/drag-helper/SortableItem';
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider'; import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
@@ -40,7 +40,7 @@ interface SubsectionCardProps {
isCustomRelativeDatesActive: boolean, isCustomRelativeDatesActive: boolean,
onOpenPublishModal: () => void, onOpenPublishModal: () => void,
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void, onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
savingStatus?: RequestStatusType, savingStatus: string,
onOpenDeleteModal: () => void, onOpenDeleteModal: () => void,
onOpenUnlinkModal: () => void, onOpenUnlinkModal: () => void,
onDuplicateSubmit: () => void, onDuplicateSubmit: () => void,
@@ -126,9 +126,7 @@ const SubsectionCard = ({
downstreamBlockId: id, downstreamBlockId: id,
upstreamBlockId: upstreamInfo.upstreamRef, upstreamBlockId: upstreamInfo.upstreamRef,
upstreamBlockVersionSynced: upstreamInfo.versionSynced, upstreamBlockVersionSynced: upstreamInfo.versionSynced,
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
isContainer: true, isContainer: true,
blockType: 'subsection',
}; };
}, [upstreamInfo]); }, [upstreamInfo]);
@@ -305,7 +303,7 @@ const SubsectionCard = ({
isFormOpen={isFormOpen} isFormOpen={isFormOpen}
closeForm={closeForm} closeForm={closeForm}
onEditSubmit={handleEditSubmit} onEditSubmit={handleEditSubmit}
savingStatus={savingStatus} isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit} onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent} titleComponent={titleComponent}
namePrefix={namePrefix} namePrefix={namePrefix}

View File

@@ -19,7 +19,7 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
})); }));
const section = { const section = {
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0', id: '1',
displayName: 'Section Name', displayName: 'Section Name',
published: true, published: true,
visibilityState: 'live', visibilityState: 'live',
@@ -34,7 +34,7 @@ const section = {
} satisfies Partial<XBlock> as XBlock; } satisfies Partial<XBlock> as XBlock;
const subsection = { const subsection = {
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0', id: '12',
displayName: 'Subsection Name', displayName: 'Subsection Name',
published: true, published: true,
visibilityState: 'live', visibilityState: 'live',
@@ -48,7 +48,7 @@ const subsection = {
} satisfies Partial<XBlock> as XBlock; } satisfies Partial<XBlock> as XBlock;
const unit = { const unit = {
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0', id: '123',
displayName: 'unit Name', displayName: 'unit Name',
category: 'vertical', category: 'vertical',
published: true, published: true,
@@ -65,10 +65,7 @@ const unit = {
readyToSync: true, readyToSync: true,
upstreamRef: 'lct:org1:lib1:unit:1', upstreamRef: 'lct:org1:lib1:unit:1',
versionSynced: 1, versionSynced: 1,
versionAvailable: 2,
versionDeclined: null,
errorMessage: null, errorMessage: null,
downstreamCustomized: [] as string[],
}, },
} satisfies Partial<XBlock> as XBlock; } satisfies Partial<XBlock> as XBlock;
@@ -84,6 +81,7 @@ const renderComponent = (props?: object) => render(
onOpenDeleteModal={jest.fn()} onOpenDeleteModal={jest.fn()}
onOpenUnlinkModal={jest.fn()} onOpenUnlinkModal={jest.fn()}
onOpenConfigureModal={jest.fn()} onOpenConfigureModal={jest.fn()}
savingStatus=""
onEditSubmit={jest.fn()} onEditSubmit={jest.fn()}
onDuplicateSubmit={jest.fn()} onDuplicateSubmit={jest.fn()}
getTitleLink={(id) => `/some/${id}`} getTitleLink={(id) => `/some/${id}`}
@@ -110,10 +108,7 @@ describe('<UnitCard />', () => {
const { findByTestId } = renderComponent(); const { findByTestId } = renderComponent();
expect(await findByTestId('unit-card-header')).toBeInTheDocument(); expect(await findByTestId('unit-card-header')).toBeInTheDocument();
expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute( expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute('href', '/some/123');
'href',
'/some/block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
);
}); });
it('hides header based on isHeaderVisible flag', async () => { it('hides header based on isHeaderVisible flag', async () => {
@@ -204,6 +199,7 @@ describe('<UnitCard />', () => {
// Should open compare preview modal // Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument(); 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 // Click on accept changes
const acceptChangesButton = screen.getByText(/accept changes/i); const acceptChangesButton = screen.getByText(/accept changes/i);
@@ -223,6 +219,7 @@ describe('<UnitCard />', () => {
// Should open compare preview modal // Should open compare preview modal
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument(); 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 // Click on ignore changes
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i }); 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 CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot';
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice'; import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice';
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk'; 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 CardHeader from '@src/course-outline/card-header/CardHeader';
import SortableItem from '@src/course-outline/drag-helper/SortableItem'; import SortableItem from '@src/course-outline/drag-helper/SortableItem';
import TitleLink from '@src/course-outline/card-header/TitleLink'; import TitleLink from '@src/course-outline/card-header/TitleLink';
@@ -32,7 +33,7 @@ interface UnitCardProps {
onOpenPublishModal: () => void; onOpenPublishModal: () => void;
onOpenConfigureModal: () => void; onOpenConfigureModal: () => void;
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void, onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
savingStatus?: RequestStatusType; savingStatus: string;
onOpenDeleteModal: () => void; onOpenDeleteModal: () => void;
onOpenUnlinkModal: () => void; onOpenUnlinkModal: () => void;
onDuplicateSubmit: () => void; onDuplicateSubmit: () => void;
@@ -103,12 +104,12 @@ const UnitCard = ({
downstreamBlockId: id, downstreamBlockId: id,
upstreamBlockId: upstreamInfo.upstreamRef, upstreamBlockId: upstreamInfo.upstreamRef,
upstreamBlockVersionSynced: upstreamInfo.versionSynced, upstreamBlockVersionSynced: upstreamInfo.versionSynced,
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
isContainer: true, isContainer: true,
blockType: 'unit',
}; };
}, [upstreamInfo]); }, [upstreamInfo]);
const readOnly = isUnitReadOnly(unit);
// re-create actions object for customizations // re-create actions object for customizations
const actions = { ...unitActions }; const actions = { ...unitActions };
// add actions to control display of move up & down menu buton. // add actions to control display of move up & down menu buton.
@@ -246,7 +247,7 @@ const UnitCard = ({
isFormOpen={isFormOpen} isFormOpen={isFormOpen}
closeForm={closeForm} closeForm={closeForm}
onEditSubmit={handleEditSubmit} onEditSubmit={handleEditSubmit}
savingStatus={savingStatus} isDisabledEditField={readOnly || savingStatus === RequestStatus.IN_PROGRESS}
onClickDuplicate={onDuplicateSubmit} onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent} titleComponent={titleComponent}
namePrefix={namePrefix} namePrefix={namePrefix}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,13 +10,13 @@ const UnitButton = ({
unitId, unitId,
className, className,
showTitle, showTitle,
isActive, // passed from parent (SequenceNavigationTabs)
}) => { }) => {
const courseId = useSelector(getCourseId); const courseId = useSelector(getCourseId);
const sequenceId = useSelector(getSequenceId); const sequenceId = useSelector(getSequenceId);
const unit = useSelector((state) => state.models.units[unitId]); const unit = useSelector((state) => state.models.units[unitId]);
const { title, contentType } = unit || {};
const { title, contentType, isActive } = unit || {};
return ( return (
<Button <Button
@@ -37,13 +37,11 @@ UnitButton.propTypes = {
className: PropTypes.string, className: PropTypes.string,
showTitle: PropTypes.bool, showTitle: PropTypes.bool,
unitId: PropTypes.string.isRequired, unitId: PropTypes.string.isRequired,
isActive: PropTypes.bool,
}; };
UnitButton.defaultProps = { UnitButton.defaultProps = {
className: undefined, className: undefined,
showTitle: false, showTitle: false,
isActive: false,
}; };
export default UnitButton; 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 { import {
hideProcessingNotification, hideProcessingNotification,
showProcessingNotification, showProcessingNotification,
} from '@src/generic/processing-notification/data/slice'; } from '../../generic/processing-notification/data/slice';
import { handleResponseErrors } from '@src/generic/saving-error-alert'; import { handleResponseErrors } from '../../generic/saving-error-alert';
import { RequestStatus } from '@src/data/constants'; import { RequestStatus } from '../../data/constants';
import { NOTIFICATION_MESSAGES } from '@src/constants'; import { NOTIFICATION_MESSAGES } from '../../constants';
import { updateModel, updateModels } from '@src/generic/model-store'; import { updateModel, updateModels } from '../../generic/model-store';
import { messageTypes } from '../constants'; import { messageTypes } from '../constants';
import { import {
editUnitDisplayName, editUnitDisplayName,
getVerticalData, getVerticalData,
createCourseXblock, createCourseXblock,
getCourseContainerChildren, getCourseVerticalChildren,
handleCourseUnitVisibilityAndData, handleCourseUnitVisibilityAndData,
deleteUnitItem, deleteUnitItem,
duplicateUnitItem, duplicateUnitItem,
@@ -126,7 +126,7 @@ export function editCourseUnitVisibilityAndData(
} }
const courseSectionVerticalData = await getVerticalData(blockId); const courseSectionVerticalData = await getVerticalData(blockId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData)); dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
const courseVerticalChildrenData = await getCourseContainerChildren(blockId); const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification()); dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
@@ -163,7 +163,7 @@ export function createNewCourseXBlock(body, callback, blockId, sendMessageToIfra
localStorage.removeItem('staticFileNotices'); localStorage.removeItem('staticFileNotices');
} }
} }
const courseVerticalChildrenData = await getCourseContainerChildren(blockId); const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification()); dispatch(hideProcessingNotification());
if (callback) { if (callback) {
@@ -190,11 +190,11 @@ export function fetchCourseVerticalChildrenData(itemId, isSplitTestType, skipPag
} }
try { try {
const courseVerticalChildrenData = await getCourseContainerChildren(itemId); const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
if (isSplitTestType) { if (isSplitTestType) {
const blockIds = courseVerticalChildrenData.children.map(child => child.blockId); const blockIds = courseVerticalChildrenData.children.map(child => child.blockId);
const childrenDataArray = await Promise.all( const childrenDataArray = await Promise.all(
blockIds.map(blockId => getCourseContainerChildren(blockId)), blockIds.map(blockId => getCourseVerticalChildren(blockId)),
); );
const allChildren = childrenDataArray.reduce( const allChildren = childrenDataArray.reduce(
(acc, data) => acc.concat(data.children || []), (acc, data) => acc.concat(data.children || []),
@@ -239,7 +239,7 @@ export function duplicateUnitItemQuery(itemId, xblockId, callback) {
callback(courseKey, locator); callback(courseKey, locator);
const courseSectionVerticalData = await getVerticalData(itemId); const courseSectionVerticalData = await getVerticalData(itemId);
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData)); dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
const courseVerticalChildrenData = await getCourseContainerChildren(itemId); const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
dispatch(hideProcessingNotification()); dispatch(hideProcessingNotification());
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); 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. * @param unit - uses the 'upstreamInfo' object if found.
* @returns True if readOnly, False if editable. * @returns True if readOnly, False if editable.
*/ */
export const isUnitImportedFromLib = ({ upstreamInfo }: XBlock): boolean => ( export const isUnitReadOnly = ({ upstreamInfo }: XBlock): boolean => (
!!upstreamInfo !!upstreamInfo
&& !!upstreamInfo.upstreamRef && !!upstreamInfo.upstreamRef
&& upstreamInfo.upstreamRef.startsWith('lct:') && upstreamInfo.upstreamRef.startsWith('lct:')

View File

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

View File

@@ -76,7 +76,7 @@ describe('<HeaderTitle />', () => {
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled(); 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 // Override mock unit with one sourced from an upstream library
axiosMock = new MockAdapter(getAuthenticatedHttpClient()); axiosMock = new MockAdapter(getAuthenticatedHttpClient());
axiosMock axiosMock
@@ -95,7 +95,7 @@ describe('<HeaderTitle />', () => {
const { getByRole } = renderComponent(); 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(); expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
}); });

View File

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

View File

@@ -1,10 +1,4 @@
.lib-preview-xblock-changes-modal { .lib-preview-xblock-changes-modal {
border-bottom-right-radius: 0; border-bottom-right-radius: 0;
border-bottom-left-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 userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types'; import MockAdapter from 'axios-mock-adapter/types';
import { import {
act, act,
render as baseRender, render as baseRender,
screen, screen,
initializeMocks, initializeMocks,
waitFor, waitFor,
} from '@src/testUtils'; } from '../../testUtils';
import { ToastActionData } from '@src/generic/toast-context';
import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.'; import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.';
import { messageTypes } from '../constants'; import { messageTypes } from '../constants';
import { libraryBlockChangesUrl } from '../data/api'; import { libraryBlockChangesUrl } from '../data/api';
import { ToastActionData } from '../../generic/toast-context';
const usageKey = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@1'; const usageKey = 'some-id';
const defaultEventData: LibraryChangesMessageData = { const defaultEventData: LibraryChangesMessageData = {
displayName: 'Test block', displayName: 'Test block',
downstreamBlockId: usageKey, downstreamBlockId: usageKey,
upstreamBlockId: 'lct:org:lib1:unit:1', upstreamBlockId: 'lct:org:lib1:unit:1',
upstreamBlockVersionSynced: 1, upstreamBlockVersionSynced: 1,
isContainer: false, isContainer: false,
isLocallyModified: false,
blockType: 'html',
}; };
const mockSendMessageToIframe = jest.fn(); const mockSendMessageToIframe = jest.fn();
jest.mock('@src/generic/hooks/context/hooks', () => ({ jest.mock('../../generic/hooks/context/hooks', () => ({
useIframe: () => ({ useIframe: () => ({
iframeRef: { current: { contentWindow: {} as HTMLIFrameElement } }, iframeRef: { current: { contentWindow: {} as HTMLIFrameElement } },
setIframeRef: () => {}, setIframeRef: () => {},
@@ -63,6 +60,7 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument(); expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Accept changes' })).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: '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: 'New version' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Old 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(); 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 { import {
useCallback, useContext, useMemo, useState, ActionRow, Button, ModalDialog, useToggle,
} from 'react';
import {
ActionRow, Button, Icon, ModalDialog, useToggle,
} from '@openedx/paragon'; } from '@openedx/paragon';
import { Info } from '@openedx/paragon/icons'; import { Warning } from '@openedx/paragon/icons';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n'; import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { ToastContext } from '@src/generic/toast-context'; import { useEventListener } from '../../generic/hooks';
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 { messageTypes } from '../constants'; import { messageTypes } from '../constants';
import CompareChangesWidget from '../../library-authoring/component-comparison/CompareChangesWidget';
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks'; import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks';
import AlertMessage from '../../generic/alert-message';
import { useIframe } from '../../generic/hooks/context/hooks';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import messages from './messages'; import messages from './messages';
import { ToastContext } from '../../generic/toast-context';
type ConfirmationModalType = 'ignore' | 'update' | 'keep' | undefined; import LoadingButton from '../../generic/loading-button';
import Loading from '../../generic/Loading';
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}
/>
);
};
export interface LibraryChangesMessageData { export interface LibraryChangesMessageData {
displayName: string, displayName: string,
downstreamBlockId: string, downstreamBlockId: string,
upstreamBlockId: string, upstreamBlockId: string,
upstreamBlockVersionSynced: number, upstreamBlockVersionSynced: number,
isLocallyModified?: boolean,
isContainer: boolean, isContainer: boolean,
blockType?: string | null,
isReadyToSyncIndividually?: boolean,
} }
export interface PreviewLibraryXBlockChangesProps { export interface PreviewLibraryXBlockChangesProps {
@@ -128,41 +45,27 @@ export const PreviewLibraryXBlockChanges = ({
const { showToast } = useContext(ToastContext); const { showToast } = useContext(ToastContext);
const intl = useIntl(); const intl = useIntl();
const [confirmationModalType, setConfirmationModalType] = useState<ConfirmationModalType>(); // ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const acceptChangesMutation = useAcceptLibraryBlockChanges(); const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges(); const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const isTextWithLocalChanges = (blockData.blockType === 'html' && blockData.isLocallyModified);
const getBody = useCallback(() => { const getBody = useCallback(() => {
if (!blockData) { if (!blockData) {
return <Loading />; return <Loading />;
} }
if (blockData.isContainer) {
return (
<CompareContainersWidget
upstreamBlockId={blockData.upstreamBlockId}
downstreamBlockId={blockData.downstreamBlockId}
isReadyToSyncIndividually={blockData.isReadyToSyncIndividually}
/>
);
}
return ( return (
<CompareChangesWidget <CompareChangesWidget
usageKey={blockData.upstreamBlockId} usageKey={blockData.upstreamBlockId}
oldUsageKey={blockData.downstreamBlockId}
oldTitle={isTextWithLocalChanges ? blockData.displayName : undefined}
oldVersion={blockData.upstreamBlockVersionSynced || 'published'} oldVersion={blockData.upstreamBlockVersionSynced || 'published'}
newVersion="published" newVersion="published"
hasLocalChanges={isTextWithLocalChanges} isContainer={blockData.isContainer}
showNewTitle={isTextWithLocalChanges}
/> />
); );
}, [blockData, isTextWithLocalChanges]); }, [blockData]);
const updateAndRefresh = useCallback(async (accept: boolean, overrideCustomizations: boolean) => { const updateAndRefresh = useCallback(async (accept: boolean) => {
// istanbul ignore if: this should never happen // istanbul ignore if: this should never happen
if (!blockData) { if (!blockData) {
return; return;
@@ -172,10 +75,7 @@ export const PreviewLibraryXBlockChanges = ({
const failureMsg = accept ? messages.acceptChangesFailure : messages.ignoreChangesFailure; const failureMsg = accept ? messages.acceptChangesFailure : messages.ignoreChangesFailure;
try { try {
await mutation.mutateAsync({ await mutation.mutateAsync(blockData.downstreamBlockId);
blockId: blockData.downstreamBlockId,
overrideCustomizations,
});
postChange(accept); postChange(accept);
} catch (e) { } catch (e) {
showToast(intl.formatMessage(failureMsg)); showToast(intl.formatMessage(failureMsg));
@@ -184,46 +84,21 @@ export const PreviewLibraryXBlockChanges = ({
} }
}, [blockData]); }, [blockData]);
const itemIcon = getItemIcon(blockData.blockType || '');
// Build title
const defaultTitle = intl.formatMessage( const defaultTitle = intl.formatMessage(
blockData.isContainer blockData.isContainer
? messages.defaultContainerTitle ? messages.defaultContainerTitle
: messages.defaultComponentTitle, : messages.defaultComponentTitle,
{
itemIcon: <Icon size="lg" src={itemIcon} />,
},
); );
const title = blockData.displayName const title = blockData.displayName
? intl.formatMessage(messages.title, { ? intl.formatMessage(messages.title, { blockTitle: blockData?.displayName })
blockTitle: blockData?.displayName,
blockIcon: <Icon size="lg" src={itemIcon} />,
})
: defaultTitle; : 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 ( return (
<ModalDialog <ModalDialog
isOpen={isModalOpen} isOpen={isModalOpen}
onClose={closeModal} onClose={closeModal}
size="xl" size="xl"
title={ariaLabel} title={title}
className="lib-preview-xblock-changes-modal" className="lib-preview-xblock-changes-modal"
hasCloseButton hasCloseButton
isFullscreenOnMobile isFullscreenOnMobile
@@ -231,57 +106,43 @@ export const PreviewLibraryXBlockChanges = ({
> >
<ModalDialog.Header> <ModalDialog.Header>
<ModalDialog.Title> <ModalDialog.Title>
<div className="d-flex preview-title"> {title}
{title}
</div>
</ModalDialog.Title> </ModalDialog.Title>
</ModalDialog.Header> </ModalDialog.Header>
<ModalDialog.Body> <ModalDialog.Body>
{isTextWithLocalChanges && ( <AlertMessage
<AlertMessage show
show variant="warning"
variant="info" icon={Warning}
icon={Info} title={intl.formatMessage(messages.olderVersionPreviewAlert)}
title={intl.formatMessage(messages.localEditsAlert)} />
/>
)}
{getBody()} {getBody()}
</ModalDialog.Body> </ModalDialog.Body>
<ModalDialog.Footer> <ModalDialog.Footer>
<ActionRow> <ActionRow>
{isTextWithLocalChanges ? ( <LoadingButton
<Button onClick={() => updateAndRefresh(true)}
variant="tertiary" label={intl.formatMessage(messages.acceptChangesBtn)}
onClick={() => setConfirmationModalType('update')} />
> <Button
<FormattedMessage {...messages.updateToPublishedLibraryContentButton} /> variant="tertiary"
</Button> onClick={openConfirmModal}
) : ( >
<LoadingButton <FormattedMessage {...messages.ignoreChangesBtn} />
onClick={() => updateAndRefresh(true, false)} </Button>
label={intl.formatMessage(messages.acceptChangesBtn)} <ModalDialog.CloseButton variant="tertiary">
/> <FormattedMessage {...messages.cancelBtn} />
)} </ModalDialog.CloseButton>
{isTextWithLocalChanges ? (
<Button
onClick={() => setConfirmationModalType('keep')}
>
<FormattedMessage {...messages.keepCourseContentButton} />
</Button>
) : (
<Button
variant="tertiary"
onClick={() => setConfirmationModalType('ignore')}
>
<FormattedMessage {...messages.ignoreChangesBtn} />
</Button>
)}
</ActionRow> </ActionRow>
</ModalDialog.Footer> </ModalDialog.Footer>
<ConfirmationModal <DeleteModal
modalType={confirmationModalType} isOpen={isConfirmModalOpen}
onClose={() => setConfirmationModalType(undefined)} close={closeConfirmModal}
updateAndRefresh={updateAndRefresh} variant="warning"
title={intl.formatMessage(messages.confirmationTitle)}
description={intl.formatMessage(messages.confirmationDescription)}
onDeleteSubmit={() => updateAndRefresh(false)}
btnLabel={intl.formatMessage(messages.confirmationConfirmBtn)}
/> />
</ModalDialog> </ModalDialog>
); );

View File

@@ -3,17 +3,17 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({ const messages = defineMessages({
title: { title: {
id: 'authoring.course-unit.preview-changes.modal-title', id: 'authoring.course-unit.preview-changes.modal-title',
defaultMessage: 'Preview changes: {blockIcon} {blockTitle}', defaultMessage: 'Preview changes: {blockTitle}',
description: 'Preview changes modal title text', description: 'Preview changes modal title text',
}, },
defaultContainerTitle: { defaultContainerTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-unit-title', 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', description: 'Preview changes modal default title text for containers',
}, },
defaultComponentTitle: { defaultComponentTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-component-title', 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', description: 'Preview changes modal default title text for components',
}, },
acceptChangesBtn: { acceptChangesBtn: {
@@ -36,6 +36,11 @@ const messages = defineMessages({
defaultMessage: 'Failed to ignore changes', defaultMessage: 'Failed to ignore changes',
description: 'Toast message to display when ignore changes call fails', 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: { confirmationTitle: {
id: 'authoring.course-unit.preview-changes.confirmation-dialog-title', id: 'authoring.course-unit.preview-changes.confirmation-dialog-title',
defaultMessage: 'Ignore these changes?', defaultMessage: 'Ignore these changes?',
@@ -51,45 +56,10 @@ const messages = defineMessages({
defaultMessage: 'Ignore', defaultMessage: 'Ignore',
description: 'Preview changes confirmation dialog confirm button text when user clicks on ignore changes.', description: 'Preview changes confirmation dialog confirm button text when user clicks on ignore changes.',
}, },
localEditsAlert: { olderVersionPreviewAlert: {
id: 'course-authoring.review-tab.preview.loal-edits-alert', id: 'course-authoring.review-tab.preview.old-version-alert',
defaultMessage: 'This library content has local edits.', defaultMessage: 'The old version preview is the previous library version',
description: 'Alert message stating that the content has local edits', description: 'Alert message stating that older version in preview is of library block',
},
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',
}, },
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -18,7 +18,6 @@ import { FeedbackBox } from './components/Feedback';
import * as hooks from './hooks'; import * as hooks from './hooks';
import { ProblemTypeKeys } from '../../../../../data/constants/problem'; import { ProblemTypeKeys } from '../../../../../data/constants/problem';
import ExpandableTextArea from '../../../../../sharedComponents/ExpandableTextArea'; import ExpandableTextArea from '../../../../../sharedComponents/ExpandableTextArea';
import { answerRangeFormatRegex } from '../../../data/OLXParser';
const AnswerOption = ({ const AnswerOption = ({
answer, answer,
@@ -49,11 +48,6 @@ const AnswerOption = ({
? `${getConfig().STUDIO_BASE_URL}/library_assets/blocks/${blockId}/` ? `${getConfig().STUDIO_BASE_URL}/library_assets/blocks/${blockId}/`
: undefined; : undefined;
const validateAnswerRange = (value) => {
const cleanedValue = value.replace(/^\s+|\s+$/g, '');
return !cleanedValue.length || answerRangeFormatRegex.test(cleanedValue);
};
const getInputArea = () => { const getInputArea = () => {
if ([ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType)) { if ([ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType)) {
return ( return (
@@ -83,9 +77,8 @@ const AnswerOption = ({
); );
} }
// Return Answer Range View // Return Answer Range View
const isValidValue = validateAnswerRange(answer.title);
return ( return (
<Form.Group isInvalid={!isValidValue}> <div>
<Form.Control <Form.Control
as="textarea" as="textarea"
className="answer-option-textarea text-gray-500 small" className="answer-option-textarea text-gray-500 small"
@@ -95,15 +88,10 @@ const AnswerOption = ({
onChange={setAnswerTitle} onChange={setAnswerTitle}
placeholder={intl.formatMessage(messages.answerRangeTextboxPlaceholder)} placeholder={intl.formatMessage(messages.answerRangeTextboxPlaceholder)}
/> />
{!isValidValue && (
<Form.Control.Feedback type="invalid">
<FormattedMessage {...messages.answerRangeErrorText} />
</Form.Control.Feedback>
)}
<div className="pgn__form-switch-helper-text"> <div className="pgn__form-switch-helper-text">
<FormattedMessage {...messages.answerRangeHelperText} /> <FormattedMessage {...messages.answerRangeHelperText} />
</div> </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).', 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', 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; 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, isMarkdownEditorEnabledForContext,
} = useEditorContext(); } = useEditorContext();
const rawMarkdown = useSelector(selectors.problem.rawMarkdown); const rawMarkdown = useSelector(selectors.problem.rawMarkdown);
const isMarkdownEditorEnabled = useSelector(selectors.problem.isMarkdownEditorEnabled);
const showMarkdownEditorButton = isMarkdownEditorEnabledForContext && rawMarkdown; const showMarkdownEditorButton = isMarkdownEditorEnabledForContext && rawMarkdown;
const { isAdvancedCardsVisible, showAdvancedCards } = showAdvancedSettingsCards(); const { isAdvancedCardsVisible, showAdvancedCards } = showAdvancedSettingsCards();
const feedbackCard = () => { const feedbackCard = () => {
@@ -162,7 +161,7 @@ const SettingsWidget = ({
<div className="my-3"> <div className="my-3">
<SwitchEditorCard problemType={problemType} editorType="advanced" /> <SwitchEditorCard problemType={problemType} editorType="advanced" />
</div> </div>
{ (showMarkdownEditorButton && !isMarkdownEditorEnabled) // Only show button if not already in markdown editor { showMarkdownEditorButton
&& ( && (
<div className="my-3"> <div className="my-3">
<SwitchEditorCard problemType={problemType} editorType="markdown" /> <SwitchEditorCard problemType={problemType} editorType="markdown" />

View File

@@ -2,11 +2,10 @@ import React from 'react';
import { ProblemTypeKeys } from '@src/editors/data/constants/problem'; import { ProblemTypeKeys } from '@src/editors/data/constants/problem';
import { screen, initializeMocks } from '@src/testUtils'; 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 { mockWaffleFlags } from '@src/data/apiHooks.mock';
import * as hooks from './hooks'; import * as hooks from './hooks';
import { SettingsWidgetInternal as SettingsWidget } from '.'; import { SettingsWidgetInternal as SettingsWidget } from '.';
import { ProblemEditorContextProvider } from '../ProblemEditorContext';
jest.mock('./settingsComponents/GeneralFeedback', () => 'GeneralFeedback'); jest.mock('./settingsComponents/GeneralFeedback', () => 'GeneralFeedback');
jest.mock('./settingsComponents/GroupFeedback', () => 'GroupFeedback'); jest.mock('./settingsComponents/GroupFeedback', () => 'GroupFeedback');
@@ -24,6 +23,7 @@ describe('SettingsWidget', () => {
const showAdvancedSettingsCardsBaseProps = { const showAdvancedSettingsCardsBaseProps = {
isAdvancedCardsVisible: false, isAdvancedCardsVisible: false,
showAdvancedCards: jest.fn().mockName('showAdvancedSettingsCards.showAdvancedCards'), showAdvancedCards: jest.fn().mockName('showAdvancedSettingsCards.showAdvancedCards'),
setResetTrue: jest.fn().mockName('showAdvancedSettingsCards.setResetTrue'),
}; };
const props = { 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(() => { beforeEach(() => {
initializeMocks(); initializeMocks();
}); });
@@ -68,7 +56,7 @@ describe('SettingsWidget', () => {
describe('behavior', () => { describe('behavior', () => {
it('calls showAdvancedSettingsCards when initialized', () => { it('calls showAdvancedSettingsCards when initialized', () => {
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps); jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps);
renderSettingsWidget(); editorRender(<SettingsWidget {...props} />);
expect(hooks.showAdvancedSettingsCards).toHaveBeenCalled(); expect(hooks.showAdvancedSettingsCards).toHaveBeenCalled();
}); });
}); });
@@ -76,7 +64,7 @@ describe('SettingsWidget', () => {
describe('renders', () => { describe('renders', () => {
test('renders Settings widget page', () => { test('renders Settings widget page', () => {
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps); jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps);
renderSettingsWidget(); editorRender(<SettingsWidget {...props} />);
expect(screen.getByText('Show advanced settings')).toBeInTheDocument(); expect(screen.getByText('Show advanced settings')).toBeInTheDocument();
}); });
@@ -86,7 +74,7 @@ describe('SettingsWidget', () => {
isAdvancedCardsVisible: true, isAdvancedCardsVisible: true,
}; };
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps); jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
const { container } = renderSettingsWidget(); const { container } = editorRender(<SettingsWidget {...props} />);
expect(screen.queryByText('Show advanced settings')).not.toBeInTheDocument(); expect(screen.queryByText('Show advanced settings')).not.toBeInTheDocument();
expect(container.querySelector('showanswercard')).toBeInTheDocument(); expect(container.querySelector('showanswercard')).toBeInTheDocument();
expect(container.querySelector('resetcard')).toBeInTheDocument(); expect(container.querySelector('resetcard')).toBeInTheDocument();
@@ -98,49 +86,12 @@ describe('SettingsWidget', () => {
isAdvancedCardsVisible: true, isAdvancedCardsVisible: true,
}; };
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps); 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(); 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', () => { describe('isLibrary', () => {
const libraryProps = { const libraryProps = {
@@ -149,7 +100,7 @@ describe('SettingsWidget', () => {
}; };
test('renders Settings widget page', () => { test('renders Settings widget page', () => {
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps); jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps);
const { container } = renderSettingsWidget(libraryProps); const { container } = editorRender(<SettingsWidget {...libraryProps} />);
expect(container.querySelector('timercard')).not.toBeInTheDocument(); expect(container.querySelector('timercard')).not.toBeInTheDocument();
expect(container.querySelector('resetcard')).not.toBeInTheDocument(); expect(container.querySelector('resetcard')).not.toBeInTheDocument();
expect(container.querySelector('typecard')).toBeInTheDocument(); expect(container.querySelector('typecard')).toBeInTheDocument();
@@ -163,7 +114,7 @@ describe('SettingsWidget', () => {
isAdvancedCardsVisible: true, isAdvancedCardsVisible: true,
}; };
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps); jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
const { container } = renderSettingsWidget(libraryProps); const { container } = editorRender(<SettingsWidget {...libraryProps} />);
expect(screen.queryByText('Show advanced settings')).not.toBeInTheDocument(); expect(screen.queryByText('Show advanced settings')).not.toBeInTheDocument();
expect(container.querySelector('showanswearscard')).not.toBeInTheDocument(); expect(container.querySelector('showanswearscard')).not.toBeInTheDocument();
expect(container.querySelector('resetcard')).not.toBeInTheDocument(); expect(container.querySelector('resetcard')).not.toBeInTheDocument();
@@ -177,7 +128,7 @@ describe('SettingsWidget', () => {
isAdvancedCardsVisible: true, isAdvancedCardsVisible: true,
}; };
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps); 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(); expect(container.querySelector('randomization')).toBeInTheDocument();
}); });
}); });

View File

@@ -169,13 +169,13 @@ const messages = defineMessages({
}, },
'ConfirmSwitchMessage-advanced': { 'ConfirmSwitchMessage-advanced': {
id: 'authoring.problemeditor.settings.switchtoeditor.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', description: 'message to confirm that a user wants to use the advanced editor',
}, },
'ConfirmSwitchMessage-markdown': { 'ConfirmSwitchMessage-markdown': {
id: 'authoring.problemeditor.settings.switchtoeditor.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.', 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 markdown editor', description: 'message to confirm that a user wants to use the advanced editor',
}, },
'ConfirmSwitchMessageTitle-advanced': { 'ConfirmSwitchMessageTitle-advanced': {
id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchMessageTitle.advanced', id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchMessageTitle.advanced',

View File

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

View File

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

View File

@@ -90,10 +90,7 @@ export const parseState = ({
return { return {
settings: { settings: {
...settings, ...settings,
// If the save action isnt triggered from the Markdown editor, the Markdown content might be outdated. Since the ...(isMarkdownEditorEnabled && { markdown: contentString }),
// 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,
markdown_edited: isMarkdownEditorEnabled, markdown_edited: isMarkdownEditorEnabled,
}, },
olx: isAdvanced || isMarkdownEditorEnabled ? rawOLX : reactBuiltOlx, olx: isAdvanced || isMarkdownEditorEnabled ? rawOLX : reactBuiltOlx,

View File

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

View File

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

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