Compare commits
1 Commits
release/ul
...
chris/FAL-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49c28de286 |
1
.env
1
.env
@@ -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'
|
|
||||||
|
|||||||
@@ -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'
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
15
.github/workflows/add-to-cc-board.yml
vendored
15
.github/workflows/add-to-cc-board.yml
vendored
@@ -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 }}
|
|
||||||
10
.github/workflows/validate.yml
vendored
10
.github/workflows/validate.yml
vendored
@@ -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:
|
||||||
|
|||||||
@@ -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
9604
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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"
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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',
|
|
||||||
};
|
|
||||||
@@ -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';
|
|
||||||
@@ -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,
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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,
|
|
||||||
});
|
|
||||||
@@ -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 || ''}`;
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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';
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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"> </span>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
<ActionRow.Spacer />
|
|
||||||
{isClickable && <Icon size="md" src={ChevronRight} />}
|
|
||||||
</ActionRow>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ContainerRow;
|
|
||||||
@@ -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);
|
|
||||||
};
|
|
||||||
@@ -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),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
.compare-changes-widget {
|
|
||||||
.compare-card {
|
|
||||||
min-height: 350px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.big-icon {
|
|
||||||
height: 68px;
|
|
||||||
width: 68px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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',
|
]);
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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 />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
218
src/course-unit/data/api.js
Normal 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));
|
||||||
|
}
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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 }));
|
||||||
|
|||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
@@ -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:')
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>;
|
|
||||||
};
|
|
||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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('');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,10 +90,7 @@ export const parseState = ({
|
|||||||
return {
|
return {
|
||||||
settings: {
|
settings: {
|
||||||
...settings,
|
...settings,
|
||||||
// If the save action isn’t 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, we’re 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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user