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:
Rômulo Penido
2025-08-28 13:44:15 -03:00
committed by GitHub
parent 0f2dd4a88f
commit 950bfee7c1
31 changed files with 584 additions and 29 deletions

View File

@@ -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:');

View 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);
});
});

View 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>
);
};

View 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));
};

View 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),
});
},
});
};

View File

@@ -0,0 +1,2 @@
export { UnlinkModal } from './UnlinkModal';
export { useUnlinkDownstream } from './data/apiHooks';

View 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;