feat: add unlink upstream menu [FC-0097] (#2393)
Adds the Unlink feature to the Course Outline for Sections, Subsections and Units.
This commit is contained in:
@@ -27,6 +27,20 @@ export function getLibraryId(usageKey: string): string {
|
||||
throw new Error(`Invalid usageKey: ${usageKey}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a usage key like `block-v1:org:course:html:id`, get the course key
|
||||
*/
|
||||
export function getCourseKey(usageKey: string): string {
|
||||
const [prefix] = usageKey?.split('@') || [];
|
||||
const [blockType, courseInfo] = prefix?.split(':') || [];
|
||||
const [org, course, run] = courseInfo?.split('+') || [];
|
||||
|
||||
if (blockType === 'block-v1' && org && course && run) {
|
||||
return `course-v1:${org}+${course}+${run}`;
|
||||
}
|
||||
throw new Error(`Invalid usageKey: ${usageKey}`);
|
||||
}
|
||||
|
||||
/** Check if this is a course key */
|
||||
export function isCourseKey(learningContextKey: string | undefined | null): learningContextKey is string {
|
||||
return typeof learningContextKey === 'string' && learningContextKey.startsWith('course-v1:');
|
||||
|
||||
80
src/generic/unlink-modal/UnlinkModal.test.jsx
Normal file
80
src/generic/unlink-modal/UnlinkModal.test.jsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
fireEvent,
|
||||
screen,
|
||||
render as defaultRender,
|
||||
waitFor,
|
||||
} from '@testing-library/react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import { UnlinkModal } from './UnlinkModal';
|
||||
import messages from './messages';
|
||||
|
||||
const onUnlinkSubmitMock = jest.fn();
|
||||
const closeMock = jest.fn();
|
||||
|
||||
const renderforContainer = () => defaultRender(
|
||||
<IntlProvider locale="en">
|
||||
<UnlinkModal
|
||||
isOpen
|
||||
close={closeMock}
|
||||
category="chapter"
|
||||
displayName="Introduction to Testing"
|
||||
onDeleteSubmit={onUnlinkSubmitMock}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
const renderforComponent = () => defaultRender(
|
||||
<IntlProvider locale="en">
|
||||
<UnlinkModal
|
||||
isOpen
|
||||
close={closeMock}
|
||||
category="component"
|
||||
onDeleteSubmit={onUnlinkSubmitMock}
|
||||
/>
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
describe('<UnlinkModal />', () => {
|
||||
it('render UnlinkModal component correctly for containers', () => {
|
||||
renderforContainer();
|
||||
|
||||
expect(screen.getByText('Unlink Introduction to Testing?')).toBeInTheDocument();
|
||||
expect(screen.getByText(/are you sure you want to unlink this library Section reference/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/subsections contained in this Section will remain linked to their library versions./i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: messages.unlinkButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('render UnlinkModal component correctly for components', () => {
|
||||
renderforComponent();
|
||||
|
||||
expect(screen.getByText('Unlink this component?')).toBeInTheDocument();
|
||||
expect(screen.getByText(/are you sure you want to unlink this library Component reference/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(/will remain linked to their library versions./i),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: messages.unlinkButton.defaultMessage })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onDeleteSubmit function when the "Unlink" button is clicked', async () => {
|
||||
renderforContainer();
|
||||
|
||||
const okButton = screen.getByRole('button', { name: messages.unlinkButton.defaultMessage });
|
||||
fireEvent.click(okButton);
|
||||
waitFor(() => {
|
||||
expect(onUnlinkSubmitMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('calls the close function when the "Cancel" button is clicked', async () => {
|
||||
renderforContainer();
|
||||
|
||||
const cancelButton = screen.getByRole('button', { name: messages.cancelButton.defaultMessage });
|
||||
fireEvent.click(cancelButton);
|
||||
expect(closeMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
98
src/generic/unlink-modal/UnlinkModal.tsx
Normal file
98
src/generic/unlink-modal/UnlinkModal.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import {
|
||||
ActionRow,
|
||||
Button,
|
||||
AlertModal,
|
||||
} from '@openedx/paragon';
|
||||
import { Warning } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
import LoadingButton from '../loading-button';
|
||||
|
||||
const BoldText = (chunk: string[]) => <b>{chunk}</b>;
|
||||
|
||||
type UnlinkModalPropsContainer = {
|
||||
displayName?: string;
|
||||
category?: string;
|
||||
};
|
||||
|
||||
type UnlinkModalPropsComponent = {
|
||||
displayName?: undefined;
|
||||
category: 'component';
|
||||
};
|
||||
|
||||
type UnlinkModalProps = {
|
||||
isOpen: boolean;
|
||||
close: () => void;
|
||||
onUnlinkSubmit: () => void | Promise<void>,
|
||||
} & (UnlinkModalPropsContainer | UnlinkModalPropsComponent);
|
||||
|
||||
export const UnlinkModal = ({
|
||||
displayName,
|
||||
category,
|
||||
isOpen,
|
||||
close,
|
||||
onUnlinkSubmit,
|
||||
}: UnlinkModalProps) => {
|
||||
const intl = useIntl();
|
||||
if (!category) {
|
||||
// On the first render, the initial value for `category` might be undefined.
|
||||
return null;
|
||||
}
|
||||
|
||||
const isComponent = category === 'component' as const;
|
||||
|
||||
const categoryName = intl.formatMessage(messages[`${category}Name` as keyof typeof messages]);
|
||||
const childrenCategoryName = !isComponent
|
||||
? intl.formatMessage(messages[`${category}ChildrenName` as keyof typeof messages])
|
||||
: undefined;
|
||||
const modalTitle = !isComponent
|
||||
? intl.formatMessage(messages.title, { displayName })
|
||||
: intl.formatMessage(messages.titleComponent);
|
||||
const modalDescription = intl.formatMessage(messages.description, {
|
||||
categoryName,
|
||||
b: BoldText,
|
||||
});
|
||||
const modalDescriptionChildren = !isComponent ? intl.formatMessage(messages.descriptionChildren, {
|
||||
categoryName,
|
||||
childrenCategoryName,
|
||||
}) : null;
|
||||
|
||||
return (
|
||||
<AlertModal
|
||||
title={modalTitle}
|
||||
isOpen={isOpen}
|
||||
onClose={close}
|
||||
variant="warning"
|
||||
icon={Warning}
|
||||
footerNode={(
|
||||
<ActionRow>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}}
|
||||
variant="tertiary"
|
||||
>
|
||||
{intl.formatMessage(messages.cancelButton)}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
await onUnlinkSubmit();
|
||||
}}
|
||||
variant="primary"
|
||||
label={intl.formatMessage(messages.unlinkButton)}
|
||||
/>
|
||||
</ActionRow>
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<p className="mt-2">{modalDescription}</p>
|
||||
<p>{modalDescriptionChildren}</p>
|
||||
</div>
|
||||
</AlertModal>
|
||||
);
|
||||
};
|
||||
11
src/generic/unlink-modal/data/api.ts
Normal file
11
src/generic/unlink-modal/data/api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getDownstreamApiUrl = (downstreamBlockId: string) => (
|
||||
`${getApiBaseUrl()}/api/contentstore/v2/downstreams/${downstreamBlockId}`
|
||||
);
|
||||
|
||||
export const unlinkDownstream = async (downstreamBlockId: string): Promise<void> => {
|
||||
await getAuthenticatedHttpClient().delete(getDownstreamApiUrl(downstreamBlockId));
|
||||
};
|
||||
18
src/generic/unlink-modal/data/apiHooks.ts
Normal file
18
src/generic/unlink-modal/data/apiHooks.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { courseLibrariesQueryKeys } from '@src/course-libraries';
|
||||
import { getCourseKey } from '@src/generic/key-utils';
|
||||
|
||||
import { unlinkDownstream } from './api';
|
||||
|
||||
export const useUnlinkDownstream = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: unlinkDownstream,
|
||||
onSuccess: (_, contentId: string) => {
|
||||
const courseKey = getCourseKey(contentId);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey),
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
2
src/generic/unlink-modal/index.tsx
Normal file
2
src/generic/unlink-modal/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { UnlinkModal } from './UnlinkModal';
|
||||
export { useUnlinkDownstream } from './data/apiHooks';
|
||||
75
src/generic/unlink-modal/messages.ts
Normal file
75
src/generic/unlink-modal/messages.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.title',
|
||||
defaultMessage: 'Unlink {displayName}?',
|
||||
description: 'Title for the unlink confirmation modal',
|
||||
},
|
||||
titleComponent: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.title-component',
|
||||
defaultMessage: 'Unlink this component?',
|
||||
description: 'Title for the unlink confirmation modal when the item is a component',
|
||||
},
|
||||
description: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.description',
|
||||
defaultMessage: 'Are you sure you want to unlink this library {categoryName} reference?'
|
||||
+ ' Unlinked blocks cannot be synced. <b>Unlinking is permanent.</b>',
|
||||
description: 'Description text in the unlink confirmation modal',
|
||||
},
|
||||
descriptionChildren: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.description-children',
|
||||
defaultMessage: '{childrenCategoryName} contained in this {categoryName} will remain linked to '
|
||||
+ 'their library versions.',
|
||||
description: 'Description text in the unlink confirmation modal when the item has children',
|
||||
},
|
||||
unlinkButton: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.button.unlink',
|
||||
defaultMessage: 'Confirm Unlink',
|
||||
},
|
||||
pendingDeleteButton: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.button.pending-unlink',
|
||||
defaultMessage: 'Unlinking',
|
||||
},
|
||||
cancelButton: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.button.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
chapterName: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.chapter-name',
|
||||
defaultMessage: 'Section',
|
||||
description: 'Used to refer to a chapter in the course outline',
|
||||
},
|
||||
sequentialName: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.sequential-name',
|
||||
defaultMessage: 'Subsection',
|
||||
description: 'Used to refer to a sequential in the course outline',
|
||||
},
|
||||
verticalName: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.vertical-name',
|
||||
defaultMessage: 'Unit',
|
||||
description: 'Used to refer to a vertical in the course outline',
|
||||
},
|
||||
componentName: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.component-name',
|
||||
defaultMessage: 'Component',
|
||||
description: 'Used to refer to a component in the course outline',
|
||||
},
|
||||
chapterChildrenName: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.chapter-children-name',
|
||||
defaultMessage: 'Subsections',
|
||||
description: 'Used to refer to chapter children in the course outline',
|
||||
},
|
||||
sequentialChildrenName: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.sequential-children-name',
|
||||
defaultMessage: 'Units',
|
||||
description: 'Used to refer to sequential children in the course outline',
|
||||
},
|
||||
verticalChildrenName: {
|
||||
id: 'course-authoring.course-outline.unlink-modal.vertical-children-name',
|
||||
defaultMessage: 'Components',
|
||||
description: 'Used to refer to vertical children in the course outline',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
Reference in New Issue
Block a user