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
|
||||
PARAGON_THEME_URLS={}
|
||||
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
|
||||
PARAGON_THEME_URLS={}
|
||||
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
|
||||
BBB_LEARN_MORE_URL=''
|
||||
INVITE_STUDENTS_EMAIL_TO="someone@domain.com"
|
||||
ENABLE_HOME_PAGE_COURSE_API_V2=true
|
||||
ENABLE_CHECKLIST_QUALITY=true
|
||||
ENABLE_GRADING_METHOD_IN_PROBLEMS=false
|
||||
# "Multi-level" blocks are unsupported in libraries
|
||||
|
||||
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:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
- run: make validate.ci
|
||||
- name: Archive code coverage results
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: code-coverage-report
|
||||
path: coverage/*.*
|
||||
@@ -27,11 +27,9 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Download code coverage results
|
||||
uses: actions/download-artifact@v6
|
||||
uses: actions/download-artifact@v5
|
||||
with:
|
||||
pattern: code-coverage-report
|
||||
path: coverage
|
||||
merge-multiple: true
|
||||
name: code-coverage-report
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
|
||||
@@ -11,5 +11,4 @@ coverage:
|
||||
ignore:
|
||||
- "src/grading-settings/grading-scale/react-ranger.js"
|
||||
- "src/generic/DraggableList/verticalSortableList.ts"
|
||||
- "src/container-comparison/data/api.mock.ts"
|
||||
- "src/index.js"
|
||||
|
||||
9604
package-lock.json
generated
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/browserslist-config": "1.5.0",
|
||||
"@edx/frontend-component-footer": "^14.9.0",
|
||||
"@edx/frontend-component-header": "^8.1.0",
|
||||
"@edx/frontend-component-header": "^6.2.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^7.2.0",
|
||||
"@edx/frontend-platform": "^8.4.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
@@ -59,17 +59,17 @@
|
||||
"@openedx-plugins/course-app-teams": "file:plugins/course-apps/teams",
|
||||
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
||||
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
||||
"@openedx/frontend-build": "^14.6.2",
|
||||
"@openedx/frontend-build": "^14.5.0",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^23.5.0",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
"@tanstack/react-query": "5.90.5",
|
||||
"@tinymce/tinymce-react": "^6.0.0",
|
||||
"@tanstack/react-query": "4.40.1",
|
||||
"@tinymce/tinymce-react": "^3.14.0",
|
||||
"classnames": "2.5.1",
|
||||
"codemirror": "^6.0.0",
|
||||
"email-validator": "2.0.4",
|
||||
"fast-xml-parser": "^5.0.0",
|
||||
"fast-xml-parser": "^4.0.10",
|
||||
"file-saver": "^2.0.5",
|
||||
"formik": "2.4.6",
|
||||
"frontend-components-tinymce-advanced-plugins": "^1.0.3",
|
||||
@@ -97,7 +97,7 @@
|
||||
"redux-thunk": "^2.4.1",
|
||||
"reselect": "^4.1.5",
|
||||
"tinymce": "^5.10.4",
|
||||
"universal-cookie": "^8.0.0",
|
||||
"universal-cookie": "^4.0.4",
|
||||
"uuid": "^11.1.0",
|
||||
"xmlchecker": "^0.1.0",
|
||||
"yup": "0.32.11"
|
||||
|
||||
@@ -125,13 +125,10 @@ describe('ORASettings', () => {
|
||||
});
|
||||
|
||||
it('Displays title, helper text and badge when flexible peer grading button is enabled', async () => {
|
||||
await mockStore({ apiStatus: 200, enabled: true });
|
||||
renderComponent();
|
||||
await mockStore({ apiStatus: 200, enabled: true });
|
||||
|
||||
const checkbox = await screen.getByRole('checkbox', { name: /Flex Peer Grading/ });
|
||||
expect(checkbox).toBeChecked();
|
||||
|
||||
await waitFor(() => {
|
||||
waitFor(() => {
|
||||
const label = screen.getByText(messages.enableFlexPeerGradeLabel.defaultMessage);
|
||||
const enableBadge = screen.getByTestId('enable-badge');
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
'authoring.proctoring.alert.error': {
|
||||
id: 'authoring.proctoring.alert.error',
|
||||
defaultMessage: 'We encountered a technical error while trying to save proctored exam settings. This might be a temporary issue, so please try again in a few minutes. If the problem persists, please go to the {support_link} for help.',
|
||||
description: 'Alert message for proctoring settings save error.',
|
||||
},
|
||||
'authoring.proctoring.alert.forbidden': {
|
||||
id: 'authoring.proctoring.alert.forbidden',
|
||||
defaultMessage: 'You do not have permission to edit proctored exam settings for this course. If you are a course team member and this problem persists, please go to the {support_link} for help.',
|
||||
|
||||
@@ -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 clipboardSubsection } from './clipboardSubsection';
|
||||
export { default as clipboardXBlock } from './clipboardXBlock';
|
||||
export { default as clipboardSection } from './clipboardSection';
|
||||
@@ -30,7 +30,7 @@ const SettingCard = ({
|
||||
const { deprecated, help, displayName } = settingData;
|
||||
const initialValue = JSON.stringify(settingData.value, null, 4);
|
||||
const [isOpen, open, close] = useToggle(false);
|
||||
const [target, setTarget] = useState<HTMLButtonElement | null>(null);
|
||||
const [target, setTarget] = useState(null);
|
||||
const [newValue, setNewValue] = useState(initialValue);
|
||||
|
||||
const handleSettingChange = (e) => {
|
||||
@@ -118,7 +118,7 @@ SettingCard.propTypes = {
|
||||
deprecated: PropTypes.bool,
|
||||
help: PropTypes.string,
|
||||
displayName: PropTypes.string,
|
||||
value: PropTypes.oneOfType([
|
||||
value: PropTypes.PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.bool,
|
||||
PropTypes.number,
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
@@ -21,6 +22,7 @@ jest.mock('react-textarea-autosize', () => jest.fn((props) => (
|
||||
<textarea
|
||||
{...props}
|
||||
onFocus={() => {}}
|
||||
onBlur={() => {}}
|
||||
/>
|
||||
)));
|
||||
|
||||
@@ -84,10 +86,10 @@ describe('<SettingCard />', () => {
|
||||
await waitFor(() => {
|
||||
expect(inputBox).toHaveValue('3, 2, 1');
|
||||
});
|
||||
await user.tab(); // blur off of the input.
|
||||
await waitFor(() => {
|
||||
await (async () => {
|
||||
expect(setEdited).toHaveBeenCalled();
|
||||
expect(handleBlur).toHaveBeenCalled();
|
||||
});
|
||||
fireEvent.focusOut(inputBox);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 waitFor(() => {
|
||||
waitFor(() => {
|
||||
expect(input.value).toBe(newInputValue);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +96,7 @@ describe('CertificateSignatories', () => {
|
||||
expect(useCreateSignatory().handleAddSignatory).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.skip('calls remove for the correct signatory when delete icon is clicked', async () => {
|
||||
it('calls remove for the correct signatory when delete icon is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getAllByRole } = renderComponent(defaultProps);
|
||||
|
||||
@@ -105,9 +105,7 @@ describe('CertificateSignatories', () => {
|
||||
|
||||
await user.click(deleteIcons[0]);
|
||||
|
||||
// FIXME: this isn't called because the whole 'useEditSignatory' hook
|
||||
// which calls it is mocked out.
|
||||
await waitFor(() => {
|
||||
waitFor(() => {
|
||||
expect(mockArrayHelpers.remove).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { render, waitFor } from '@testing-library/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
@@ -30,7 +30,6 @@ const initialState = {
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
index: 0,
|
||||
...signatoriesMock[0],
|
||||
showDeleteButton: true,
|
||||
isEdit: true,
|
||||
@@ -63,36 +62,31 @@ describe('Signatory Component', () => {
|
||||
it('handles input change', async () => {
|
||||
const user = userEvent.setup();
|
||||
const handleChange = jest.fn();
|
||||
renderSignatory({ ...defaultProps, handleChange });
|
||||
const input = screen.getByPlaceholderText(messages.namePlaceholder.defaultMessage);
|
||||
const { getByPlaceholderText } = renderSignatory({ ...defaultProps, handleChange });
|
||||
const input = getByPlaceholderText(messages.namePlaceholder.defaultMessage);
|
||||
const newInputValue = 'Jane Doe';
|
||||
|
||||
expect(handleChange).not.toHaveBeenCalled();
|
||||
expect(input.value).not.toBe(newInputValue);
|
||||
await user.type(input, newInputValue);
|
||||
await user.type(input, newInputValue, { name: 'signatories[0].name' });
|
||||
|
||||
await waitFor(() => {
|
||||
// This is not a great test; handleChange() gets called for each key press:
|
||||
expect(handleChange).toHaveBeenCalledTimes(newInputValue.length);
|
||||
// And the input value never actually changes because it's a controlled component
|
||||
// and we pass the name in as a prop, which hasn't changed.
|
||||
// expect(input.value).toBe(newInputValue);
|
||||
waitFor(() => {
|
||||
expect(handleChange).toHaveBeenCalledWith(expect.anything());
|
||||
expect(input.value).toBe(newInputValue);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens image upload modal on button click', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByRole, queryByTestId } = renderSignatory(defaultProps);
|
||||
const { getByRole, queryByRole } = renderSignatory(defaultProps);
|
||||
const replaceButton = getByRole(
|
||||
'button',
|
||||
{ name: messages.uploadImageButton.defaultMessage.replace('{uploadText}', messages.uploadModalReplace.defaultMessage) },
|
||||
);
|
||||
|
||||
expect(queryByTestId('dropzone-container')).not.toBeInTheDocument();
|
||||
expect(queryByRole('presentation')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(replaceButton);
|
||||
|
||||
expect(queryByTestId('dropzone-container')).toBeInTheDocument();
|
||||
expect(getByRole('presentation')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows confirm modal on delete icon click', async () => {
|
||||
|
||||
@@ -62,7 +62,7 @@ describe('HeaderButtons Component', () => {
|
||||
const dropdownButton = getByRole('button', { name: certificatesDataMock.courseModes[0] });
|
||||
await user.click(dropdownButton);
|
||||
|
||||
const verifiedMode = getByRole('button', { name: certificatesDataMock.courseModes[1] });
|
||||
const verifiedMode = await getByRole('button', { name: certificatesDataMock.courseModes[1] });
|
||||
await user.click(verifiedMode);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -7,7 +7,6 @@ export const STATEFUL_BUTTON_STATES = {
|
||||
default: 'default',
|
||||
pending: 'pending',
|
||||
error: 'error',
|
||||
disable: 'disable',
|
||||
};
|
||||
|
||||
export const USER_ROLES = {
|
||||
@@ -62,8 +61,6 @@ export const COURSE_BLOCK_NAMES = ({
|
||||
libraryContent: { id: 'library_content', name: 'Library content' },
|
||||
splitTest: { id: 'split_test', name: 'Split Test' },
|
||||
component: { id: 'component', name: 'Component' },
|
||||
itembank: { id: 'itembank', name: 'Problem Bank' },
|
||||
legacyLibraryContent: { id: 'library_content', name: 'Randomized Content Block' },
|
||||
});
|
||||
|
||||
export const STUDIO_CLIPBOARD_CHANNEL = 'studio_clipboard_channel';
|
||||
@@ -110,9 +107,3 @@ export const iframeMessageTypes = {
|
||||
xblockEvent: 'xblock-event',
|
||||
xblockScroll: 'xblock-scroll',
|
||||
};
|
||||
|
||||
export const BROKEN = 'broken';
|
||||
|
||||
export const LOCKED = 'locked';
|
||||
|
||||
export const MANUAL = 'manual';
|
||||
|
||||
@@ -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}
|
||||
ref={/** @type {React.RefObject} */(selectRef)}
|
||||
isMulti
|
||||
isLoading={updateTags.isPending}
|
||||
isDisabled={updateTags.isPending}
|
||||
isLoading={updateTags.isLoading}
|
||||
isDisabled={updateTags.isLoading}
|
||||
name="tags-select"
|
||||
placeholder={intl.formatMessage(messages.collapsibleAddTagsPlaceholderText)}
|
||||
isSearchable
|
||||
|
||||
@@ -37,9 +37,3 @@
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
// Fix a bug with a toast on edit tags sheet component: can't click on close toast button
|
||||
// https://github.com/openedx/frontend-app-authoring/issues/1898
|
||||
#toast-root[data-focus-on-hidden] {
|
||||
pointer-events: initial !important;
|
||||
}
|
||||
|
||||
@@ -719,16 +719,14 @@ describe('<ContentTagsDrawer />', () => {
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.put[0].url).toEqual(url));
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledTimes(5);
|
||||
expect(mockInvalidateQueries).toHaveBeenNthCalledWith(5, {
|
||||
queryKey: [
|
||||
'contentLibrary',
|
||||
'lib:org:lib',
|
||||
'content',
|
||||
'container',
|
||||
containerId,
|
||||
'children',
|
||||
],
|
||||
});
|
||||
expect(mockInvalidateQueries).toHaveBeenNthCalledWith(5, [
|
||||
'contentLibrary',
|
||||
'lib:org:lib',
|
||||
'content',
|
||||
'container',
|
||||
containerId,
|
||||
'children',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-check
|
||||
import { useMemo } from 'react';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import {
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
useQueryClient,
|
||||
} from '@tanstack/react-query';
|
||||
import { useParams } from 'react-router';
|
||||
import { TagData, TagListData } from '@src/taxonomy/data/types';
|
||||
import {
|
||||
getTaxonomyTagsData,
|
||||
getContentTaxonomyTagsData,
|
||||
@@ -17,16 +17,18 @@ import {
|
||||
} from './api';
|
||||
import { libraryAuthoringQueryKeys, libraryQueryPredicate, xblockQueryKeys } from '../../library-authoring/data/apiHooks';
|
||||
import { getLibraryId } from '../../generic/key-utils';
|
||||
import { UpdateTagsData } from './types';
|
||||
|
||||
/** @typedef {import("../../taxonomy/data/types.js").TagListData} TagListData */
|
||||
/** @typedef {import("../../taxonomy/data/types.js").TagData} TagData */
|
||||
|
||||
/**
|
||||
* Builds the query to get the taxonomy tags
|
||||
* @param taxonomyId The id of the taxonomy to fetch tags for
|
||||
* @param parentTag The tag whose children we're loading, if any
|
||||
* @param searchTerm The term passed in to perform search on tags
|
||||
* @param numPages How many pages of tags to load at this level
|
||||
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
|
||||
* @param {string|null} parentTag The tag whose children we're loading, if any
|
||||
* @param {string} searchTerm The term passed in to perform search on tags
|
||||
* @param {number} numPages How many pages of tags to load at this level
|
||||
*/
|
||||
export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null = null, numPages = 1, searchTerm = '') => {
|
||||
export const useTaxonomyTagsData = (taxonomyId, parentTag = null, numPages = 1, searchTerm = '') => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const queryFn = async ({ queryKey }) => {
|
||||
@@ -34,7 +36,8 @@ export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null
|
||||
return getTaxonomyTagsData(taxonomyId, { parentTag: parentTag || '', searchTerm, page });
|
||||
};
|
||||
|
||||
const queries: { queryKey: any[]; queryFn: typeof queryFn; staleTime: number }[] = [];
|
||||
/** @type {{queryKey: any[], queryFn: typeof queryFn, staleTime: number}[]} */
|
||||
const queries = [];
|
||||
for (let page = 1; page <= numPages; page++) {
|
||||
queries.push(
|
||||
{ queryKey: ['taxonomyTags', taxonomyId, parentTag, page, searchTerm], queryFn, staleTime: Infinity },
|
||||
@@ -51,7 +54,8 @@ export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null
|
||||
const preLoadedData = new Map();
|
||||
|
||||
const newTags = dataPages.map(result => {
|
||||
const simplifiedTagsList: TagData[] = [];
|
||||
/** @type {TagData[]} */
|
||||
const simplifiedTagsList = [];
|
||||
|
||||
result.data?.results?.forEach((tag) => {
|
||||
if (tag.parentValue === parentTag) {
|
||||
@@ -69,7 +73,8 @@ export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null
|
||||
// Store the pre-loaded descendants into the query cache:
|
||||
preLoadedData.forEach((tags, parentValue) => {
|
||||
const queryKey = ['taxonomyTags', taxonomyId, parentValue, 1, searchTerm];
|
||||
const cachedData: TagListData = {
|
||||
/** @type {TagListData} */
|
||||
const cachedData = {
|
||||
next: '',
|
||||
previous: '',
|
||||
count: tags.length,
|
||||
@@ -96,9 +101,9 @@ export const useTaxonomyTagsData = (taxonomyId: number, parentTag: string | null
|
||||
|
||||
/**
|
||||
* Builds the query to get the taxonomy tags applied to the content object
|
||||
* @param contentId The ID of the content object to fetch the applied tags for (e.g. an XBlock usage key)
|
||||
* @param {string} contentId The ID of the content object to fetch the applied tags for (e.g. an XBlock usage key)
|
||||
*/
|
||||
export const useContentTaxonomyTagsData = (contentId: string) => (
|
||||
export const useContentTaxonomyTagsData = (contentId) => (
|
||||
useQuery({
|
||||
queryKey: ['contentTaxonomyTags', contentId],
|
||||
queryFn: () => getContentTaxonomyTagsData(contentId),
|
||||
@@ -107,30 +112,37 @@ export const useContentTaxonomyTagsData = (contentId: string) => (
|
||||
|
||||
/**
|
||||
* Builds the query to get meta data about the content object
|
||||
* @param contentId The id of the content object
|
||||
* @param enabled Flag to enable/disable the query
|
||||
* @param {string} contentId The id of the content object
|
||||
* @param {boolean} enabled Flag to enable/disable the query
|
||||
*/
|
||||
export const useContentData = (contentId: string, enabled: boolean) => (
|
||||
export const useContentData = (contentId, enabled) => (
|
||||
useQuery({
|
||||
queryKey: ['contentData', contentId],
|
||||
queryFn: () => getContentData(contentId),
|
||||
queryFn: enabled ? () => getContentData(contentId) : undefined,
|
||||
enabled,
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Builds the mutation to update the tags applied to the content object
|
||||
* @param contentId The id of the content object to update tags for
|
||||
* @param {string} contentId The id of the content object to update tags for
|
||||
*/
|
||||
export const useContentTaxonomyTagsUpdater = (contentId: string) => {
|
||||
export const useContentTaxonomyTagsUpdater = (contentId) => {
|
||||
const queryClient = useQueryClient();
|
||||
const unitIframe = window.frames['xblock-iframe'];
|
||||
const { containerId } = useParams();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ tagsData }: { tagsData: Promise<UpdateTagsData[]> }) => (
|
||||
updateContentTaxonomyTags(contentId, tagsData)
|
||||
),
|
||||
/**
|
||||
* @type {import("@tanstack/react-query").MutateFunction<
|
||||
* any,
|
||||
* any,
|
||||
* {
|
||||
* tagsData: Promise<import("./types.js").UpdateTagsData[]>
|
||||
* }
|
||||
* >}
|
||||
*/
|
||||
mutationFn: ({ tagsData }) => updateContentTaxonomyTags(contentId, tagsData),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['contentTaxonomyTags', contentId] });
|
||||
/// Invalidate query with pattern on course outline
|
||||
@@ -145,13 +157,13 @@ export const useContentTaxonomyTagsUpdater = (contentId: string) => {
|
||||
// Obtain library id from contentId
|
||||
const libraryId = getLibraryId(contentId);
|
||||
// Invalidate component metadata to update tags count
|
||||
queryClient.invalidateQueries({ queryKey: xblockQueryKeys.componentMetadata(contentId) });
|
||||
queryClient.invalidateQueries(xblockQueryKeys.componentMetadata(contentId));
|
||||
// Invalidate content search to update tags count
|
||||
queryClient.invalidateQueries({ queryKey: ['content_search'], predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
queryClient.invalidateQueries(['content_search'], { predicate: (query) => libraryQueryPredicate(query, libraryId) });
|
||||
// If the tags for an item were edited from a container page (Unit, Subsection, Section),
|
||||
// invalidate children query to fetch count again.
|
||||
if (containerId) {
|
||||
queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.containerChildren(containerId) });
|
||||
queryClient.invalidateQueries(libraryAuthoringQueryKeys.containerChildren(containerId));
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -114,7 +114,7 @@ describe('<CourseLibraries />', () => {
|
||||
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
|
||||
await user.click(dismissBtn);
|
||||
expect(allTab).toHaveAttribute('aria-selected', 'true');
|
||||
await waitFor(() => expect(alert).not.toBeInTheDocument());
|
||||
waitFor(() => expect(alert).not.toBeInTheDocument());
|
||||
// review updates button
|
||||
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
|
||||
await user.click(reviewActionBtn);
|
||||
@@ -327,19 +327,4 @@ describe('<CourseLibraries ReviewTab />', () => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
|
||||
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
|
||||
});
|
||||
|
||||
it('should show sync modal with local changes', async () => {
|
||||
const itemIndex = 3;
|
||||
const user = userEvent.setup();
|
||||
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
|
||||
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
|
||||
expect(previewBtns.length).toEqual(7);
|
||||
await user.click(previewBtns[itemIndex]);
|
||||
|
||||
expect(screen.getByText('This library content has local edits.')).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /course content/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /published library content/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /update to published library content/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /keep course content/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,14 +32,14 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
|
||||
onReview,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { data, isPending } = useEntityLinksSummaryByDownstreamContext(courseId);
|
||||
const { data, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
|
||||
const outOfSyncCount = data?.reduce((count, lib) => count + (lib.readyToSyncCount || 0), 0);
|
||||
const lastPublishedDate = data?.map(lib => new Date(lib.lastPublishedAt || 0).getTime())
|
||||
.reduce((acc, lastPublished) => Math.max(lastPublished, acc), 0);
|
||||
const alertKey = `outOfSyncCountAlert-${courseId}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (isPending) {
|
||||
if (isLoading) {
|
||||
return;
|
||||
}
|
||||
if (outOfSyncCount === 0) {
|
||||
@@ -50,7 +50,7 @@ export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
|
||||
const dismissedAlertDate = parseInt(localStorage.getItem(alertKey) ?? '0', 10);
|
||||
|
||||
setShowAlert((lastPublishedDate ?? 0) > dismissedAlertDate);
|
||||
}, [outOfSyncCount, lastPublishedDate, isPending, data]);
|
||||
}, [outOfSyncCount, lastPublishedDate, isLoading, data]);
|
||||
|
||||
const dismissAlert = () => {
|
||||
setShowAlert(false);
|
||||
|
||||
@@ -144,7 +144,7 @@ const ItemReviewList = ({
|
||||
|
||||
const {
|
||||
hits,
|
||||
isPending: isIndexDataPending,
|
||||
isLoading: isIndexDataLoading,
|
||||
hasError,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
@@ -173,8 +173,6 @@ const ItemReviewList = ({
|
||||
upstreamBlockId: outOfSyncItemsByKey[info.usageKey].upstreamKey,
|
||||
upstreamBlockVersionSynced: outOfSyncItemsByKey[info.usageKey].versionSynced,
|
||||
isContainer: info.blockType === 'vertical' || info.blockType === 'sequential' || info.blockType === 'chapter',
|
||||
blockType: info.blockType,
|
||||
isLocallyModified: outOfSyncItemsByKey[info.usageKey].downstreamIsModified,
|
||||
});
|
||||
}, [outOfSyncItemsByKey]);
|
||||
|
||||
@@ -215,10 +213,7 @@ const ItemReviewList = ({
|
||||
|
||||
const updateBlock = useCallback(async (info: ContentHit) => {
|
||||
try {
|
||||
await acceptChangesMutation.mutateAsync({
|
||||
blockId: info.usageKey,
|
||||
overrideCustomizations: info.blockType === 'html' && outOfSyncItemsByKey[info.usageKey].downstreamIsModified,
|
||||
});
|
||||
await acceptChangesMutation.mutateAsync(info.usageKey);
|
||||
reloadLinks(info.usageKey);
|
||||
showToast(intl.formatMessage(
|
||||
messages.updateSingleBlockSuccess,
|
||||
@@ -235,9 +230,7 @@ const ItemReviewList = ({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await ignoreChangesMutation.mutateAsync({
|
||||
blockId: blockData.downstreamBlockId,
|
||||
});
|
||||
await ignoreChangesMutation.mutateAsync(blockData.downstreamBlockId);
|
||||
reloadLinks(blockData.downstreamBlockId);
|
||||
showToast(intl.formatMessage(
|
||||
messages.ignoreSingleBlockSuccess,
|
||||
@@ -250,7 +243,7 @@ const ItemReviewList = ({
|
||||
}
|
||||
}, [blockData]);
|
||||
|
||||
if (isIndexDataPending) {
|
||||
if (isIndexDataLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
@@ -321,7 +314,7 @@ const ReviewTabContent = ({ courseId }: Props) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
data: outOfSyncItems,
|
||||
isPending: isSyncItemsLoading,
|
||||
isLoading: isSyncItemsLoading,
|
||||
isError,
|
||||
error,
|
||||
} = useEntityLinks({
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"downstreamIsModified": false,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
@@ -27,7 +26,6 @@
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"downstreamIsModified": false,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
@@ -43,7 +41,6 @@
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 16,
|
||||
"versionDeclined": null,
|
||||
"downstreamIsModified": true,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
@@ -59,7 +56,6 @@
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"downstreamIsModified": false,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
@@ -75,7 +71,6 @@
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"downstreamIsModified": false,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
@@ -91,7 +86,6 @@
|
||||
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
|
||||
"versionSynced": 2,
|
||||
"versionDeclined": null,
|
||||
"downstreamIsModified": false,
|
||||
"created": "2025-02-08T14:07:05.588484Z",
|
||||
"updated": "2025-02-08T14:07:05.588484Z"
|
||||
},
|
||||
|
||||
@@ -29,7 +29,6 @@ export interface BasePublishableEntityLink {
|
||||
created: string;
|
||||
updated: string;
|
||||
readyToSync: boolean;
|
||||
downstreamIsModified: boolean;
|
||||
}
|
||||
|
||||
export interface ComponentPublishableEntityLink extends BasePublishableEntityLink {
|
||||
|
||||
@@ -32,7 +32,7 @@ const messages = defineMessages({
|
||||
description: 'Tab title for review tab',
|
||||
},
|
||||
reviewTabDescriptionEmpty: {
|
||||
id: 'course-authoring.course-libraries.tab.review.description-no-links',
|
||||
id: 'course-authoring.course-libraries.tab.home.description-no-links',
|
||||
defaultMessage: 'All components are up to date',
|
||||
description: 'Description text for home tab',
|
||||
},
|
||||
|
||||
@@ -33,10 +33,10 @@ jest.mock('react-redux', () => ({
|
||||
expect(newBtn).toBeInTheDocument();
|
||||
const useBtn = await screen.findByRole('button', { name: `Use ${containerType} from library` });
|
||||
expect(useBtn).toBeInTheDocument();
|
||||
await userEvent.click(newBtn);
|
||||
await waitFor(() => expect(newClickHandler).toHaveBeenCalled());
|
||||
await userEvent.click(useBtn);
|
||||
await waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
|
||||
userEvent.click(newBtn);
|
||||
waitFor(() => expect(newClickHandler).toHaveBeenCalled());
|
||||
userEvent.click(useBtn);
|
||||
waitFor(() => expect(useFromLibClickHandler).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
import CardHeader from './CardHeader';
|
||||
import TitleButton from './TitleButton';
|
||||
import messages from './messages';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
const onExpandMock = jest.fn();
|
||||
const onClickMenuButtonMock = jest.fn();
|
||||
@@ -233,6 +232,16 @@ describe('<CardHeader />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('check is field disabled when isDisabledEditField is true', async () => {
|
||||
renderComponent({
|
||||
...cardHeaderProps,
|
||||
isFormOpen: true,
|
||||
isDisabledEditField: true,
|
||||
});
|
||||
|
||||
expect(await screen.findByTestId('subsection-edit-field')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('check editing is enabled when isDisabledEditField is false', async () => {
|
||||
renderComponent({ ...cardHeaderProps });
|
||||
|
||||
@@ -245,8 +254,8 @@ describe('<CardHeader />', () => {
|
||||
expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled');
|
||||
});
|
||||
|
||||
it('check editing is disabled when saving is in progress', async () => {
|
||||
renderComponent({ ...cardHeaderProps, savingStatus: RequestStatus.IN_PROGRESS });
|
||||
it('check editing is disabled when isDisabledEditField is true', async () => {
|
||||
renderComponent({ ...cardHeaderProps, isDisabledEditField: true });
|
||||
|
||||
expect(await screen.findByTestId('subsection-edit-button')).toBeDisabled();
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import { ContentTagsDrawerSheet } from '@src/content-tags-drawer';
|
||||
import TagCount from '@src/generic/tag-count';
|
||||
import { useEscapeClick } from '@src/hooks';
|
||||
import { XBlockActions } from '@src/data/types';
|
||||
import { RequestStatus, RequestStatusType } from '@src/data/constants';
|
||||
import { ITEM_BADGE_STATUS } from '../constants';
|
||||
import { scrollToElement } from '../utils';
|
||||
import CardStatus from './CardStatus';
|
||||
@@ -42,6 +41,7 @@ interface CardHeaderProps {
|
||||
isFormOpen: boolean;
|
||||
onEditSubmit: (titleValue: string) => void;
|
||||
closeForm: () => void;
|
||||
isDisabledEditField: boolean;
|
||||
onClickDelete: () => void;
|
||||
onClickUnlink: () => void;
|
||||
onClickDuplicate: () => void;
|
||||
@@ -69,7 +69,6 @@ interface CardHeaderProps {
|
||||
extraActionsComponent?: ReactNode,
|
||||
onClickSync?: () => void;
|
||||
readyToSync?: boolean;
|
||||
savingStatus?: RequestStatusType;
|
||||
}
|
||||
|
||||
const CardHeader = ({
|
||||
@@ -84,6 +83,7 @@ const CardHeader = ({
|
||||
isFormOpen,
|
||||
onEditSubmit,
|
||||
closeForm,
|
||||
isDisabledEditField,
|
||||
onClickDelete,
|
||||
onClickUnlink,
|
||||
onClickDuplicate,
|
||||
@@ -103,7 +103,6 @@ const CardHeader = ({
|
||||
extraActionsComponent,
|
||||
onClickSync,
|
||||
readyToSync,
|
||||
savingStatus,
|
||||
}: CardHeaderProps) => {
|
||||
const intl = useIntl();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -120,7 +119,6 @@ const CardHeader = ({
|
||||
|| status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges;
|
||||
|
||||
const { data: contentTagCount } = useContentTagsCount(cardId);
|
||||
const isSaving = savingStatus === RequestStatus.IN_PROGRESS;
|
||||
|
||||
useEffect(() => {
|
||||
const locatorId = searchParams.get('show');
|
||||
@@ -174,7 +172,7 @@ const CardHeader = ({
|
||||
onEditSubmit(titleValue);
|
||||
}
|
||||
}}
|
||||
disabled={isSaving}
|
||||
disabled={isDisabledEditField}
|
||||
/>
|
||||
</Form.Group>
|
||||
) : (
|
||||
@@ -188,7 +186,7 @@ const CardHeader = ({
|
||||
iconAs={EditIcon}
|
||||
onClick={onClickEdit}
|
||||
// @ts-ignore
|
||||
disabled={isSaving}
|
||||
disabled={isDisabledEditField}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -240,7 +238,7 @@ const CardHeader = ({
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-configure-button`}
|
||||
disabled={isSaving}
|
||||
disabled={isDisabledEditField}
|
||||
onClick={onClickConfigure}
|
||||
>
|
||||
{intl.formatMessage(messages.menuConfigure)}
|
||||
@@ -248,7 +246,7 @@ const CardHeader = ({
|
||||
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
|
||||
<Dropdown.Item
|
||||
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
|
||||
disabled={isSaving}
|
||||
disabled={isDisabledEditField}
|
||||
onClick={openManageTagsDrawer}
|
||||
>
|
||||
{intl.formatMessage(messages.menuManageTags)}
|
||||
|
||||
@@ -22,7 +22,7 @@ export const useCreateCourseBlock = (
|
||||
) => useMutation({
|
||||
mutationFn: createCourseXblock,
|
||||
onSettled: async (data) => {
|
||||
callback?.(data?.locator, data.parent_locator);
|
||||
callback?.(data.locator, data.parent_locator);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ import {
|
||||
* @param {string} courseId - ID of the course
|
||||
* @returns {Object} - Object containing fetch course outline index query success or failure status
|
||||
*/
|
||||
export function fetchCourseOutlineIndexQuery(courseId: string): (dispatch: any) => Promise<void> {
|
||||
export function fetchCourseOutlineIndexQuery(courseId: string): object {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useToggle } from '@openedx/paragon';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import moment from 'moment';
|
||||
import { getSavingStatus as getGenericSavingStatus } from '@src/generic/data/selectors';
|
||||
@@ -65,11 +64,9 @@ import {
|
||||
} from './data/thunk';
|
||||
import { useCreateCourseBlock } from './data/apiHooks';
|
||||
import { getCourseItem } from './data/api';
|
||||
import { containerComparisonQueryKeys } from '../container-comparison/data/apiHooks';
|
||||
|
||||
const useCourseOutline = ({ courseId }) => {
|
||||
const dispatch = useDispatch();
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const waffleFlags = useWaffleFlags(courseId);
|
||||
|
||||
@@ -248,8 +245,6 @@ const useCourseOutline = ({ courseId }) => {
|
||||
|
||||
const handleEditSubmit = (itemId, sectionId, displayName) => {
|
||||
dispatch(editCourseItemQuery(itemId, sectionId, displayName));
|
||||
// Invalidate container diff queries to update sync diff preview
|
||||
queryClient.invalidateQueries({ queryKey: containerComparisonQueryKeys.course(courseId) });
|
||||
};
|
||||
|
||||
const handleDeleteItemSubmit = () => {
|
||||
|
||||
@@ -1,31 +1,30 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Campaign as CampaignIcon,
|
||||
InfoOutline as InfoOutlineIcon,
|
||||
Warning as WarningIcon,
|
||||
Error as ErrorIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
import {
|
||||
Alert, Button, Hyperlink, Truncate,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
Campaign as CampaignIcon,
|
||||
Error as ErrorIcon,
|
||||
InfoOutline as InfoOutlineIcon,
|
||||
Warning as WarningIcon,
|
||||
} from '@openedx/paragon/icons';
|
||||
import { uniqBy } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import CourseOutlinePageAlertsSlot from '../../plugin-slots/CourseOutlinePageAlertsSlot';
|
||||
import advancedSettingsMessages from '../../advanced-settings/messages';
|
||||
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
|
||||
import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import AlertMessage from '../../generic/alert-message';
|
||||
import AlertProctoringError from '../../generic/AlertProctoringError';
|
||||
import { API_ERROR_TYPES } from '../constants';
|
||||
import messages from './messages';
|
||||
import advancedSettingsMessages from '../../advanced-settings/messages';
|
||||
import { getPasteFileNotices } from '../data/selectors';
|
||||
import { dismissError, removePasteFileNotices } from '../data/slice';
|
||||
import messages from './messages';
|
||||
import { API_ERROR_TYPES } from '../constants';
|
||||
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
|
||||
|
||||
const PageAlerts = ({
|
||||
courseId,
|
||||
@@ -438,7 +437,6 @@ const PageAlerts = ({
|
||||
{conflictingFilesPasteAlert()}
|
||||
{newFilesPasteAlert()}
|
||||
{renderOutOfSyncAlert()}
|
||||
<CourseOutlinePageAlertsSlot />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -17,11 +17,11 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
|
||||
}));
|
||||
|
||||
const unit = {
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
|
||||
id: 'unit-1',
|
||||
};
|
||||
|
||||
const subsection = {
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
|
||||
id: '123',
|
||||
displayName: 'Subsection Name',
|
||||
category: 'sequential',
|
||||
published: true,
|
||||
@@ -43,7 +43,7 @@ const subsection = {
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
const section = {
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
|
||||
id: '123',
|
||||
displayName: 'Section Name',
|
||||
category: 'chapter',
|
||||
published: true,
|
||||
@@ -71,10 +71,7 @@ const section = {
|
||||
readyToSync: true,
|
||||
upstreamRef: 'lct:org1:lib1:section:1',
|
||||
versionSynced: 1,
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
errorMessage: null,
|
||||
downstreamCustomized: [] as string[],
|
||||
},
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
@@ -91,6 +88,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenUnlinkModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSectionSubmit={onEditSectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
isSectionsExpanded
|
||||
@@ -189,9 +187,7 @@ describe('<SectionCard />', () => {
|
||||
const collapsedSections = { ...section };
|
||||
// @ts-ignore-next-line
|
||||
collapsedSections.isSectionsExpanded = false;
|
||||
// url encode subsection.id
|
||||
const subsectionIdUrl = encodeURIComponent(subsection.id);
|
||||
renderComponent(collapsedSections, `/course/:courseId?show=${subsectionIdUrl}`);
|
||||
renderComponent(collapsedSections, `/course/:courseId?show=${subsection.id}`);
|
||||
|
||||
const cardSubsections = await screen.findByTestId('section-card__subsections');
|
||||
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
|
||||
@@ -203,9 +199,7 @@ describe('<SectionCard />', () => {
|
||||
const collapsedSections = { ...section };
|
||||
// @ts-ignore-next-line
|
||||
collapsedSections.isSectionsExpanded = false;
|
||||
// url encode subsection.id
|
||||
const unitIdUrl = encodeURIComponent(unit.id);
|
||||
renderComponent(collapsedSections, `/course/:courseId?show=${unitIdUrl}`);
|
||||
renderComponent(collapsedSections, `/course/:courseId?show=${unit.id}`);
|
||||
|
||||
const cardSubsections = await screen.findByTestId('section-card__subsections');
|
||||
const newSubsectionButton = await screen.findByRole('button', { name: 'New subsection' });
|
||||
@@ -237,6 +231,7 @@ describe('<SectionCard />', () => {
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: section name/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
|
||||
|
||||
// Click on accept changes
|
||||
const acceptChangesButton = screen.getByText(/accept changes/i);
|
||||
@@ -256,6 +251,7 @@ describe('<SectionCard />', () => {
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: section name/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
|
||||
|
||||
// Click on ignore changes
|
||||
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });
|
||||
|
||||
@@ -11,7 +11,7 @@ import classNames from 'classnames';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { setCurrentItem, setCurrentSection } from '@src/course-outline/data/slice';
|
||||
import { RequestStatus, RequestStatusType } from '@src/data/constants';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import CardHeader from '@src/course-outline/card-header/CardHeader';
|
||||
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
|
||||
@@ -39,7 +39,7 @@ interface SectionCardProps {
|
||||
onOpenPublishModal: () => void,
|
||||
onOpenConfigureModal: () => void,
|
||||
onEditSectionSubmit: (itemId: string, sectionId: string, displayName: string) => void,
|
||||
savingStatus?: RequestStatusType,
|
||||
savingStatus: string,
|
||||
onOpenDeleteModal: () => void,
|
||||
onOpenUnlinkModal: () => void,
|
||||
onDuplicateSubmit: () => void,
|
||||
@@ -144,9 +144,7 @@ const SectionCard = ({
|
||||
downstreamBlockId: id,
|
||||
upstreamBlockId: upstreamInfo.upstreamRef,
|
||||
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
|
||||
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
|
||||
isContainer: true,
|
||||
blockType: 'section',
|
||||
};
|
||||
}, [upstreamInfo]);
|
||||
|
||||
@@ -303,7 +301,7 @@ const SectionCard = ({
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
savingStatus={savingStatus}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
|
||||
@@ -52,7 +52,7 @@ const unit = {
|
||||
};
|
||||
|
||||
const subsection: XBlock = {
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
|
||||
id: '123',
|
||||
displayName: 'Subsection Name',
|
||||
category: 'sequential',
|
||||
published: true,
|
||||
@@ -75,15 +75,12 @@ const subsection: XBlock = {
|
||||
readyToSync: true,
|
||||
upstreamRef: 'lct:org1:lib1:subsection:1',
|
||||
versionSynced: 1,
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
errorMessage: null,
|
||||
downstreamCustomized: [] as string[],
|
||||
},
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
const section: XBlock = {
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
|
||||
id: '123',
|
||||
displayName: 'Section Name',
|
||||
published: true,
|
||||
visibilityState: 'live',
|
||||
@@ -118,6 +115,7 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
|
||||
onNewUnitSubmit={jest.fn()}
|
||||
onAddUnitFromLibrary={handleOnAddUnitFromLibrary}
|
||||
isCustomRelativeDatesActive={false}
|
||||
savingStatus=""
|
||||
onEditSubmit={onEditSubectionSubmit}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
@@ -324,7 +322,7 @@ describe('<SubsectionCard />', () => {
|
||||
expect(handleOnAddUnitFromLibrary).toHaveBeenCalled();
|
||||
expect(handleOnAddUnitFromLibrary).toHaveBeenCalledWith({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
parentLocator: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
|
||||
parentLocator: '123',
|
||||
category: 'vertical',
|
||||
libraryContentKey: containerKey,
|
||||
});
|
||||
@@ -341,6 +339,7 @@ describe('<SubsectionCard />', () => {
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: subsection name/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
|
||||
|
||||
// Click on accept changes
|
||||
const acceptChangesButton = screen.getByText(/accept changes/i);
|
||||
@@ -360,6 +359,7 @@ describe('<SubsectionCard />', () => {
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: subsection name/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
|
||||
|
||||
// Click on ignore changes
|
||||
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });
|
||||
|
||||
@@ -11,7 +11,7 @@ import { isEmpty } from 'lodash';
|
||||
|
||||
import CourseOutlineSubsectionCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineSubsectionCardExtraActionsSlot';
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice';
|
||||
import { RequestStatus, RequestStatusType } from '@src/data/constants';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import CardHeader from '@src/course-outline/card-header/CardHeader';
|
||||
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import { DragContext } from '@src/course-outline/drag-helper/DragContextProvider';
|
||||
@@ -40,7 +40,7 @@ interface SubsectionCardProps {
|
||||
isCustomRelativeDatesActive: boolean,
|
||||
onOpenPublishModal: () => void,
|
||||
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
|
||||
savingStatus?: RequestStatusType,
|
||||
savingStatus: string,
|
||||
onOpenDeleteModal: () => void,
|
||||
onOpenUnlinkModal: () => void,
|
||||
onDuplicateSubmit: () => void,
|
||||
@@ -126,9 +126,7 @@ const SubsectionCard = ({
|
||||
downstreamBlockId: id,
|
||||
upstreamBlockId: upstreamInfo.upstreamRef,
|
||||
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
|
||||
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
|
||||
isContainer: true,
|
||||
blockType: 'subsection',
|
||||
};
|
||||
}, [upstreamInfo]);
|
||||
|
||||
@@ -305,7 +303,7 @@ const SubsectionCard = ({
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
savingStatus={savingStatus}
|
||||
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
|
||||
@@ -19,7 +19,7 @@ jest.mock('@src/course-unit/data/apiHooks', () => ({
|
||||
}));
|
||||
|
||||
const section = {
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@section+block@0',
|
||||
id: '1',
|
||||
displayName: 'Section Name',
|
||||
published: true,
|
||||
visibilityState: 'live',
|
||||
@@ -34,7 +34,7 @@ const section = {
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
const subsection = {
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@subsection+block@0',
|
||||
id: '12',
|
||||
displayName: 'Subsection Name',
|
||||
published: true,
|
||||
visibilityState: 'live',
|
||||
@@ -48,7 +48,7 @@ const subsection = {
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
const unit = {
|
||||
id: 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
|
||||
id: '123',
|
||||
displayName: 'unit Name',
|
||||
category: 'vertical',
|
||||
published: true,
|
||||
@@ -65,10 +65,7 @@ const unit = {
|
||||
readyToSync: true,
|
||||
upstreamRef: 'lct:org1:lib1:unit:1',
|
||||
versionSynced: 1,
|
||||
versionAvailable: 2,
|
||||
versionDeclined: null,
|
||||
errorMessage: null,
|
||||
downstreamCustomized: [] as string[],
|
||||
},
|
||||
} satisfies Partial<XBlock> as XBlock;
|
||||
|
||||
@@ -84,6 +81,7 @@ const renderComponent = (props?: object) => render(
|
||||
onOpenDeleteModal={jest.fn()}
|
||||
onOpenUnlinkModal={jest.fn()}
|
||||
onOpenConfigureModal={jest.fn()}
|
||||
savingStatus=""
|
||||
onEditSubmit={jest.fn()}
|
||||
onDuplicateSubmit={jest.fn()}
|
||||
getTitleLink={(id) => `/some/${id}`}
|
||||
@@ -110,10 +108,7 @@ describe('<UnitCard />', () => {
|
||||
const { findByTestId } = renderComponent();
|
||||
|
||||
expect(await findByTestId('unit-card-header')).toBeInTheDocument();
|
||||
expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute(
|
||||
'href',
|
||||
'/some/block-v1:UNIX+UX1+2025_T3+type@unit+block@0',
|
||||
);
|
||||
expect(await findByTestId('unit-card-header__title-link')).toHaveAttribute('href', '/some/123');
|
||||
});
|
||||
|
||||
it('hides header based on isHeaderVisible flag', async () => {
|
||||
@@ -204,6 +199,7 @@ describe('<UnitCard />', () => {
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
|
||||
|
||||
// Click on accept changes
|
||||
const acceptChangesButton = screen.getByText(/accept changes/i);
|
||||
@@ -223,6 +219,7 @@ describe('<UnitCard />', () => {
|
||||
|
||||
// Should open compare preview modal
|
||||
expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument();
|
||||
expect(screen.getByText('Preview not available for container changes at this time')).toBeInTheDocument();
|
||||
|
||||
// Click on ignore changes
|
||||
const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i });
|
||||
|
||||
@@ -13,7 +13,8 @@ import { useQueryClient } from '@tanstack/react-query';
|
||||
import CourseOutlineUnitCardExtraActionsSlot from '@src/plugin-slots/CourseOutlineUnitCardExtraActionsSlot';
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '@src/course-outline/data/slice';
|
||||
import { fetchCourseSectionQuery } from '@src/course-outline/data/thunk';
|
||||
import { RequestStatus, RequestStatusType } from '@src/data/constants';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { isUnitReadOnly } from '@src/course-unit/data/utils';
|
||||
import CardHeader from '@src/course-outline/card-header/CardHeader';
|
||||
import SortableItem from '@src/course-outline/drag-helper/SortableItem';
|
||||
import TitleLink from '@src/course-outline/card-header/TitleLink';
|
||||
@@ -32,7 +33,7 @@ interface UnitCardProps {
|
||||
onOpenPublishModal: () => void;
|
||||
onOpenConfigureModal: () => void;
|
||||
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
|
||||
savingStatus?: RequestStatusType;
|
||||
savingStatus: string;
|
||||
onOpenDeleteModal: () => void;
|
||||
onOpenUnlinkModal: () => void;
|
||||
onDuplicateSubmit: () => void;
|
||||
@@ -103,12 +104,12 @@ const UnitCard = ({
|
||||
downstreamBlockId: id,
|
||||
upstreamBlockId: upstreamInfo.upstreamRef,
|
||||
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
|
||||
isReadyToSyncIndividually: upstreamInfo.isReadyToSyncIndividually,
|
||||
isContainer: true,
|
||||
blockType: 'unit',
|
||||
};
|
||||
}, [upstreamInfo]);
|
||||
|
||||
const readOnly = isUnitReadOnly(unit);
|
||||
|
||||
// re-create actions object for customizations
|
||||
const actions = { ...unitActions };
|
||||
// add actions to control display of move up & down menu buton.
|
||||
@@ -246,7 +247,7 @@ const UnitCard = ({
|
||||
isFormOpen={isFormOpen}
|
||||
closeForm={closeForm}
|
||||
onEditSubmit={handleEditSubmit}
|
||||
savingStatus={savingStatus}
|
||||
isDisabledEditField={readOnly || savingStatus === RequestStatus.IN_PROGRESS}
|
||||
onClickDuplicate={onDuplicateSubmit}
|
||||
titleComponent={titleComponent}
|
||||
namePrefix={namePrefix}
|
||||
|
||||
@@ -50,7 +50,6 @@ const CourseUnit = ({ courseId }) => {
|
||||
isUnitVerticalType,
|
||||
isUnitLibraryType,
|
||||
isSplitTestType,
|
||||
isProblemBankType,
|
||||
staticFileNotices,
|
||||
currentlyVisibleToStudents,
|
||||
unitXBlockActions,
|
||||
@@ -220,7 +219,6 @@ const CourseUnit = ({ courseId }) => {
|
||||
parentLocator={blockId}
|
||||
isSplitTestType={isSplitTestType}
|
||||
isUnitVerticalType={isUnitVerticalType}
|
||||
isProblemBankType={isProblemBankType}
|
||||
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
|
||||
addComponentTemplateData={addComponentTemplateData}
|
||||
/>
|
||||
|
||||
@@ -2218,7 +2218,7 @@ describe('<CourseUnit />', () => {
|
||||
const currentSubSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name;
|
||||
const helpLinkUrl = 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/developing_course/course_components.html#components-that-contain-other-components';
|
||||
|
||||
await waitFor(() => {
|
||||
waitFor(() => {
|
||||
const unitHeaderTitle = screen.getByTestId('unit-header-title');
|
||||
expect(screen.getByText(unitDisplayName)).toBeInTheDocument();
|
||||
expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument();
|
||||
@@ -2291,17 +2291,19 @@ describe('<CourseUnit />', () => {
|
||||
});
|
||||
|
||||
it('renders and navigates to the new HTML XBlock editor after xblock duplicating', async () => {
|
||||
render(<RootWrapper />);
|
||||
const updatedCourseVerticalChildrenMock = JSON.parse(JSON.stringify(courseVerticalChildrenMock));
|
||||
// Convert the second child from drag and drop to HTML:
|
||||
const targetChild = updatedCourseVerticalChildrenMock.children[1];
|
||||
targetChild.block_type = 'html';
|
||||
targetChild.name = 'Test HTML Block';
|
||||
targetChild.block_id = 'block-v1:OpenedX+L153+3T2023+type@html+block@test123original';
|
||||
const targetBlockId = updatedCourseVerticalChildrenMock.children[1].block_id;
|
||||
|
||||
updatedCourseVerticalChildrenMock.children = updatedCourseVerticalChildrenMock.children
|
||||
.map((child) => (child.block_id === targetBlockId
|
||||
? { ...child, block_type: 'html' }
|
||||
: child));
|
||||
|
||||
axiosMock
|
||||
.onPost(postXBlockBaseApiUrl({
|
||||
parent_locator: blockId,
|
||||
duplicate_source_locator: targetChild.block_id,
|
||||
duplicate_source_locator: courseVerticalChildrenMock.children[0].block_id,
|
||||
}))
|
||||
.replyOnce(200, { locator: '1234567890' });
|
||||
|
||||
@@ -2309,20 +2311,21 @@ describe('<CourseUnit />', () => {
|
||||
.onGet(getCourseVerticalChildrenApiUrl(blockId))
|
||||
.reply(200, updatedCourseVerticalChildrenMock);
|
||||
|
||||
render(<RootWrapper />);
|
||||
await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);
|
||||
|
||||
await waitFor(() => {
|
||||
const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
|
||||
expect(iframe).toBeInTheDocument();
|
||||
simulatePostMessageEvent(messageTypes.currentXBlockId, {
|
||||
id: targetBlockId,
|
||||
});
|
||||
});
|
||||
|
||||
// After duplicating, the editor modal will open:
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
simulatePostMessageEvent(messageTypes.duplicateXBlock, { usageId: targetChild.block_id });
|
||||
simulatePostMessageEvent(messageTypes.newXBlockEditor, { blockType: 'html', usageId: targetChild.block_id });
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).toBeInTheDocument();
|
||||
waitFor(() => {
|
||||
simulatePostMessageEvent(messageTypes.duplicateXBlock, {});
|
||||
simulatePostMessageEvent(messageTypes.newXBlockEditor, {});
|
||||
expect(mockedUsedNavigate)
|
||||
.toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2350,14 +2353,14 @@ describe('<CourseUnit />', () => {
|
||||
|
||||
expect(screen.getByText(/this unit can only be edited from the \./i)).toBeInTheDocument();
|
||||
|
||||
// Edit button should be enabled even for library imported units
|
||||
// Disable the "Edit" button
|
||||
const unitHeaderTitle = screen.getByTestId('unit-header-title');
|
||||
const editButton = within(unitHeaderTitle).getByRole(
|
||||
'button',
|
||||
{ name: 'Edit' },
|
||||
);
|
||||
expect(editButton).toBeInTheDocument();
|
||||
expect(editButton).toBeEnabled();
|
||||
expect(editButton).toBeDisabled();
|
||||
|
||||
// The "Publish" button should still be enabled
|
||||
const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
|
||||
@@ -2368,6 +2371,14 @@ describe('<CourseUnit />', () => {
|
||||
expect(publishButton).toBeInTheDocument();
|
||||
expect(publishButton).toBeEnabled();
|
||||
|
||||
// Disable the "Manage Tags" button
|
||||
const manageTagsButton = screen.getByRole(
|
||||
'button',
|
||||
{ name: tagsDrawerMessages.manageTagsButton.defaultMessage },
|
||||
);
|
||||
expect(manageTagsButton).toBeInTheDocument();
|
||||
expect(manageTagsButton).toBeDisabled();
|
||||
|
||||
// Does not render the "Add Components" section
|
||||
expect(screen.queryByText(addComponentMessages.title.defaultMessage)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
@@ -6,64 +7,28 @@ import {
|
||||
ActionRow, Button, StandardModal, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import { useWaffleFlags } from '@src/data/apiHooks';
|
||||
import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants';
|
||||
import { ComponentPicker } from '@src/library-authoring/component-picker';
|
||||
import { ContentType } from '@src/library-authoring/routes';
|
||||
import { useIframe } from '@src/generic/hooks/context/hooks';
|
||||
import { useEventListener } from '@src/generic/hooks';
|
||||
import VideoSelectorPage from '@src/editors/VideoSelectorPage';
|
||||
import EditorPage from '@src/editors/EditorPage';
|
||||
import { SelectedComponent } from '@src/library-authoring';
|
||||
import { fetchCourseSectionVerticalData } from '../data/thunk';
|
||||
import { messageTypes } from '../constants';
|
||||
import messages from './messages';
|
||||
import AddComponentButton from './add-component-btn';
|
||||
import ComponentModalView from './add-component-modals/ComponentModalView';
|
||||
import { getCourseSectionVertical, getCourseUnitData } from '../data/selectors';
|
||||
|
||||
type ComponentTemplateData = {
|
||||
displayName: string,
|
||||
category?: string,
|
||||
type: string,
|
||||
beta?: boolean,
|
||||
templates: Array<{
|
||||
boilerplateName?: string,
|
||||
category?: string,
|
||||
displayName: string,
|
||||
supportLevel?: string | boolean,
|
||||
}>,
|
||||
supportLegend: {
|
||||
allowUnsupportedXblocks?: boolean,
|
||||
documentationLabel?: string,
|
||||
showLegend?: boolean,
|
||||
},
|
||||
};
|
||||
|
||||
export interface AddComponentProps {
|
||||
isSplitTestType?: boolean,
|
||||
isUnitVerticalType?: boolean,
|
||||
parentLocator: string,
|
||||
handleCreateNewCourseXBlock: (
|
||||
args: object,
|
||||
callback?: (args: { courseKey: string, locator: string }) => void
|
||||
) => void,
|
||||
isProblemBankType?: boolean,
|
||||
addComponentTemplateData?: {
|
||||
blockId: string,
|
||||
parentLocator?: string,
|
||||
model: ComponentTemplateData,
|
||||
},
|
||||
}
|
||||
import { useWaffleFlags } from '../../data/apiHooks';
|
||||
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
|
||||
import ComponentModalView from './add-component-modals/ComponentModalView';
|
||||
import AddComponentButton from './add-component-btn';
|
||||
import messages from './messages';
|
||||
import { ComponentPicker } from '../../library-authoring/component-picker';
|
||||
import { ContentType } from '../../library-authoring/routes';
|
||||
import { messageTypes } from '../constants';
|
||||
import { useIframe } from '../../generic/hooks/context/hooks';
|
||||
import { useEventListener } from '../../generic/hooks';
|
||||
import VideoSelectorPage from '../../editors/VideoSelectorPage';
|
||||
import EditorPage from '../../editors/EditorPage';
|
||||
import { fetchCourseSectionVerticalData } from '../data/thunk';
|
||||
|
||||
const AddComponent = ({
|
||||
parentLocator,
|
||||
isSplitTestType,
|
||||
isUnitVerticalType,
|
||||
isProblemBankType,
|
||||
addComponentTemplateData,
|
||||
handleCreateNewCourseXBlock,
|
||||
}: AddComponentProps) => {
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
@@ -71,16 +36,16 @@ const AddComponent = ({
|
||||
const [isOpenHtml, openHtml, closeHtml] = useToggle(false);
|
||||
const [isOpenOpenAssessment, openOpenAssessment, closeOpenAssessment] = useToggle(false);
|
||||
const { componentTemplates = {} } = useSelector(getCourseSectionVertical);
|
||||
const blockId = addComponentTemplateData?.parentLocator || parentLocator;
|
||||
const blockId = addComponentTemplateData.parentLocator || parentLocator;
|
||||
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
|
||||
const [isVideoSelectorModalOpen, showVideoSelectorModal, closeVideoSelectorModal] = useToggle();
|
||||
const [isXBlockEditorModalOpen, showXBlockEditorModal, closeXBlockEditorModal] = useToggle();
|
||||
|
||||
const [blockType, setBlockType] = useState<string | null>(null);
|
||||
const [courseId, setCourseId] = useState<string | null>(null);
|
||||
const [newBlockId, setNewBlockId] = useState<string | null>(null);
|
||||
const [blockType, setBlockType] = useState(null);
|
||||
const [courseId, setCourseId] = useState(null);
|
||||
const [newBlockId, setNewBlockId] = useState(null);
|
||||
const [isSelectLibraryContentModalOpen, showSelectLibraryContentModal, closeSelectLibraryContentModal] = useToggle();
|
||||
const [selectedComponents, setSelectedComponents] = useState<SelectedComponent[]>([]);
|
||||
const [selectedComponents, setSelectedComponents] = useState([]);
|
||||
const [usageId, setUsageId] = useState(null);
|
||||
const { sendMessageToIframe } = useIframe();
|
||||
const { useVideoGalleryFlow } = useWaffleFlags(courseId ?? undefined);
|
||||
@@ -119,7 +84,7 @@ const AddComponent = ({
|
||||
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
|
||||
}, [closeXBlockEditorModal, closeVideoSelectorModal, sendMessageToIframe, blockId, sequenceId]);
|
||||
|
||||
const handleLibraryV2Selection = useCallback((selection: SelectedComponent) => {
|
||||
const handleLibraryV2Selection = useCallback((selection) => {
|
||||
handleCreateNewCourseXBlock({
|
||||
type: COMPONENT_TYPES.libraryV2,
|
||||
category: selection.blockType,
|
||||
@@ -129,7 +94,7 @@ const AddComponent = ({
|
||||
closeAddLibraryContentModal();
|
||||
}, [usageId]);
|
||||
|
||||
const handleCreateNewXBlock = (type: string, moduleName?: string) => {
|
||||
const handleCreateNewXBlock = (type, moduleName) => {
|
||||
switch (type) {
|
||||
case COMPONENT_TYPES.discussion:
|
||||
case COMPONENT_TYPES.dragAndDrop:
|
||||
@@ -191,16 +156,16 @@ const AddComponent = ({
|
||||
}
|
||||
};
|
||||
|
||||
if (isUnitVerticalType || isSplitTestType || isProblemBankType) {
|
||||
if (isUnitVerticalType || isSplitTestType) {
|
||||
return (
|
||||
<div className="py-4">
|
||||
{Object.keys(componentTemplates).length && isUnitVerticalType ? (
|
||||
<>
|
||||
<h5 className="h3 mb-4 text-center">{intl.formatMessage(messages.title)}</h5>
|
||||
<ul className="new-component-type list-unstyled m-0 d-flex flex-wrap justify-content-center">
|
||||
{componentTemplates.map((component: ComponentTemplateData) => {
|
||||
{componentTemplates.map((component) => {
|
||||
const { type, displayName, beta } = component;
|
||||
let modalParams: { open: () => void, close: () => void, isOpen: boolean };
|
||||
let modalParams;
|
||||
|
||||
if (!component.templates.length) {
|
||||
return null;
|
||||
@@ -303,7 +268,7 @@ const AddComponent = ({
|
||||
/>
|
||||
</div>
|
||||
</StandardModal>
|
||||
{isXBlockEditorModalOpen && courseId && blockType && newBlockId && (
|
||||
{isXBlockEditorModalOpen && (
|
||||
<div className="editor-page">
|
||||
<EditorPage
|
||||
courseId={courseId}
|
||||
@@ -323,4 +288,32 @@ const AddComponent = ({
|
||||
return null;
|
||||
};
|
||||
|
||||
AddComponent.propTypes = {
|
||||
isSplitTestType: PropTypes.bool.isRequired,
|
||||
isUnitVerticalType: PropTypes.bool.isRequired,
|
||||
parentLocator: PropTypes.string.isRequired,
|
||||
handleCreateNewCourseXBlock: PropTypes.func.isRequired,
|
||||
addComponentTemplateData: {
|
||||
blockId: PropTypes.string.isRequired,
|
||||
model: PropTypes.shape({
|
||||
displayName: PropTypes.string.isRequired,
|
||||
category: PropTypes.string,
|
||||
type: PropTypes.string.isRequired,
|
||||
templates: PropTypes.arrayOf(
|
||||
PropTypes.shape({
|
||||
boilerplateName: PropTypes.string,
|
||||
category: PropTypes.string,
|
||||
displayName: PropTypes.string.isRequired,
|
||||
supportLevel: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]),
|
||||
}),
|
||||
),
|
||||
supportLegend: PropTypes.shape({
|
||||
allowUnsupportedXblocks: PropTypes.bool,
|
||||
documentationLabel: PropTypes.string,
|
||||
showLegend: PropTypes.bool,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default AddComponent;
|
||||
@@ -14,7 +14,7 @@ import { fetchCourseSectionVerticalData } from '../data/thunk';
|
||||
import { getCourseSectionVerticalApiUrl } from '../data/api';
|
||||
import { courseSectionVerticalMock } from '../__mocks__';
|
||||
import { COMPONENT_TYPES } from '../../generic/block-type-utils/constants';
|
||||
import AddComponent, { AddComponentProps } from './AddComponent';
|
||||
import AddComponent from './AddComponent';
|
||||
import messages from './messages';
|
||||
import { IframeProvider } from '../../generic/hooks/context/iFrameContext';
|
||||
import { messageTypes } from '../constants';
|
||||
@@ -56,11 +56,13 @@ jest.mock('../../generic/hooks/context/hooks', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const renderComponent = (props?: AddComponentProps) => render(
|
||||
const renderComponent = (props) => render(
|
||||
<IframeProvider>
|
||||
<AddComponent
|
||||
blockId={blockId}
|
||||
isUnitVerticalType
|
||||
parentLocator={blockId}
|
||||
addComponentTemplateData={{}}
|
||||
handleCreateNewCourseXBlock={handleCreateNewCourseXBlockMock}
|
||||
{...props}
|
||||
/>
|
||||
@@ -92,7 +94,7 @@ describe('<AddComponent />', () => {
|
||||
),
|
||||
});
|
||||
expect(btn).toBeInTheDocument();
|
||||
if (componentTemplates[component].beta) {
|
||||
if (component.beta) {
|
||||
expect(within(btn).queryByText('Beta')).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
@@ -41,7 +41,6 @@ export const getXBlockSupportMessages = (intl) => ({
|
||||
|
||||
export const messageTypes = {
|
||||
refreshXBlock: 'refreshXBlock',
|
||||
refreshIframe: 'refreshIframe',
|
||||
showMoveXBlockModal: 'showMoveXBlockModal',
|
||||
completeXBlockMoving: 'completeXBlockMoving',
|
||||
rollbackMovedXBlock: 'rollbackMovedXBlock',
|
||||
|
||||
@@ -10,13 +10,13 @@ const UnitButton = ({
|
||||
unitId,
|
||||
className,
|
||||
showTitle,
|
||||
isActive, // passed from parent (SequenceNavigationTabs)
|
||||
}) => {
|
||||
const courseId = useSelector(getCourseId);
|
||||
const sequenceId = useSelector(getSequenceId);
|
||||
|
||||
const unit = useSelector((state) => state.models.units[unitId]);
|
||||
const { title, contentType } = unit || {};
|
||||
|
||||
const { title, contentType, isActive } = unit || {};
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -37,13 +37,11 @@ UnitButton.propTypes = {
|
||||
className: PropTypes.string,
|
||||
showTitle: PropTypes.bool,
|
||||
unitId: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool,
|
||||
};
|
||||
|
||||
UnitButton.defaultProps = {
|
||||
className: undefined,
|
||||
showTitle: false,
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
export default UnitButton;
|
||||
|
||||
218
src/course-unit/data/api.js
Normal file
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 {
|
||||
hideProcessingNotification,
|
||||
showProcessingNotification,
|
||||
} from '@src/generic/processing-notification/data/slice';
|
||||
import { handleResponseErrors } from '@src/generic/saving-error-alert';
|
||||
import { RequestStatus } from '@src/data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '@src/constants';
|
||||
import { updateModel, updateModels } from '@src/generic/model-store';
|
||||
} from '../../generic/processing-notification/data/slice';
|
||||
import { handleResponseErrors } from '../../generic/saving-error-alert';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import { NOTIFICATION_MESSAGES } from '../../constants';
|
||||
import { updateModel, updateModels } from '../../generic/model-store';
|
||||
import { messageTypes } from '../constants';
|
||||
import {
|
||||
editUnitDisplayName,
|
||||
getVerticalData,
|
||||
createCourseXblock,
|
||||
getCourseContainerChildren,
|
||||
getCourseVerticalChildren,
|
||||
handleCourseUnitVisibilityAndData,
|
||||
deleteUnitItem,
|
||||
duplicateUnitItem,
|
||||
@@ -126,7 +126,7 @@ export function editCourseUnitVisibilityAndData(
|
||||
}
|
||||
const courseSectionVerticalData = await getVerticalData(blockId);
|
||||
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
|
||||
const courseVerticalChildrenData = await getCourseContainerChildren(blockId);
|
||||
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
|
||||
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
@@ -163,7 +163,7 @@ export function createNewCourseXBlock(body, callback, blockId, sendMessageToIfra
|
||||
localStorage.removeItem('staticFileNotices');
|
||||
}
|
||||
}
|
||||
const courseVerticalChildrenData = await getCourseContainerChildren(blockId);
|
||||
const courseVerticalChildrenData = await getCourseVerticalChildren(blockId);
|
||||
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
|
||||
dispatch(hideProcessingNotification());
|
||||
if (callback) {
|
||||
@@ -190,11 +190,11 @@ export function fetchCourseVerticalChildrenData(itemId, isSplitTestType, skipPag
|
||||
}
|
||||
|
||||
try {
|
||||
const courseVerticalChildrenData = await getCourseContainerChildren(itemId);
|
||||
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
|
||||
if (isSplitTestType) {
|
||||
const blockIds = courseVerticalChildrenData.children.map(child => child.blockId);
|
||||
const childrenDataArray = await Promise.all(
|
||||
blockIds.map(blockId => getCourseContainerChildren(blockId)),
|
||||
blockIds.map(blockId => getCourseVerticalChildren(blockId)),
|
||||
);
|
||||
const allChildren = childrenDataArray.reduce(
|
||||
(acc, data) => acc.concat(data.children || []),
|
||||
@@ -239,7 +239,7 @@ export function duplicateUnitItemQuery(itemId, xblockId, callback) {
|
||||
callback(courseKey, locator);
|
||||
const courseSectionVerticalData = await getVerticalData(itemId);
|
||||
dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData));
|
||||
const courseVerticalChildrenData = await getCourseContainerChildren(itemId);
|
||||
const courseVerticalChildrenData = await getCourseVerticalChildren(itemId);
|
||||
dispatch(updateCourseVerticalChildren(courseVerticalChildrenData));
|
||||
dispatch(hideProcessingNotification());
|
||||
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
|
||||
|
||||
@@ -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.
|
||||
* @returns True if readOnly, False if editable.
|
||||
*/
|
||||
export const isUnitImportedFromLib = ({ upstreamInfo }: XBlock): boolean => (
|
||||
export const isUnitReadOnly = ({ upstreamInfo }: XBlock): boolean => (
|
||||
!!upstreamInfo
|
||||
&& !!upstreamInfo.upstreamRef
|
||||
&& upstreamInfo.upstreamRef.startsWith('lct:')
|
||||
|
||||
@@ -34,6 +34,8 @@ const HeaderTitle = ({
|
||||
COURSE_BLOCK_NAMES.component.id,
|
||||
].includes(currentItemData.category);
|
||||
|
||||
const readOnly = !!currentItemData.readOnly;
|
||||
|
||||
const onConfigureSubmit = (...arg) => {
|
||||
handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal);
|
||||
};
|
||||
@@ -80,6 +82,7 @@ const HeaderTitle = ({
|
||||
className="ml-1 flex-shrink-0"
|
||||
iconAs={EditIcon}
|
||||
onClick={handleTitleEdit}
|
||||
disabled={readOnly}
|
||||
/>
|
||||
<IconButton
|
||||
alt={intl.formatMessage(messages.altButtonSettings)}
|
||||
|
||||
@@ -76,7 +76,7 @@ describe('<HeaderTitle />', () => {
|
||||
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
|
||||
});
|
||||
|
||||
it('Units sourced from upstream show a enabled edit button', async () => {
|
||||
it('Units sourced from upstream show a disabled edit button', async () => {
|
||||
// Override mock unit with one sourced from an upstream library
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
axiosMock
|
||||
@@ -95,7 +95,7 @@ describe('<HeaderTitle />', () => {
|
||||
|
||||
const { getByRole } = renderComponent();
|
||||
|
||||
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled();
|
||||
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeDisabled();
|
||||
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
|
||||
});
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
fetchCourseVerticalChildrenData,
|
||||
getCourseOutlineInfoQuery,
|
||||
patchUnitItemQuery,
|
||||
updateCourseUnitSidebar,
|
||||
} from './data/thunk';
|
||||
import {
|
||||
getCanEdit,
|
||||
@@ -73,10 +72,6 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
const isUnitVerticalType = unitCategory === COURSE_BLOCK_NAMES.vertical.id;
|
||||
const isUnitLibraryType = unitCategory === COURSE_BLOCK_NAMES.libraryContent.id;
|
||||
const isSplitTestType = unitCategory === COURSE_BLOCK_NAMES.splitTest.id;
|
||||
const isProblemBankType = [
|
||||
COURSE_BLOCK_NAMES.legacyLibraryContent.id,
|
||||
COURSE_BLOCK_NAMES.itembank.id,
|
||||
].includes(unitCategory);
|
||||
|
||||
const headerNavigationsActions = {
|
||||
handleViewLive: () => {
|
||||
@@ -232,7 +227,8 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
// edits the component using editor which has a separate store
|
||||
/* istanbul ignore next */
|
||||
if (event.key === 'courseRefreshTriggerOnComponentEditSave') {
|
||||
dispatch(updateCourseUnitSidebar(blockId));
|
||||
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
|
||||
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
|
||||
localStorage.removeItem(event.key);
|
||||
}
|
||||
};
|
||||
@@ -258,7 +254,6 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
||||
isUnitVerticalType,
|
||||
isUnitLibraryType,
|
||||
isSplitTestType,
|
||||
isProblemBankType,
|
||||
sharedClipboardData,
|
||||
showPasteXBlock,
|
||||
showPasteUnit,
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
.lib-preview-xblock-changes-modal {
|
||||
border-bottom-right-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
|
||||
.preview-title {
|
||||
span {
|
||||
margin: 0 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,29 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter/types';
|
||||
|
||||
import {
|
||||
act,
|
||||
render as baseRender,
|
||||
screen,
|
||||
initializeMocks,
|
||||
waitFor,
|
||||
} from '@src/testUtils';
|
||||
import { ToastActionData } from '@src/generic/toast-context';
|
||||
} from '../../testUtils';
|
||||
|
||||
import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.';
|
||||
import { messageTypes } from '../constants';
|
||||
import { libraryBlockChangesUrl } from '../data/api';
|
||||
import { ToastActionData } from '../../generic/toast-context';
|
||||
|
||||
const usageKey = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@1';
|
||||
const usageKey = 'some-id';
|
||||
const defaultEventData: LibraryChangesMessageData = {
|
||||
displayName: 'Test block',
|
||||
downstreamBlockId: usageKey,
|
||||
upstreamBlockId: 'lct:org:lib1:unit:1',
|
||||
upstreamBlockVersionSynced: 1,
|
||||
isContainer: false,
|
||||
isLocallyModified: false,
|
||||
blockType: 'html',
|
||||
};
|
||||
|
||||
const mockSendMessageToIframe = jest.fn();
|
||||
jest.mock('@src/generic/hooks/context/hooks', () => ({
|
||||
jest.mock('../../generic/hooks/context/hooks', () => ({
|
||||
useIframe: () => ({
|
||||
iframeRef: { current: { contentWindow: {} as HTMLIFrameElement } },
|
||||
setIframeRef: () => {},
|
||||
@@ -63,6 +60,7 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
|
||||
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Accept changes' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Ignore changes' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Cancel' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('tab', { name: 'New version' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('tab', { name: 'Old version' })).toBeInTheDocument();
|
||||
});
|
||||
@@ -134,59 +132,4 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
|
||||
});
|
||||
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render modal of text with local changes', async () => {
|
||||
render({ ...defaultEventData, isLocallyModified: true });
|
||||
|
||||
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('This library content has local edits.')).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Update to published library content' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'Keep course content' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('tab', { name: 'Course content' })).toBeInTheDocument();
|
||||
expect(await screen.findByRole('tab', { name: 'Published library content' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('update changes works', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
|
||||
render({ ...defaultEventData, isLocallyModified: true });
|
||||
|
||||
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
|
||||
const acceptBtn = await screen.findByRole('button', { name: 'Update to published library content' });
|
||||
await user.click(acceptBtn);
|
||||
const confirmBtn = await screen.findByRole('button', { name: 'Discard local edits and update' });
|
||||
await user.click(confirmBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
|
||||
messageTypes.completeXBlockEditing,
|
||||
{ locator: usageKey },
|
||||
);
|
||||
expect(axiosMock.history.post.length).toEqual(1);
|
||||
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
|
||||
});
|
||||
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keep changes work', async () => {
|
||||
const user = userEvent.setup();
|
||||
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(200, {});
|
||||
render({ ...defaultEventData, isLocallyModified: true });
|
||||
|
||||
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
|
||||
const ignoreBtn = await screen.findByRole('button', { name: 'Keep course content' });
|
||||
await user.click(ignoreBtn);
|
||||
const ignoreConfirmBtn = (await screen.findAllByRole('button', { name: 'Keep course content' }))[0];
|
||||
await user.click(ignoreConfirmBtn);
|
||||
await waitFor(() => {
|
||||
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
|
||||
messageTypes.completeXBlockEditing,
|
||||
{ locator: usageKey },
|
||||
);
|
||||
expect(axiosMock.history.delete.length).toEqual(1);
|
||||
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
|
||||
});
|
||||
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,111 +1,28 @@
|
||||
import { useCallback, useContext, useState } from 'react';
|
||||
import {
|
||||
useCallback, useContext, useMemo, useState,
|
||||
} from 'react';
|
||||
import {
|
||||
ActionRow, Button, Icon, ModalDialog, useToggle,
|
||||
ActionRow, Button, ModalDialog, useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { Info } from '@openedx/paragon/icons';
|
||||
import { Warning } from '@openedx/paragon/icons';
|
||||
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { ToastContext } from '@src/generic/toast-context';
|
||||
import Loading from '@src/generic/Loading';
|
||||
import CompareChangesWidget from '@src/library-authoring/component-comparison/CompareChangesWidget';
|
||||
import AlertMessage from '@src/generic/alert-message';
|
||||
import LoadingButton from '@src/generic/loading-button';
|
||||
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
|
||||
import { useIframe } from '@src/generic/hooks/context/hooks';
|
||||
import { useEventListener } from '@src/generic/hooks';
|
||||
import { getItemIcon } from '@src/generic/block-type-utils';
|
||||
import { CompareContainersWidget } from '@src/container-comparison/CompareContainersWidget';
|
||||
|
||||
import { useEventListener } from '../../generic/hooks';
|
||||
import { messageTypes } from '../constants';
|
||||
import CompareChangesWidget from '../../library-authoring/component-comparison/CompareChangesWidget';
|
||||
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks';
|
||||
import AlertMessage from '../../generic/alert-message';
|
||||
import { useIframe } from '../../generic/hooks/context/hooks';
|
||||
import DeleteModal from '../../generic/delete-modal/DeleteModal';
|
||||
import messages from './messages';
|
||||
|
||||
type ConfirmationModalType = 'ignore' | 'update' | 'keep' | undefined;
|
||||
|
||||
const ConfirmationModal = ({
|
||||
modalType,
|
||||
onClose,
|
||||
updateAndRefresh,
|
||||
}: {
|
||||
modalType: ConfirmationModalType,
|
||||
onClose: () => void,
|
||||
updateAndRefresh: (accept: boolean, overrideCustomizations: boolean) => void,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
btnLabel,
|
||||
btnVariant,
|
||||
accept,
|
||||
overrideCustomizations,
|
||||
} = useMemo(() => {
|
||||
let resultTitle: string | undefined;
|
||||
let resultDescription: string | undefined;
|
||||
let resutlBtnLabel: string | undefined;
|
||||
let resultAccept: boolean = false;
|
||||
let resultOverrideCustomizations: boolean = false;
|
||||
let resultBtnVariant: 'danger' | 'primary' = 'danger';
|
||||
|
||||
switch (modalType) {
|
||||
case 'ignore':
|
||||
resultTitle = intl.formatMessage(messages.confirmationTitle);
|
||||
resultDescription = intl.formatMessage(messages.confirmationDescription);
|
||||
resutlBtnLabel = intl.formatMessage(messages.confirmationConfirmBtn);
|
||||
break;
|
||||
case 'update':
|
||||
resultTitle = intl.formatMessage(messages.updateToPublishedLibraryContentTitle);
|
||||
resultDescription = intl.formatMessage(messages.updateToPublishedLibraryContentBody);
|
||||
resutlBtnLabel = intl.formatMessage(messages.updateToPublishedLibraryContentConfirm);
|
||||
resultAccept = true;
|
||||
resultOverrideCustomizations = true;
|
||||
break;
|
||||
case 'keep':
|
||||
resultTitle = intl.formatMessage(messages.keepCourseContentTitle);
|
||||
resultDescription = intl.formatMessage(messages.keepCourseContentBody);
|
||||
resutlBtnLabel = intl.formatMessage(messages.keepCourseContentButton);
|
||||
resultBtnVariant = 'primary';
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
title: resultTitle,
|
||||
description: resultDescription,
|
||||
btnLabel: resutlBtnLabel,
|
||||
accept: resultAccept,
|
||||
btnVariant: resultBtnVariant,
|
||||
overrideCustomizations: resultOverrideCustomizations,
|
||||
};
|
||||
}, [modalType]);
|
||||
|
||||
return (
|
||||
<DeleteModal
|
||||
isOpen={modalType !== undefined}
|
||||
close={onClose}
|
||||
variant="warning"
|
||||
title={title}
|
||||
description={description}
|
||||
onDeleteSubmit={() => updateAndRefresh(accept, overrideCustomizations)}
|
||||
btnLabel={btnLabel}
|
||||
buttonVariant={btnVariant}
|
||||
/>
|
||||
);
|
||||
};
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import LoadingButton from '../../generic/loading-button';
|
||||
import Loading from '../../generic/Loading';
|
||||
|
||||
export interface LibraryChangesMessageData {
|
||||
displayName: string,
|
||||
downstreamBlockId: string,
|
||||
upstreamBlockId: string,
|
||||
upstreamBlockVersionSynced: number,
|
||||
isLocallyModified?: boolean,
|
||||
isContainer: boolean,
|
||||
blockType?: string | null,
|
||||
isReadyToSyncIndividually?: boolean,
|
||||
}
|
||||
|
||||
export interface PreviewLibraryXBlockChangesProps {
|
||||
@@ -128,41 +45,27 @@ export const PreviewLibraryXBlockChanges = ({
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const intl = useIntl();
|
||||
|
||||
const [confirmationModalType, setConfirmationModalType] = useState<ConfirmationModalType>();
|
||||
// ignore changes confirmation modal toggle.
|
||||
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
|
||||
|
||||
const acceptChangesMutation = useAcceptLibraryBlockChanges();
|
||||
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
|
||||
|
||||
const isTextWithLocalChanges = (blockData.blockType === 'html' && blockData.isLocallyModified);
|
||||
|
||||
const getBody = useCallback(() => {
|
||||
if (!blockData) {
|
||||
return <Loading />;
|
||||
}
|
||||
if (blockData.isContainer) {
|
||||
return (
|
||||
<CompareContainersWidget
|
||||
upstreamBlockId={blockData.upstreamBlockId}
|
||||
downstreamBlockId={blockData.downstreamBlockId}
|
||||
isReadyToSyncIndividually={blockData.isReadyToSyncIndividually}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CompareChangesWidget
|
||||
usageKey={blockData.upstreamBlockId}
|
||||
oldUsageKey={blockData.downstreamBlockId}
|
||||
oldTitle={isTextWithLocalChanges ? blockData.displayName : undefined}
|
||||
oldVersion={blockData.upstreamBlockVersionSynced || 'published'}
|
||||
newVersion="published"
|
||||
hasLocalChanges={isTextWithLocalChanges}
|
||||
showNewTitle={isTextWithLocalChanges}
|
||||
isContainer={blockData.isContainer}
|
||||
/>
|
||||
);
|
||||
}, [blockData, isTextWithLocalChanges]);
|
||||
}, [blockData]);
|
||||
|
||||
const updateAndRefresh = useCallback(async (accept: boolean, overrideCustomizations: boolean) => {
|
||||
const updateAndRefresh = useCallback(async (accept: boolean) => {
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!blockData) {
|
||||
return;
|
||||
@@ -172,10 +75,7 @@ export const PreviewLibraryXBlockChanges = ({
|
||||
const failureMsg = accept ? messages.acceptChangesFailure : messages.ignoreChangesFailure;
|
||||
|
||||
try {
|
||||
await mutation.mutateAsync({
|
||||
blockId: blockData.downstreamBlockId,
|
||||
overrideCustomizations,
|
||||
});
|
||||
await mutation.mutateAsync(blockData.downstreamBlockId);
|
||||
postChange(accept);
|
||||
} catch (e) {
|
||||
showToast(intl.formatMessage(failureMsg));
|
||||
@@ -184,46 +84,21 @@ export const PreviewLibraryXBlockChanges = ({
|
||||
}
|
||||
}, [blockData]);
|
||||
|
||||
const itemIcon = getItemIcon(blockData.blockType || '');
|
||||
|
||||
// Build title
|
||||
const defaultTitle = intl.formatMessage(
|
||||
blockData.isContainer
|
||||
? messages.defaultContainerTitle
|
||||
: messages.defaultComponentTitle,
|
||||
{
|
||||
itemIcon: <Icon size="lg" src={itemIcon} />,
|
||||
},
|
||||
);
|
||||
const title = blockData.displayName
|
||||
? intl.formatMessage(messages.title, {
|
||||
blockTitle: blockData?.displayName,
|
||||
blockIcon: <Icon size="lg" src={itemIcon} />,
|
||||
})
|
||||
? intl.formatMessage(messages.title, { blockTitle: blockData?.displayName })
|
||||
: defaultTitle;
|
||||
|
||||
// Build aria label
|
||||
const defaultAriaLabel = intl.formatMessage(
|
||||
blockData.isContainer
|
||||
? messages.defaultContainerTitle
|
||||
: messages.defaultComponentTitle,
|
||||
{
|
||||
itemIcon: '',
|
||||
},
|
||||
);
|
||||
const ariaLabel = blockData.displayName
|
||||
? intl.formatMessage(messages.title, {
|
||||
blockTitle: blockData?.displayName,
|
||||
blockIcon: '',
|
||||
})
|
||||
: defaultAriaLabel;
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
isOpen={isModalOpen}
|
||||
onClose={closeModal}
|
||||
size="xl"
|
||||
title={ariaLabel}
|
||||
title={title}
|
||||
className="lib-preview-xblock-changes-modal"
|
||||
hasCloseButton
|
||||
isFullscreenOnMobile
|
||||
@@ -231,57 +106,43 @@ export const PreviewLibraryXBlockChanges = ({
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
<div className="d-flex preview-title">
|
||||
{title}
|
||||
</div>
|
||||
{title}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
{isTextWithLocalChanges && (
|
||||
<AlertMessage
|
||||
show
|
||||
variant="info"
|
||||
icon={Info}
|
||||
title={intl.formatMessage(messages.localEditsAlert)}
|
||||
/>
|
||||
)}
|
||||
<AlertMessage
|
||||
show
|
||||
variant="warning"
|
||||
icon={Warning}
|
||||
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
|
||||
/>
|
||||
{getBody()}
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
{isTextWithLocalChanges ? (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => setConfirmationModalType('update')}
|
||||
>
|
||||
<FormattedMessage {...messages.updateToPublishedLibraryContentButton} />
|
||||
</Button>
|
||||
) : (
|
||||
<LoadingButton
|
||||
onClick={() => updateAndRefresh(true, false)}
|
||||
label={intl.formatMessage(messages.acceptChangesBtn)}
|
||||
/>
|
||||
)}
|
||||
{isTextWithLocalChanges ? (
|
||||
<Button
|
||||
onClick={() => setConfirmationModalType('keep')}
|
||||
>
|
||||
<FormattedMessage {...messages.keepCourseContentButton} />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={() => setConfirmationModalType('ignore')}
|
||||
>
|
||||
<FormattedMessage {...messages.ignoreChangesBtn} />
|
||||
</Button>
|
||||
)}
|
||||
<LoadingButton
|
||||
onClick={() => updateAndRefresh(true)}
|
||||
label={intl.formatMessage(messages.acceptChangesBtn)}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={openConfirmModal}
|
||||
>
|
||||
<FormattedMessage {...messages.ignoreChangesBtn} />
|
||||
</Button>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
<FormattedMessage {...messages.cancelBtn} />
|
||||
</ModalDialog.CloseButton>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
<ConfirmationModal
|
||||
modalType={confirmationModalType}
|
||||
onClose={() => setConfirmationModalType(undefined)}
|
||||
updateAndRefresh={updateAndRefresh}
|
||||
<DeleteModal
|
||||
isOpen={isConfirmModalOpen}
|
||||
close={closeConfirmModal}
|
||||
variant="warning"
|
||||
title={intl.formatMessage(messages.confirmationTitle)}
|
||||
description={intl.formatMessage(messages.confirmationDescription)}
|
||||
onDeleteSubmit={() => updateAndRefresh(false)}
|
||||
btnLabel={intl.formatMessage(messages.confirmationConfirmBtn)}
|
||||
/>
|
||||
</ModalDialog>
|
||||
);
|
||||
|
||||
@@ -3,17 +3,17 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'authoring.course-unit.preview-changes.modal-title',
|
||||
defaultMessage: 'Preview changes: {blockIcon} {blockTitle}',
|
||||
defaultMessage: 'Preview changes: {blockTitle}',
|
||||
description: 'Preview changes modal title text',
|
||||
},
|
||||
defaultContainerTitle: {
|
||||
id: 'authoring.course-unit.preview-changes.modal-default-unit-title',
|
||||
defaultMessage: 'Preview changes: {itemIcon} Container',
|
||||
defaultMessage: 'Preview changes: Container',
|
||||
description: 'Preview changes modal default title text for containers',
|
||||
},
|
||||
defaultComponentTitle: {
|
||||
id: 'authoring.course-unit.preview-changes.modal-default-component-title',
|
||||
defaultMessage: 'Preview changes: {itemIcon} Component',
|
||||
defaultMessage: 'Preview changes: Component',
|
||||
description: 'Preview changes modal default title text for components',
|
||||
},
|
||||
acceptChangesBtn: {
|
||||
@@ -36,6 +36,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Failed to ignore changes',
|
||||
description: 'Toast message to display when ignore changes call fails',
|
||||
},
|
||||
cancelBtn: {
|
||||
id: 'authoring.course-unit.preview-changes.cancel-btn',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Preview changes modal cancel button text.',
|
||||
},
|
||||
confirmationTitle: {
|
||||
id: 'authoring.course-unit.preview-changes.confirmation-dialog-title',
|
||||
defaultMessage: 'Ignore these changes?',
|
||||
@@ -51,45 +56,10 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Ignore',
|
||||
description: 'Preview changes confirmation dialog confirm button text when user clicks on ignore changes.',
|
||||
},
|
||||
localEditsAlert: {
|
||||
id: 'course-authoring.review-tab.preview.loal-edits-alert',
|
||||
defaultMessage: 'This library content has local edits.',
|
||||
description: 'Alert message stating that the content has local edits',
|
||||
},
|
||||
updateToPublishedLibraryContentButton: {
|
||||
id: 'course-authoring.review-tab.preview.update-to-published.button.text',
|
||||
defaultMessage: 'Update to published library content',
|
||||
description: 'Label of the button to update a content to the published library content',
|
||||
},
|
||||
updateToPublishedLibraryContentTitle: {
|
||||
id: 'course-authoring.review-tab.preview.update-to-published.modal.title',
|
||||
defaultMessage: 'Update to published library content?',
|
||||
description: 'Title of the modal to update a content to the published library content',
|
||||
},
|
||||
updateToPublishedLibraryContentBody: {
|
||||
id: 'course-authoring.review-tab.preview.update-to-published.modal.body',
|
||||
defaultMessage: 'Updating this block will discard local changes. Any edits made within this course will be discarded, and cannot be recovered',
|
||||
description: 'Body of the modal to update a content to the published library content',
|
||||
},
|
||||
updateToPublishedLibraryContentConfirm: {
|
||||
id: 'course-authoring.review-tab.preview.update-to-published.modal.confirm',
|
||||
defaultMessage: 'Discard local edits and update',
|
||||
description: 'Label of the button in the modal to update a content to the published library content',
|
||||
},
|
||||
keepCourseContentButton: {
|
||||
id: 'course-authoring.review-tab.preview.keep-course-content.button.text',
|
||||
defaultMessage: 'Keep course content',
|
||||
description: 'Label of the button to keep the content of a course component',
|
||||
},
|
||||
keepCourseContentTitle: {
|
||||
id: 'course-authoring.review-tab.preview.keep-course-content.modal.title',
|
||||
defaultMessage: 'Keep course content?',
|
||||
description: 'Title of the modal to keep the content of a course component',
|
||||
},
|
||||
keepCourseContentBody: {
|
||||
id: 'course-authoring.review-tab.preview.keep-course-content.modal.body',
|
||||
defaultMessage: 'This will keep the locally edited course content. If the component is published again in its library, you can choose to update to published library content',
|
||||
description: 'Body of the modal to keep the content of a course component',
|
||||
olderVersionPreviewAlert: {
|
||||
id: 'course-authoring.review-tab.preview.old-version-alert',
|
||||
defaultMessage: 'The old version preview is the previous library version',
|
||||
description: 'Alert message stating that older version in preview is of library block',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ export type UseMessageHandlersTypes = {
|
||||
handleOpenManageTagsModal: (id: string) => void;
|
||||
handleShowProcessingNotification: (variant: string) => void;
|
||||
handleHideProcessingNotification: () => void;
|
||||
handleRefreshIframe: () => void;
|
||||
};
|
||||
|
||||
export type MessageHandlersTypes = Record<string, (payload: any) => void>;
|
||||
|
||||
@@ -31,7 +31,6 @@ export const useMessageHandlers = ({
|
||||
handleShowProcessingNotification,
|
||||
handleHideProcessingNotification,
|
||||
handleEditXBlock,
|
||||
handleRefreshIframe,
|
||||
}: UseMessageHandlersTypes): MessageHandlersTypes => {
|
||||
const { copyToClipboard } = useClipboard();
|
||||
|
||||
@@ -51,7 +50,6 @@ export const useMessageHandlers = ({
|
||||
[messageTypes.saveEditedXBlockData]: handleSaveEditedXBlockData,
|
||||
[messageTypes.studioAjaxError]: ({ error }) => handleResponseErrors(error, dispatch, updateSavingStatus),
|
||||
[messageTypes.refreshPositions]: handleFinishXBlockDragging,
|
||||
[messageTypes.refreshIframe]: handleRefreshIframe,
|
||||
[messageTypes.openManageTags]: (payload) => handleOpenManageTagsModal(payload.contentId),
|
||||
[messageTypes.addNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.adding),
|
||||
[messageTypes.pasteNewComponent]: () => handleShowProcessingNotification(NOTIFICATION_MESSAGES.pasting),
|
||||
|
||||
@@ -46,8 +46,6 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
const intl = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Useful to reload iframe
|
||||
const [iframeKey, setIframeKey] = useState(0);
|
||||
const [isDeleteModalOpen, openDeleteModal, closeDeleteModal] = useToggle(false);
|
||||
const [isUnlinkModalOpen, openUnlinkModal, closeUnlinkModal] = useToggle(false);
|
||||
const [isConfigureModalOpen, openConfigureModal, closeConfigureModal] = useToggle(false);
|
||||
@@ -184,12 +182,6 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
dispatch(hideProcessingNotification());
|
||||
};
|
||||
|
||||
const handleRefreshIframe = () => {
|
||||
// Updating iframeKey forces the iframe to re-render.
|
||||
/* istanbul ignore next */
|
||||
setIframeKey((prev) => prev + 1);
|
||||
};
|
||||
|
||||
const messageHandlers = useMessageHandlers({
|
||||
courseId,
|
||||
dispatch,
|
||||
@@ -207,7 +199,6 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
handleShowProcessingNotification,
|
||||
handleHideProcessingNotification,
|
||||
handleEditXBlock,
|
||||
handleRefreshIframe,
|
||||
});
|
||||
|
||||
useIframeMessages(messageHandlers);
|
||||
@@ -277,7 +268,6 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||
/>
|
||||
) : null}
|
||||
<iframe
|
||||
key={iframeKey}
|
||||
ref={iframeRef}
|
||||
title={intl.formatMessage(messages.xblockIframeTitle)}
|
||||
name="xblock-iframe"
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('CustomPages', () => {
|
||||
it('should update page order on drag', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
const buttons = screen.queryAllByRole('button');
|
||||
const buttons = await screen.queryAllByRole('button');
|
||||
const draggableButton = buttons[9];
|
||||
expect(draggableButton).toBeVisible();
|
||||
await act(async () => {
|
||||
|
||||
@@ -32,7 +32,6 @@ export async function getCourseDetail(courseId: string, username: string) {
|
||||
*/
|
||||
export const waffleFlagDefaults = {
|
||||
enableCourseOptimizer: false,
|
||||
enableCourseOptimizerCheckPrevRunLinks: false,
|
||||
useNewHomePage: true,
|
||||
useNewCustomPages: true,
|
||||
useNewScheduleDetailsPage: true,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getWaffleFlags, waffleFlagDefaults } from './api';
|
||||
export const useWaffleFlags = (courseId?: string) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isPending: isLoading, isError } = useQuery({
|
||||
const { data, isLoading, isError } = useQuery({
|
||||
queryKey: ['waffleFlags', courseId],
|
||||
queryFn: () => getWaffleFlags(courseId),
|
||||
// Waffle flags change rarely, so never bother refetching them:
|
||||
|
||||
@@ -48,23 +48,11 @@ export interface XBlockPrereqs {
|
||||
blockDisplayName: string;
|
||||
}
|
||||
|
||||
export interface UpstreamChildrenInfo {
|
||||
name: string;
|
||||
upstream: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface UpstreamInfo {
|
||||
readyToSync: boolean,
|
||||
upstreamRef: string,
|
||||
versionSynced: number,
|
||||
versionAvailable: number | null,
|
||||
versionDeclined: number | null,
|
||||
errorMessage: string | null,
|
||||
downstreamCustomized: string[],
|
||||
hasTopLevelParent?: boolean,
|
||||
readyToSyncChildren?: UpstreamChildrenInfo[],
|
||||
isReadyToSyncIndividually?: boolean,
|
||||
}
|
||||
|
||||
export interface XBlock {
|
||||
|
||||
@@ -18,7 +18,6 @@ import { FeedbackBox } from './components/Feedback';
|
||||
import * as hooks from './hooks';
|
||||
import { ProblemTypeKeys } from '../../../../../data/constants/problem';
|
||||
import ExpandableTextArea from '../../../../../sharedComponents/ExpandableTextArea';
|
||||
import { answerRangeFormatRegex } from '../../../data/OLXParser';
|
||||
|
||||
const AnswerOption = ({
|
||||
answer,
|
||||
@@ -49,11 +48,6 @@ const AnswerOption = ({
|
||||
? `${getConfig().STUDIO_BASE_URL}/library_assets/blocks/${blockId}/`
|
||||
: undefined;
|
||||
|
||||
const validateAnswerRange = (value) => {
|
||||
const cleanedValue = value.replace(/^\s+|\s+$/g, '');
|
||||
return !cleanedValue.length || answerRangeFormatRegex.test(cleanedValue);
|
||||
};
|
||||
|
||||
const getInputArea = () => {
|
||||
if ([ProblemTypeKeys.SINGLESELECT, ProblemTypeKeys.MULTISELECT].includes(problemType)) {
|
||||
return (
|
||||
@@ -83,9 +77,8 @@ const AnswerOption = ({
|
||||
);
|
||||
}
|
||||
// Return Answer Range View
|
||||
const isValidValue = validateAnswerRange(answer.title);
|
||||
return (
|
||||
<Form.Group isInvalid={!isValidValue}>
|
||||
<div>
|
||||
<Form.Control
|
||||
as="textarea"
|
||||
className="answer-option-textarea text-gray-500 small"
|
||||
@@ -95,15 +88,10 @@ const AnswerOption = ({
|
||||
onChange={setAnswerTitle}
|
||||
placeholder={intl.formatMessage(messages.answerRangeTextboxPlaceholder)}
|
||||
/>
|
||||
{!isValidValue && (
|
||||
<Form.Control.Feedback type="invalid">
|
||||
<FormattedMessage {...messages.answerRangeErrorText} />
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
<div className="pgn__form-switch-helper-text">
|
||||
<FormattedMessage {...messages.answerRangeHelperText} />
|
||||
</div>
|
||||
</Form.Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -77,11 +77,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Enter min and max values separated by a comma. Use a bracket to include the number next to it in the range, or a parenthesis to exclude the number. For example, to identify the correct answers as 5, 6, or 7, but not 8, specify [5,8).',
|
||||
description: 'Helper text describing usage of answer ranges',
|
||||
},
|
||||
answerRangeErrorText: {
|
||||
id: 'authoring.answerwidget.answer.answerRangeErrorText',
|
||||
defaultMessage: 'Error: Invalid range format. Use brackets or parentheses with values separated by a comma.',
|
||||
description: 'Error text describing wrong format of answer ranges',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -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,
|
||||
} = useEditorContext();
|
||||
const rawMarkdown = useSelector(selectors.problem.rawMarkdown);
|
||||
const isMarkdownEditorEnabled = useSelector(selectors.problem.isMarkdownEditorEnabled);
|
||||
const showMarkdownEditorButton = isMarkdownEditorEnabledForContext && rawMarkdown;
|
||||
const { isAdvancedCardsVisible, showAdvancedCards } = showAdvancedSettingsCards();
|
||||
const feedbackCard = () => {
|
||||
@@ -162,7 +161,7 @@ const SettingsWidget = ({
|
||||
<div className="my-3">
|
||||
<SwitchEditorCard problemType={problemType} editorType="advanced" />
|
||||
</div>
|
||||
{ (showMarkdownEditorButton && !isMarkdownEditorEnabled) // Only show button if not already in markdown editor
|
||||
{ showMarkdownEditorButton
|
||||
&& (
|
||||
<div className="my-3">
|
||||
<SwitchEditorCard problemType={problemType} editorType="markdown" />
|
||||
|
||||
@@ -2,11 +2,10 @@ import React from 'react';
|
||||
|
||||
import { ProblemTypeKeys } from '@src/editors/data/constants/problem';
|
||||
import { screen, initializeMocks } from '@src/testUtils';
|
||||
import { editorRender, type PartialEditorState } from '@src/editors/editorTestRender';
|
||||
import { editorRender } from '@src/editors/editorTestRender';
|
||||
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
|
||||
import * as hooks from './hooks';
|
||||
import { SettingsWidgetInternal as SettingsWidget } from '.';
|
||||
import { ProblemEditorContextProvider } from '../ProblemEditorContext';
|
||||
|
||||
jest.mock('./settingsComponents/GeneralFeedback', () => 'GeneralFeedback');
|
||||
jest.mock('./settingsComponents/GroupFeedback', () => 'GroupFeedback');
|
||||
@@ -24,6 +23,7 @@ describe('SettingsWidget', () => {
|
||||
const showAdvancedSettingsCardsBaseProps = {
|
||||
isAdvancedCardsVisible: false,
|
||||
showAdvancedCards: jest.fn().mockName('showAdvancedSettingsCards.showAdvancedCards'),
|
||||
setResetTrue: jest.fn().mockName('showAdvancedSettingsCards.setResetTrue'),
|
||||
};
|
||||
|
||||
const props = {
|
||||
@@ -49,18 +49,6 @@ describe('SettingsWidget', () => {
|
||||
|
||||
};
|
||||
|
||||
const editorRef = { current: null };
|
||||
|
||||
const renderSettingsWidget = (
|
||||
overrideProps = {},
|
||||
options = {},
|
||||
) => editorRender(
|
||||
<ProblemEditorContextProvider editorRef={editorRef}>
|
||||
<SettingsWidget {...props} {...overrideProps} />
|
||||
</ProblemEditorContextProvider>,
|
||||
options,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
@@ -68,7 +56,7 @@ describe('SettingsWidget', () => {
|
||||
describe('behavior', () => {
|
||||
it('calls showAdvancedSettingsCards when initialized', () => {
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps);
|
||||
renderSettingsWidget();
|
||||
editorRender(<SettingsWidget {...props} />);
|
||||
expect(hooks.showAdvancedSettingsCards).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -76,7 +64,7 @@ describe('SettingsWidget', () => {
|
||||
describe('renders', () => {
|
||||
test('renders Settings widget page', () => {
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps);
|
||||
renderSettingsWidget();
|
||||
editorRender(<SettingsWidget {...props} />);
|
||||
expect(screen.getByText('Show advanced settings')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -86,7 +74,7 @@ describe('SettingsWidget', () => {
|
||||
isAdvancedCardsVisible: true,
|
||||
};
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
|
||||
const { container } = renderSettingsWidget();
|
||||
const { container } = editorRender(<SettingsWidget {...props} />);
|
||||
expect(screen.queryByText('Show advanced settings')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('showanswercard')).toBeInTheDocument();
|
||||
expect(container.querySelector('resetcard')).toBeInTheDocument();
|
||||
@@ -98,49 +86,12 @@ describe('SettingsWidget', () => {
|
||||
isAdvancedCardsVisible: true,
|
||||
};
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
|
||||
const { container } = renderSettingsWidget({ problemType: ProblemTypeKeys.ADVANCED });
|
||||
const { container } = editorRender(
|
||||
<SettingsWidget {...props} problemType={ProblemTypeKeys.ADVANCED} />,
|
||||
);
|
||||
expect(container.querySelector('randomization')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
describe('SwitchEditorCard rendering (markdown vs advanced)', () => {
|
||||
test('shows two SwitchEditorCard components when markdown is available and not currently enabled', () => {
|
||||
const showAdvancedSettingsCardsProps = {
|
||||
...showAdvancedSettingsCardsBaseProps,
|
||||
isAdvancedCardsVisible: true,
|
||||
};
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
|
||||
const modifiedInitialState: PartialEditorState = {
|
||||
problem: {
|
||||
problemType: null, // non-advanced problem
|
||||
isMarkdownEditorEnabled: false, // currently in advanced/raw (or standard) editor
|
||||
rawOLX: '<problem></problem>',
|
||||
rawMarkdown: '## Problem', // markdown content exists so button should appear
|
||||
isDirty: false,
|
||||
},
|
||||
};
|
||||
const { container } = renderSettingsWidget({}, { initialState: modifiedInitialState });
|
||||
expect(container.querySelectorAll('switcheditorcard')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('shows only the advanced SwitchEditorCard when already in markdown mode', () => {
|
||||
const showAdvancedSettingsCardsProps = {
|
||||
...showAdvancedSettingsCardsBaseProps,
|
||||
isAdvancedCardsVisible: true,
|
||||
};
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
|
||||
const modifiedInitialState: PartialEditorState = {
|
||||
problem: {
|
||||
problemType: null,
|
||||
isMarkdownEditorEnabled: true, // already in markdown editor, so markdown button hidden
|
||||
rawOLX: '<problem></problem>',
|
||||
rawMarkdown: '## Problem',
|
||||
isDirty: false,
|
||||
},
|
||||
};
|
||||
const { container } = renderSettingsWidget({}, { initialState: modifiedInitialState });
|
||||
expect(container.querySelectorAll('switcheditorcard')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isLibrary', () => {
|
||||
const libraryProps = {
|
||||
@@ -149,7 +100,7 @@ describe('SettingsWidget', () => {
|
||||
};
|
||||
test('renders Settings widget page', () => {
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsBaseProps);
|
||||
const { container } = renderSettingsWidget(libraryProps);
|
||||
const { container } = editorRender(<SettingsWidget {...libraryProps} />);
|
||||
expect(container.querySelector('timercard')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('resetcard')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('typecard')).toBeInTheDocument();
|
||||
@@ -163,7 +114,7 @@ describe('SettingsWidget', () => {
|
||||
isAdvancedCardsVisible: true,
|
||||
};
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
|
||||
const { container } = renderSettingsWidget(libraryProps);
|
||||
const { container } = editorRender(<SettingsWidget {...libraryProps} />);
|
||||
expect(screen.queryByText('Show advanced settings')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('showanswearscard')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('resetcard')).not.toBeInTheDocument();
|
||||
@@ -177,7 +128,7 @@ describe('SettingsWidget', () => {
|
||||
isAdvancedCardsVisible: true,
|
||||
};
|
||||
jest.spyOn(hooks, 'showAdvancedSettingsCards').mockReturnValue(showAdvancedSettingsCardsProps);
|
||||
const { container } = renderSettingsWidget({ ...libraryProps, problemType: ProblemTypeKeys.ADVANCED });
|
||||
const { container } = editorRender(<SettingsWidget {...libraryProps} problemType={ProblemTypeKeys.ADVANCED} />);
|
||||
expect(container.querySelector('randomization')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -169,13 +169,13 @@ const messages = defineMessages({
|
||||
},
|
||||
'ConfirmSwitchMessage-advanced': {
|
||||
id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchMessage.advanced',
|
||||
defaultMessage: 'If you use the advanced editor, this problem will be converted to OLX. Depending on what edits you make to the OLX, you may not be able to return to the simple editor.',
|
||||
defaultMessage: 'If you use the advanced editor, this problem will be converted to OLX and you will not be able to return to the simple editor.',
|
||||
description: 'message to confirm that a user wants to use the advanced editor',
|
||||
},
|
||||
'ConfirmSwitchMessage-markdown': {
|
||||
id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchMessage.markdown',
|
||||
defaultMessage: 'Some edits that are possible with the markdown editor are not supported by the simple editor, so you may not be able to change back to the simple editor.',
|
||||
description: 'message to confirm that a user wants to use the markdown editor',
|
||||
defaultMessage: 'If you use the markdown editor, this problem will be converted to markdown and you will not be able to return to the simple editor.',
|
||||
description: 'message to confirm that a user wants to use the advanced editor',
|
||||
},
|
||||
'ConfirmSwitchMessageTitle-advanced': {
|
||||
id: 'authoring.problemeditor.settings.switchtoeditor.ConfirmSwitchMessageTitle.advanced',
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import React from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { Card } from '@openedx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import { thunkActions } from '@src/editors/data/redux';
|
||||
import { useEditorContext } from '@src/editors/EditorContext';
|
||||
import { selectors, thunkActions } from '@src/editors/data/redux';
|
||||
import BaseModal from '@src/editors/sharedComponents/BaseModal';
|
||||
import Button from '@src/editors/sharedComponents/Button';
|
||||
import { ProblemTypeKeys } from '@src/editors/data/constants/problem';
|
||||
import messages from '../messages';
|
||||
import { handleConfirmEditorSwitch } from '../hooks';
|
||||
import { useProblemEditorContext } from '../../ProblemEditorContext';
|
||||
|
||||
const SwitchEditorCard = ({
|
||||
editorType,
|
||||
problemType,
|
||||
}) => {
|
||||
const [isConfirmOpen, setConfirmOpen] = React.useState(false);
|
||||
const { isMarkdownEditorEnabledForContext } = useEditorContext();
|
||||
const isMarkdownEditorEnabled = useSelector(selectors.problem.isMarkdownEditorEnabled);
|
||||
const dispatch = useDispatch();
|
||||
const { editorRef } = useProblemEditorContext();
|
||||
if (problemType === ProblemTypeKeys.ADVANCED) { return null; }
|
||||
|
||||
const isMarkdownEditorActive = isMarkdownEditorEnabled && isMarkdownEditorEnabledForContext;
|
||||
if (isMarkdownEditorActive || problemType === ProblemTypeKeys.ADVANCED) { return null; }
|
||||
|
||||
return (
|
||||
<Card className="border border-light-700 shadow-none">
|
||||
@@ -30,7 +33,7 @@ const SwitchEditorCard = ({
|
||||
<Button
|
||||
/* istanbul ignore next */
|
||||
onClick={() => handleConfirmEditorSwitch({
|
||||
switchEditor: () => dispatch(thunkActions.problem.switchEditor(editorType, editorRef)),
|
||||
switchEditor: () => dispatch(thunkActions.problem.switchEditor(editorType)),
|
||||
setConfirmOpen,
|
||||
})}
|
||||
variant="primary"
|
||||
|
||||
@@ -4,7 +4,6 @@ import userEvent from '@testing-library/user-event';
|
||||
import { editorRender } from '@src/editors/editorTestRender';
|
||||
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
|
||||
import { thunkActions } from '@src/editors/data/redux';
|
||||
import { ProblemEditorContextProvider } from '../../ProblemEditorContext';
|
||||
import SwitchEditorCard from './SwitchEditorCard';
|
||||
|
||||
const switchEditorSpy = jest.spyOn(thunkActions.problem, 'switchEditor');
|
||||
@@ -14,13 +13,6 @@ describe('SwitchEditorCard - markdown', () => {
|
||||
problemType: 'stringresponse',
|
||||
editorType: 'markdown',
|
||||
};
|
||||
const editorRef = { current: null };
|
||||
|
||||
const renderSwitchEditorCard = (overrideProps = {}) => editorRender(
|
||||
<ProblemEditorContextProvider editorRef={editorRef}>
|
||||
<SwitchEditorCard {...baseProps} {...overrideProps} />
|
||||
</ProblemEditorContextProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
@@ -31,7 +23,7 @@ describe('SwitchEditorCard - markdown', () => {
|
||||
mockWaffleFlags({ useReactMarkdownEditor: true });
|
||||
// The markdown editor is not currently active (default)
|
||||
|
||||
renderSwitchEditorCard();
|
||||
editorRender(<SwitchEditorCard {...baseProps} />);
|
||||
const user = userEvent.setup();
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
const switchButton = screen.getByRole('button', { name: 'Switch to markdown editor' });
|
||||
@@ -46,7 +38,7 @@ describe('SwitchEditorCard - markdown', () => {
|
||||
mockWaffleFlags({ useReactMarkdownEditor: true });
|
||||
// The markdown editor is not currently active (default)
|
||||
|
||||
renderSwitchEditorCard();
|
||||
editorRender(<SwitchEditorCard {...baseProps} />);
|
||||
const user = userEvent.setup();
|
||||
const switchButton = screen.getByRole('button', { name: 'Switch to markdown editor' });
|
||||
expect(switchButton).toBeInTheDocument();
|
||||
@@ -57,12 +49,28 @@ describe('SwitchEditorCard - markdown', () => {
|
||||
expect(confirmButton).toBeInTheDocument();
|
||||
expect(switchEditorSpy).not.toHaveBeenCalled();
|
||||
await user.click(confirmButton);
|
||||
expect(switchEditorSpy).toHaveBeenCalledWith('markdown', editorRef);
|
||||
expect(switchEditorSpy).toHaveBeenCalledWith('markdown');
|
||||
// Markdown editor would now be active.
|
||||
});
|
||||
|
||||
test('renders nothing for advanced problemType', () => {
|
||||
const { container } = renderSwitchEditorCard({ problemType: 'advanced' });
|
||||
const { container } = editorRender(<SwitchEditorCard {...baseProps} problemType="advanced" />);
|
||||
const reduxWrapper = (container.firstChild as HTMLElement | null);
|
||||
expect(reduxWrapper?.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
test('returns null when editor is already Markdown', () => {
|
||||
// Markdown Editor support is on for this course:
|
||||
mockWaffleFlags({ useReactMarkdownEditor: true });
|
||||
// The markdown editor *IS* currently active (default)
|
||||
|
||||
const { container } = editorRender(<SwitchEditorCard {...baseProps} />, {
|
||||
initialState: {
|
||||
problem: {
|
||||
isMarkdownEditorEnabled: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
const reduxWrapper = (container.firstChild as HTMLElement | null);
|
||||
expect(reduxWrapper?.innerHTML).toBe('');
|
||||
});
|
||||
|
||||
@@ -90,10 +90,7 @@ export const parseState = ({
|
||||
return {
|
||||
settings: {
|
||||
...settings,
|
||||
// If the save action isn’t triggered from the Markdown editor, the Markdown content might be outdated. Since the
|
||||
// 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,
|
||||
...(isMarkdownEditorEnabled && { markdown: contentString }),
|
||||
markdown_edited: isMarkdownEditorEnabled,
|
||||
},
|
||||
olx: isAdvanced || isMarkdownEditorEnabled ? rawOLX : reactBuiltOlx,
|
||||
|
||||
@@ -165,7 +165,6 @@ describe('EditProblemView hooks parseState', () => {
|
||||
assets: {},
|
||||
})();
|
||||
expect(res.olx).toBe(mockRawOLX);
|
||||
expect(res.settings.markdown).toBe(null);
|
||||
});
|
||||
it('markdown problem', () => {
|
||||
const res = hooks.parseState({
|
||||
@@ -307,8 +306,6 @@ describe('EditProblemView hooks parseState', () => {
|
||||
show_reset_button: false,
|
||||
submission_wait_seconds: 0,
|
||||
attempts_before_showanswer_button: 0,
|
||||
markdown: null,
|
||||
markdown_edited: false,
|
||||
};
|
||||
const openSaveWarningModal = jest.fn();
|
||||
|
||||
@@ -316,7 +313,6 @@ describe('EditProblemView hooks parseState', () => {
|
||||
const problem = { ...problemState, problemType: ProblemTypeKeys.NUMERIC, answers: [{ id: 'A', title: 'problem', correct: true }] };
|
||||
const content = hooks.getContent({
|
||||
isAdvancedProblemType: false,
|
||||
isMarkdownEditorEnabled: false,
|
||||
problemState: problem,
|
||||
editorRef,
|
||||
assets,
|
||||
@@ -343,7 +339,6 @@ describe('EditProblemView hooks parseState', () => {
|
||||
};
|
||||
const { settings } = hooks.getContent({
|
||||
isAdvancedProblemType: false,
|
||||
isMarkdownEditorEnabled: false,
|
||||
problemState: problem,
|
||||
editorRef,
|
||||
assets,
|
||||
@@ -358,15 +353,12 @@ describe('EditProblemView hooks parseState', () => {
|
||||
attempts_before_showanswer_button: 0,
|
||||
submission_wait_seconds: 0,
|
||||
weight: 1,
|
||||
markdown: null,
|
||||
markdown_edited: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('default advanced save and returns parseState data', () => {
|
||||
const content = hooks.getContent({
|
||||
isAdvancedProblemType: true,
|
||||
isMarkdownEditorEnabled: false,
|
||||
problemState,
|
||||
editorRef,
|
||||
assets,
|
||||
|
||||
@@ -28,7 +28,6 @@ import ExplanationWidget from './ExplanationWidget';
|
||||
import { saveBlock } from '../../../../hooks';
|
||||
|
||||
import { selectors } from '../../../../data/redux';
|
||||
import { ProblemEditorContextProvider } from './ProblemEditorContext';
|
||||
|
||||
const EditProblemView = ({ returnFunction }) => {
|
||||
const intl = useIntl();
|
||||
@@ -59,87 +58,85 @@ const EditProblemView = ({ returnFunction }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<ProblemEditorContextProvider editorRef={editorRef}>
|
||||
<EditorContainer
|
||||
getContent={() => getContent({
|
||||
problemState,
|
||||
openSaveWarningModal,
|
||||
isAdvancedProblemType,
|
||||
isMarkdownEditorEnabled,
|
||||
editorRef,
|
||||
lmsEndpointUrl,
|
||||
})}
|
||||
isDirty={checkIfDirty}
|
||||
returnFunction={returnFunction}
|
||||
>
|
||||
<AlertModal
|
||||
title={isAdvancedProblemType
|
||||
? intl.formatMessage(messages.olxSettingDiscrepancyTitle)
|
||||
: intl.formatMessage(messages.noAnswerTitle)}
|
||||
isOpen={isSaveWarningModalOpen}
|
||||
onClose={closeSaveWarningModal}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button variant="tertiary" onClick={closeSaveWarningModal}>
|
||||
<FormattedMessage {...messages.saveWarningModalCancelButtonLabel} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveBlock({
|
||||
content: parseState({
|
||||
problem: problemState,
|
||||
isAdvanced: isAdvancedProblemType,
|
||||
isMarkdown: isMarkdownEditorEnabled,
|
||||
ref: editorRef,
|
||||
lmsEndpointUrl,
|
||||
})(),
|
||||
returnFunction,
|
||||
destination: returnUrl,
|
||||
dispatch,
|
||||
analytics,
|
||||
})}
|
||||
>
|
||||
<FormattedMessage {...messages.saveWarningModalSaveButtonLabel} />
|
||||
</Button>
|
||||
</ActionRow>
|
||||
<EditorContainer
|
||||
getContent={() => getContent({
|
||||
problemState,
|
||||
openSaveWarningModal,
|
||||
isAdvancedProblemType,
|
||||
isMarkdownEditorEnabled,
|
||||
editorRef,
|
||||
lmsEndpointUrl,
|
||||
})}
|
||||
isDirty={checkIfDirty}
|
||||
returnFunction={returnFunction}
|
||||
>
|
||||
<AlertModal
|
||||
title={isAdvancedProblemType
|
||||
? intl.formatMessage(messages.olxSettingDiscrepancyTitle)
|
||||
: intl.formatMessage(messages.noAnswerTitle)}
|
||||
isOpen={isSaveWarningModalOpen}
|
||||
onClose={closeSaveWarningModal}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button variant="tertiary" onClick={closeSaveWarningModal}>
|
||||
<FormattedMessage {...messages.saveWarningModalCancelButtonLabel} />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => saveBlock({
|
||||
content: parseState({
|
||||
problem: problemState,
|
||||
isAdvanced: isAdvancedProblemType,
|
||||
isMarkdown: isMarkdownEditorEnabled,
|
||||
ref: editorRef,
|
||||
lmsEndpointUrl,
|
||||
})(),
|
||||
returnFunction,
|
||||
destination: returnUrl,
|
||||
dispatch,
|
||||
analytics,
|
||||
})}
|
||||
>
|
||||
<FormattedMessage {...messages.saveWarningModalSaveButtonLabel} />
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
{isAdvancedProblemType ? (
|
||||
<FormattedMessage {...messages.olxSettingDiscrepancyBodyExplanation} />
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<FormattedMessage {...messages.saveWarningModalBodyQuestion} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage {...messages.noAnswerBodyExplanation} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AlertModal>
|
||||
>
|
||||
{isAdvancedProblemType ? (
|
||||
<FormattedMessage {...messages.olxSettingDiscrepancyBodyExplanation} />
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<FormattedMessage {...messages.saveWarningModalBodyQuestion} />
|
||||
</div>
|
||||
<div>
|
||||
<FormattedMessage {...messages.noAnswerBodyExplanation} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AlertModal>
|
||||
|
||||
<div className="editProblemView d-flex flex-row flex-nowrap justify-content-end">
|
||||
{isAdvancedProblemType || isMarkdownEditorEnabled ? (
|
||||
<Container fluid className="advancedEditorTopMargin p-0">
|
||||
<RawEditor
|
||||
editorRef={editorRef}
|
||||
lang={isMarkdownEditorEnabled ? 'markdown' : 'xml'}
|
||||
content={isMarkdownEditorEnabled ? problemState.rawMarkdown : problemState.rawOLX}
|
||||
/>
|
||||
</Container>
|
||||
) : (
|
||||
<span className="flex-grow-1 mb-5">
|
||||
<QuestionWidget />
|
||||
<ExplanationWidget />
|
||||
<AnswerWidget problemType={problemType} />
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="editProblemView-settingsColumn">
|
||||
<SettingsWidget problemType={problemType} />
|
||||
<div className="editProblemView d-flex flex-row flex-nowrap justify-content-end">
|
||||
{isAdvancedProblemType || isMarkdownEditorEnabled ? (
|
||||
<Container fluid className="advancedEditorTopMargin p-0">
|
||||
<RawEditor
|
||||
editorRef={editorRef}
|
||||
lang={isMarkdownEditorEnabled ? 'markdown' : 'xml'}
|
||||
content={isMarkdownEditorEnabled ? problemState.rawMarkdown : problemState.rawOLX}
|
||||
/>
|
||||
</Container>
|
||||
) : (
|
||||
<span className="flex-grow-1 mb-5">
|
||||
<QuestionWidget />
|
||||
<ExplanationWidget />
|
||||
<AnswerWidget problemType={problemType} />
|
||||
</span>
|
||||
</div>
|
||||
</EditorContainer>
|
||||
</ProblemEditorContextProvider>
|
||||
)}
|
||||
|
||||
<span className="editProblemView-settingsColumn">
|
||||
<SettingsWidget problemType={problemType} />
|
||||
</span>
|
||||
</div>
|
||||
</EditorContainer>
|
||||
);
|
||||
};
|
||||
EditProblemView.defaultProps = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user