feat: allow editing imported unit blocks (#2405)

Allows authors to edit imported unit display name in outline.
This commit is contained in:
Navin Karkera
2025-09-17 22:57:04 +05:30
committed by GitHub
parent c21b664a8b
commit 61c87fe6a6
20 changed files with 31 additions and 70 deletions

View File

@@ -1,11 +1,6 @@
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.',

View File

@@ -32,7 +32,7 @@ const messages = defineMessages({
description: 'Tab title for review tab',
},
reviewTabDescriptionEmpty: {
id: 'course-authoring.course-libraries.tab.home.description-no-links',
id: 'course-authoring.course-libraries.tab.review.description-no-links',
defaultMessage: 'All components are up to date',
description: 'Description text for home tab',
},

View File

@@ -7,6 +7,7 @@ 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();
@@ -232,16 +233,6 @@ 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 });
@@ -254,8 +245,8 @@ describe('<CardHeader />', () => {
expect(await screen.findByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled');
});
it('check editing is disabled when isDisabledEditField is true', async () => {
renderComponent({ ...cardHeaderProps, isDisabledEditField: true });
it('check editing is disabled when saving is in progress', async () => {
renderComponent({ ...cardHeaderProps, savingStatus: RequestStatus.IN_PROGRESS });
expect(await screen.findByTestId('subsection-edit-button')).toBeDisabled();

View File

@@ -24,6 +24,7 @@ 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';
@@ -41,7 +42,6 @@ interface CardHeaderProps {
isFormOpen: boolean;
onEditSubmit: (titleValue: string) => void;
closeForm: () => void;
isDisabledEditField: boolean;
onClickDelete: () => void;
onClickUnlink: () => void;
onClickDuplicate: () => void;
@@ -69,6 +69,7 @@ interface CardHeaderProps {
extraActionsComponent?: ReactNode,
onClickSync?: () => void;
readyToSync?: boolean;
savingStatus?: RequestStatusType;
}
const CardHeader = ({
@@ -83,7 +84,6 @@ const CardHeader = ({
isFormOpen,
onEditSubmit,
closeForm,
isDisabledEditField,
onClickDelete,
onClickUnlink,
onClickDuplicate,
@@ -103,6 +103,7 @@ const CardHeader = ({
extraActionsComponent,
onClickSync,
readyToSync,
savingStatus,
}: CardHeaderProps) => {
const intl = useIntl();
const [searchParams] = useSearchParams();
@@ -119,6 +120,7 @@ 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');
@@ -172,7 +174,7 @@ const CardHeader = ({
onEditSubmit(titleValue);
}
}}
disabled={isDisabledEditField}
disabled={isSaving}
/>
</Form.Group>
) : (
@@ -186,7 +188,7 @@ const CardHeader = ({
iconAs={EditIcon}
onClick={onClickEdit}
// @ts-ignore
disabled={isDisabledEditField}
disabled={isSaving}
/>
</>
)}
@@ -238,7 +240,7 @@ const CardHeader = ({
</Dropdown.Item>
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-configure-button`}
disabled={isDisabledEditField}
disabled={isSaving}
onClick={onClickConfigure}
>
{intl.formatMessage(messages.menuConfigure)}
@@ -246,7 +248,7 @@ const CardHeader = ({
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
disabled={isDisabledEditField}
disabled={isSaving}
onClick={openManageTagsDrawer}
>
{intl.formatMessage(messages.menuManageTags)}

View File

@@ -88,7 +88,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
onOpenDeleteModal={jest.fn()}
onOpenUnlinkModal={jest.fn()}
onOpenConfigureModal={jest.fn()}
savingStatus=""
onEditSectionSubmit={onEditSectionSubmit}
onDuplicateSubmit={jest.fn()}
isSectionsExpanded

View File

@@ -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 } from '@src/data/constants';
import { RequestStatus, RequestStatusType } 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: string,
savingStatus?: RequestStatusType,
onOpenDeleteModal: () => void,
onOpenUnlinkModal: () => void,
onDuplicateSubmit: () => void,
@@ -301,7 +301,7 @@ const SectionCard = ({
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
savingStatus={savingStatus}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}

View File

@@ -115,7 +115,6 @@ const renderComponent = (props?: object, entry = '/course/:courseId') => render(
onNewUnitSubmit={jest.fn()}
onAddUnitFromLibrary={handleOnAddUnitFromLibrary}
isCustomRelativeDatesActive={false}
savingStatus=""
onEditSubmit={onEditSubectionSubmit}
onDuplicateSubmit={jest.fn()}
onOpenConfigureModal={jest.fn()}

View File

@@ -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 } from '@src/data/constants';
import { RequestStatus, RequestStatusType } 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: string,
savingStatus?: RequestStatusType,
onOpenDeleteModal: () => void,
onOpenUnlinkModal: () => void,
onDuplicateSubmit: () => void,
@@ -303,7 +303,7 @@ const SubsectionCard = ({
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
savingStatus={savingStatus}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}

View File

@@ -81,7 +81,6 @@ 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}`}

View File

@@ -13,8 +13,7 @@ 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 } from '@src/data/constants';
import { isUnitReadOnly } from '@src/course-unit/data/utils';
import { RequestStatus, RequestStatusType } from '@src/data/constants';
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';
@@ -33,7 +32,7 @@ interface UnitCardProps {
onOpenPublishModal: () => void;
onOpenConfigureModal: () => void;
onEditSubmit: (itemId: string, sectionId: string, displayName: string) => void,
savingStatus: string;
savingStatus?: RequestStatusType;
onOpenDeleteModal: () => void;
onOpenUnlinkModal: () => void;
onDuplicateSubmit: () => void;
@@ -108,8 +107,6 @@ const UnitCard = ({
};
}, [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.
@@ -247,7 +244,7 @@ const UnitCard = ({
isFormOpen={isFormOpen}
closeForm={closeForm}
onEditSubmit={handleEditSubmit}
isDisabledEditField={readOnly || savingStatus === RequestStatus.IN_PROGRESS}
savingStatus={savingStatus}
onClickDuplicate={onDuplicateSubmit}
titleComponent={titleComponent}
namePrefix={namePrefix}

View File

@@ -2353,14 +2353,14 @@ describe('<CourseUnit />', () => {
expect(screen.getByText(/this unit can only be edited from the \./i)).toBeInTheDocument();
// Disable the "Edit" button
// Edit button should be enabled even for library imported units
const unitHeaderTitle = screen.getByTestId('unit-header-title');
const editButton = within(unitHeaderTitle).getByRole(
'button',
{ name: 'Edit' },
);
expect(editButton).toBeInTheDocument();
expect(editButton).toBeDisabled();
expect(editButton).toBeEnabled();
// The "Publish" button should still be enabled
const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
@@ -2371,14 +2371,6 @@ 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();
});

View File

@@ -3,7 +3,7 @@ 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';
import { isUnitImportedFromLib, normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils';
const getStudioBaseUrl = () => getConfig().STUDIO_BASE_URL;
@@ -41,7 +41,7 @@ export async function getVerticalData(unitId) {
.get(getCourseSectionVerticalApiUrl(unitId));
const courseSectionVerticalData = normalizeCourseSectionVerticalData(data);
courseSectionVerticalData.xblockInfo.readOnly = isUnitReadOnly(courseSectionVerticalData.xblockInfo);
courseSectionVerticalData.xblockInfo.readOnly = isUnitImportedFromLib(courseSectionVerticalData.xblockInfo);
return courseSectionVerticalData;
}

View File

@@ -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 isUnitReadOnly = ({ upstreamInfo }: XBlock): boolean => (
export const isUnitImportedFromLib = ({ upstreamInfo }: XBlock): boolean => (
!!upstreamInfo
&& !!upstreamInfo.upstreamRef
&& upstreamInfo.upstreamRef.startsWith('lct:')

View File

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

View File

@@ -76,7 +76,7 @@ describe('<HeaderTitle />', () => {
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});
it('Units sourced from upstream show a disabled edit button', async () => {
it('Units sourced from upstream show a enabled 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 })).toBeDisabled();
expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled();
expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled();
});

View File

@@ -37,7 +37,7 @@ const messages = defineMessages({
description: 'Button to save changes to the OLX',
},
advancedDetailsOLXCancelButton: {
id: 'course-authoring.library-authoring.component.advanced.olx-save',
id: 'course-authoring.library-authoring.component.advanced.olx-cancel',
defaultMessage: 'Cancel',
description: 'Button to cancel changes to the OLX',
},

View File

@@ -41,11 +41,6 @@ const messages = defineMessages({
defaultMessage: 'Remove from collection',
description: 'Menu item for remove a component from collection.',
},
menuRemoveFromContainer: {
id: 'course-authoring.library-authoring.component.menu.remove',
defaultMessage: 'Remove from {containerType}',
description: 'Menu item for remove an item from {containerType}.',
},
removeComponentFromCollectionSuccess: {
id: 'course-authoring.library-authoring.component.remove-from-collection-success',
defaultMessage: 'Item successfully removed',

View File

@@ -122,7 +122,7 @@ const messages = defineMessages({
description: 'Title text for the warning displayed before deleting a Subsection',
},
deleteSubsectionCourseMessaage: {
id: 'course-authoring.library-authoring.subsection.delete-parent-message',
id: 'course-authoring.library-authoring.subsection.delete-course-message',
defaultMessage: 'This subsection is used {courseCount, plural, one {{courseCountText} time} other {{courseCountText} times}} in courses, and will stop receiving updates there.',
description: 'Course usage details shown before deleting a subsection',
},

View File

@@ -47,7 +47,7 @@ const messages = defineMessages({
description: 'Message when the library changes are reverted successfully.',
},
revertErrorMsg: {
id: 'course-authoring.library-authoring.publish.error',
id: 'course-authoring.library-authoring.revert.error',
defaultMessage: 'There was an error reverting changes in the library.',
description: 'Message when there is an error when reverting changes in the library.',
},

View File

@@ -71,11 +71,6 @@ const messages = defineMessages({
defaultMessage: 'Coming soon!',
description: 'Temp placeholder for the collections container. This will be replaced with the actual collection list.',
},
createLibrary: {
id: 'course-authoring.library-authoring.create-library',
defaultMessage: 'Create library',
description: 'Header for the create library form',
},
createLibraryTempPlaceholder: {
id: 'course-authoring.library-authoring.create-library-temp-placeholder',
defaultMessage: 'This is a placeholder for the create library form. This will be replaced with the actual form.',