From eeec683bff65b5e66775dbc3324f7a600ef35a01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 18 Apr 2025 11:03:51 -0300 Subject: [PATCH 1/8] feat: disallow edits to units in courses that are sourced from a library --- ...agsSidebarBody.jsx => TagsSidebarBody.tsx} | 30 +++++++++---- .../{index.jsx => index.tsx} | 8 +++- src/course-unit/CourseUnit.jsx | 22 +++++---- src/course-unit/CourseUnit.test.jsx | 45 +++++++++++++++++++ .../add-component/AddComponent.jsx | 4 -- src/course-unit/header-title/HeaderTitle.jsx | 7 ++- ...ublishControls.jsx => PublishControls.tsx} | 20 ++++----- .../{ActionButtons.jsx => ActionButtons.tsx} | 27 +++++++---- ...ibilityInfo.jsx => UnitVisibilityInfo.tsx} | 16 ++++--- .../sidebar-footer/{index.jsx => index.tsx} | 31 ++++++------- .../configure-modal/ConfigureModal.jsx | 9 +--- .../CourseAuthoringUnitSidebarSlot/README.md | 1 + .../CourseAuthoringUnitSidebarSlot/index.tsx | 6 ++- 13 files changed, 149 insertions(+), 77 deletions(-) rename src/content-tags-drawer/tags-sidebar-controls/{TagsSidebarBody.jsx => TagsSidebarBody.tsx} (79%) rename src/content-tags-drawer/tags-sidebar-controls/{index.jsx => index.tsx} (54%) rename src/course-unit/sidebar/{PublishControls.jsx => PublishControls.tsx} (91%) rename src/course-unit/sidebar/components/sidebar-footer/{ActionButtons.jsx => ActionButtons.tsx} (77%) rename src/course-unit/sidebar/components/sidebar-footer/{UnitVisibilityInfo.jsx => UnitVisibilityInfo.tsx} (89%) rename src/course-unit/sidebar/components/sidebar-footer/{index.jsx => index.tsx} (74%) diff --git a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.tsx similarity index 79% rename from src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx rename to src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.tsx index ca7659a17f..4089efb2d7 100644 --- a/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx +++ b/src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.tsx @@ -1,5 +1,4 @@ -// @ts-check -import React, { useState, useMemo } from 'react'; +import { useState, useMemo } from 'react'; import { Card, Stack, Button, Collapsible, Icon, } from '@openedx/paragon'; @@ -10,10 +9,19 @@ import { ContentTagsDrawerSheet } from '..'; import messages from '../messages'; import { useContentTaxonomyTagsData } from '../data/apiHooks'; +import type { ContentTaxonomyTagData, Tag } from '../data/types'; import { LoadingSpinner } from '../../generic/Loading'; import TagsTree from '../TagsTree'; -const TagsSidebarBody = () => { +interface TagsSidebarBodyProps { + readOnly: boolean +} + +type TagTree = { + [key: string]: { children: TagTree, canChangeObjecttag: boolean, canDeleteObjecttag: boolean } +}; + +const TagsSidebarBody = ({ readOnly }: TagsSidebarBodyProps) => { const intl = useIntl(); const [showManageTags, setShowManageTags] = useState(false); const contentId = useParams().blockId; @@ -24,8 +32,8 @@ const TagsSidebarBody = () => { isSuccess: isContentTaxonomyTagsLoaded, } = useContentTaxonomyTagsData(contentId || ''); - const buildTagsTree = (contentTags) => { - const resultTree = {}; + const buildTagsTree = (contentTags: Tag[]) => { + const resultTree: TagTree = {}; contentTags.forEach(item => { let currentLevel = resultTree; @@ -46,7 +54,7 @@ const TagsSidebarBody = () => { }; const tree = useMemo(() => { - const result = []; + const result: (Omit & { tags: TagTree })[] = []; if (isContentTaxonomyTagsLoaded && contentTaxonomyTagsData) { contentTaxonomyTagsData.taxonomies.forEach((taxonomy) => { result.push({ @@ -88,7 +96,13 @@ const TagsSidebarBody = () => { )} - @@ -102,6 +116,4 @@ const TagsSidebarBody = () => { ); }; -TagsSidebarBody.propTypes = {}; - export default TagsSidebarBody; diff --git a/src/content-tags-drawer/tags-sidebar-controls/index.jsx b/src/content-tags-drawer/tags-sidebar-controls/index.tsx similarity index 54% rename from src/content-tags-drawer/tags-sidebar-controls/index.jsx rename to src/content-tags-drawer/tags-sidebar-controls/index.tsx index 98ffc5e7c4..c2ecb48553 100644 --- a/src/content-tags-drawer/tags-sidebar-controls/index.jsx +++ b/src/content-tags-drawer/tags-sidebar-controls/index.tsx @@ -1,10 +1,14 @@ import TagsSidebarHeader from './TagsSidebarHeader'; import TagsSidebarBody from './TagsSidebarBody'; -const TagsSidebarControls = () => ( +interface TagsSidebarControlsProps { + readOnly: boolean, +} + +const TagsSidebarControls = ({ readOnly }: TagsSidebarControlsProps) => ( <> - + ); diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index bc97c48548..10ea54ca0c 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -40,6 +40,7 @@ const CourseUnit = ({ courseId }) => { const { blockId } = useParams(); const intl = useIntl(); const { + courseUnit, isLoading, sequenceId, unitTitle, @@ -75,6 +76,8 @@ const CourseUnit = ({ courseId }) => { } = useCourseUnit({ courseId, blockId }); const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType); + const readOnly = !!courseUnit.upstream; + useEffect(() => { document.title = getPageHeadTitle('', unitTitle); }, [unitTitle]); @@ -195,14 +198,16 @@ const CourseUnit = ({ courseId }) => { courseVerticalChildren={courseVerticalChildren.children} handleConfigureSubmit={handleConfigureSubmit} /> - - {showPasteXBlock && canPasteComponent && isUnitVerticalType && ( + {!readOnly && ( + + )} + {!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && ( { blockId={blockId} unitTitle={unitTitle} xBlocks={courseVerticalChildren.children} + readOnly={readOnly} /> )} {isSplitTestType && ( diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 2f128c0014..3d98ee1331 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -2195,4 +2195,49 @@ describe('', () => { .toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true }); }); }); + + it('renders units from libraries with some components read-only', async () => { + setConfig({ + ...getConfig(), + ENABLE_TAGGING_TAXONOMY_PAGES: 'true', + }); + render(); + + axiosMock + .onGet(getCourseUnitApiUrl(courseId)) + .reply(200, { + ...courseUnitIndexMock, + upstream: 'lct:org:lib:unit:unit-1', + }); + await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); + + // Disable the "Edit" button + const unitHeaderTitle = screen.getByTestId('unit-header-title'); + const editButton = within(unitHeaderTitle).getByRole( + 'button', + { name: 'Edit' }, + ); + expect(editButton).toBeInTheDocument(); + expect(editButton).toBeDisabled(); + + // Disable the "Publish" button + const courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); + const publishButton = within(courseUnitSidebar).getByRole( + 'button', + { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }, + ); + expect(publishButton).toBeInTheDocument(); + expect(publishButton).toBeDisabled(); + + // 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(); + }); }); diff --git a/src/course-unit/add-component/AddComponent.jsx b/src/course-unit/add-component/AddComponent.jsx index be5ec6ec05..3c44f743fb 100644 --- a/src/course-unit/add-component/AddComponent.jsx +++ b/src/course-unit/add-component/AddComponent.jsx @@ -208,10 +208,6 @@ const AddComponent = ({ return null; }; -AddComponent.defaultProps = { - addComponentTemplateData: {}, -}; - AddComponent.propTypes = { isSplitTestType: PropTypes.bool.isRequired, isUnitVerticalType: PropTypes.bool.isRequired, diff --git a/src/course-unit/header-title/HeaderTitle.jsx b/src/course-unit/header-title/HeaderTitle.jsx index f7955b8636..1dc0b563ca 100644 --- a/src/course-unit/header-title/HeaderTitle.jsx +++ b/src/course-unit/header-title/HeaderTitle.jsx @@ -34,6 +34,8 @@ const HeaderTitle = ({ COURSE_BLOCK_NAMES.component.id, ].includes(currentItemData.category); + const isReadOnly = !!currentItemData.upstream; + 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={isReadOnly} /> { +interface PublishControlsProps { + blockId?: string, +} + +const PublishControls = ({ blockId }: PublishControlsProps) => { + const unitData = useSelector(getCourseUnitData); const { title, locationId, releaseLabel, visibilityState, visibleToStaffOnly, - } = useCourseUnitData(useSelector(getCourseUnitData)); + } = useCourseUnitData(unitData); const intl = useIntl(); const { sendMessageToIframe } = useIframe(); + const hasUpstream = !!unitData?.upstream; const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false); const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false); @@ -65,6 +70,7 @@ const PublishControls = ({ blockId }) => { openVisibleModal={openVisibleModal} handlePublishing={handleCourseUnitPublish} visibleToStaffOnly={visibleToStaffOnly} + hasUpstream={hasUpstream} /> { ); }; -PublishControls.propTypes = { - blockId: PropTypes.string, -}; - -PublishControls.defaultProps = { - blockId: null, -}; - export default PublishControls; diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx similarity index 77% rename from src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx rename to src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx index 645651a271..3833a89b4b 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import { useSelector } from 'react-redux'; import { Button } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -8,7 +7,17 @@ import { getCanEdit, getCourseUnitData } from '../../../data/selectors'; import { useClipboard } from '../../../../generic/clipboard'; import messages from '../../messages'; -const ActionButtons = ({ openDiscardModal, handlePublishing }) => { +interface ActionButtonsProps { + openDiscardModal: () => void, + handlePublishing: () => void, + readOnly: boolean, +} + +const ActionButtons = ({ + openDiscardModal, + handlePublishing, + readOnly = false, +}: ActionButtonsProps) => { const intl = useIntl(); const { id, @@ -22,7 +31,13 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => { return ( <> {(!published || hasChanges) && ( - )} @@ -32,6 +47,7 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => { variant="link" onClick={openDiscardModal} className="course-unit-sidebar-footer__discard-changes__btn mt-2" + disabled={readOnly} > {intl.formatMessage(messages.actionButtonDiscardChangesTitle)} @@ -52,9 +68,4 @@ const ActionButtons = ({ openDiscardModal, handlePublishing }) => { ); }; -ActionButtons.propTypes = { - openDiscardModal: PropTypes.func.isRequired, - handlePublishing: PropTypes.func.isRequired, -}; - export default ActionButtons; diff --git a/src/course-unit/sidebar/components/sidebar-footer/UnitVisibilityInfo.jsx b/src/course-unit/sidebar/components/sidebar-footer/UnitVisibilityInfo.tsx similarity index 89% rename from src/course-unit/sidebar/components/sidebar-footer/UnitVisibilityInfo.jsx rename to src/course-unit/sidebar/components/sidebar-footer/UnitVisibilityInfo.tsx index b4dc35b568..e10edcdfe6 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/UnitVisibilityInfo.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/UnitVisibilityInfo.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import { useDispatch, useSelector } from 'react-redux'; import { Form } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -10,7 +9,15 @@ import { PUBLISH_TYPES } from '../../../constants'; import { getVisibilityTitle } from '../../utils'; import messages from '../../messages'; -const UnitVisibilityInfo = ({ openVisibleModal, visibleToStaffOnly }) => { +interface UnitVisibilityInfoProps { + openVisibleModal: () => void, + visibleToStaffOnly: boolean, +} + +const UnitVisibilityInfo = ({ + openVisibleModal, + visibleToStaffOnly, +}: UnitVisibilityInfoProps) => { const intl = useIntl(); const { blockId } = useParams(); const dispatch = useDispatch(); @@ -59,9 +66,4 @@ const UnitVisibilityInfo = ({ openVisibleModal, visibleToStaffOnly }) => { ); }; -UnitVisibilityInfo.propTypes = { - openVisibleModal: PropTypes.func.isRequired, - visibleToStaffOnly: PropTypes.bool.isRequired, -}; - export default UnitVisibilityInfo; diff --git a/src/course-unit/sidebar/components/sidebar-footer/index.jsx b/src/course-unit/sidebar/components/sidebar-footer/index.tsx similarity index 74% rename from src/course-unit/sidebar/components/sidebar-footer/index.jsx rename to src/course-unit/sidebar/components/sidebar-footer/index.tsx index 62af6c672b..7e7f933022 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/index.jsx +++ b/src/course-unit/sidebar/components/sidebar-footer/index.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import { Card, Stack } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; @@ -6,14 +5,25 @@ import messages from '../../messages'; import UnitVisibilityInfo from './UnitVisibilityInfo'; import ActionButtons from './ActionButtons'; +interface SidebarFooterProps { + locationId?: string, + displayUnitLocation?: boolean, + openDiscardModal: () => void, + openVisibleModal: () => void, + handlePublishing: () => void, + visibleToStaffOnly: boolean, + hasUpstream?: boolean, +} + const SidebarFooter = ({ locationId, openVisibleModal, handlePublishing, openDiscardModal, visibleToStaffOnly, - displayUnitLocation, -}) => { + displayUnitLocation = false, + hasUpstream = false, +}: SidebarFooterProps) => { const intl = useIntl(); return ( @@ -32,6 +42,7 @@ const SidebarFooter = ({ )} @@ -40,18 +51,4 @@ const SidebarFooter = ({ ); }; -SidebarFooter.propTypes = { - locationId: PropTypes.string, - displayUnitLocation: PropTypes.bool, - openDiscardModal: PropTypes.func, - openVisibleModal: PropTypes.func, - handlePublishing: PropTypes.func, - visibleToStaffOnly: PropTypes.bool.isRequired, -}; - -SidebarFooter.defaultProps = { - displayUnitLocation: false, - locationId: null, -}; - export default SidebarFooter; diff --git a/src/generic/configure-modal/ConfigureModal.jsx b/src/generic/configure-modal/ConfigureModal.jsx index 76b2bd603a..915ba5d19b 100644 --- a/src/generic/configure-modal/ConfigureModal.jsx +++ b/src/generic/configure-modal/ConfigureModal.jsx @@ -26,8 +26,8 @@ const ConfigureModal = ({ onClose, onConfigureSubmit, currentItemData, - enableProctoredExams, - isXBlockComponent, + enableProctoredExams = false, + isXBlockComponent = false, isSelfPaced, }) => { const intl = useIntl(); @@ -320,11 +320,6 @@ const ConfigureModal = ({ ); }; -ConfigureModal.defaultProps = { - isXBlockComponent: false, - enableProctoredExams: false, -}; - ConfigureModal.propTypes = { isOpen: PropTypes.bool.isRequired, onClose: PropTypes.func.isRequired, diff --git a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.md b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.md index b8fe123066..ee00e7ba16 100644 --- a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.md +++ b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/README.md @@ -11,6 +11,7 @@ * `blockId` - String. The usage id of the current unit being viewed / edited. * `unitTitle` - String. The name of the current unit being viewed / edited. * `xBlocks` - Array of Objects. List of XBlocks in the Unit. Object structure defined in `index.tsx`. +* `readOnly` - Boolean. True if the user should not be able to edit the contents of the unit. ## Description diff --git a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/index.tsx b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/index.tsx index af8f97d670..0bf6de14ca 100644 --- a/src/plugin-slots/CourseAuthoringUnitSidebarSlot/index.tsx +++ b/src/plugin-slots/CourseAuthoringUnitSidebarSlot/index.tsx @@ -11,13 +11,14 @@ export const CourseAuthoringUnitSidebarSlot = ( courseId, unitTitle, xBlocks, + readOnly, }: CourseAuthoringUnitSidebarSlotProps, ) => ( @@ -25,7 +26,7 @@ export const CourseAuthoringUnitSidebarSlot = ( {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( - + )} @@ -45,4 +46,5 @@ interface CourseAuthoringUnitSidebarSlotProps { courseId: string; unitTitle: string; xBlocks: XBlock[]; + readOnly: boolean; } From 11d457bd109d90d1ac13b2046dfb70460d8dcb81 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 24 Apr 2025 14:38:57 +0930 Subject: [PATCH 2/8] feat: adds isUnitReadOnly() and uses in CourseUnit --- src/course-unit/CourseUnit.jsx | 2 +- src/course-unit/data/thunk.js | 4 +++- src/course-unit/data/utils.js | 12 ++++++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/course-unit/CourseUnit.jsx b/src/course-unit/CourseUnit.jsx index 10ea54ca0c..5aae700e5b 100644 --- a/src/course-unit/CourseUnit.jsx +++ b/src/course-unit/CourseUnit.jsx @@ -76,7 +76,7 @@ const CourseUnit = ({ courseId }) => { } = useCourseUnit({ courseId, blockId }); const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType); - const readOnly = !!courseUnit.upstream; + const readOnly = !!courseUnit.readOnly; useEffect(() => { document.title = getPageHeadTitle('', unitTitle); diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index bdf2e44024..a0c1dc54ec 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -38,7 +38,7 @@ import { updateCourseOutlineInfoLoadingStatus, updateMovedXBlockParams, } from './slice'; -import { getNotificationMessage } from './utils'; +import { getNotificationMessage, isUnitReadOnly } from './utils'; export function fetchCourseUnitQuery(courseId) { return async (dispatch) => { @@ -46,6 +46,8 @@ export function fetchCourseUnitQuery(courseId) { try { const courseUnit = await getCourseUnitData(courseId); + courseUnit.readOnly = isUnitReadOnly(courseUnit); + dispatch(fetchCourseItemSuccess(courseUnit)); dispatch(updateLoadingCourseUnitStatus({ status: RequestStatus.SUCCESSFUL })); return true; diff --git a/src/course-unit/data/utils.js b/src/course-unit/data/utils.js index 0b28805297..8ecd64c208 100644 --- a/src/course-unit/data/utils.js +++ b/src/course-unit/data/utils.js @@ -84,3 +84,15 @@ export const updateXBlockBlockIdToId = (data) => { return updatedData; }; + +/** + * Returns whether the given Unit should be read-only. + * + * Units sourced from libraries are read-only (temporary, for Teak). + * + * @param {object} unit - The unit data, should contain the 'upstream' key if set. + * @returns {boolean} True if readOnly, False if editable. + */ +export const isUnitReadOnly = ({ upstream }) => ( + upstream && upstream.startsWith('lct:') +); From d707402ece4003f74e276521f524123275f1f597 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 24 Apr 2025 13:11:05 +0930 Subject: [PATCH 3/8] feat: Disable editing readonly units shown in course outline --- src/course-outline/card-header/CardHeader.jsx | 3 ++ .../card-header/CardHeader.test.jsx | 29 +++++++++++++++++++ src/course-outline/unit-card/UnitCard.jsx | 6 +++- 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx index 0012b78b80..41a023bb1b 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.jsx @@ -136,6 +136,7 @@ const CardHeader = ({ alt={intl.formatMessage(messages.altButtonEdit)} iconAs={EditIcon} onClick={onClickEdit} + disabled={isDisabledEditField} /> )} @@ -178,6 +179,7 @@ const CardHeader = ({ {intl.formatMessage(messages.menuConfigure)} @@ -185,6 +187,7 @@ const CardHeader = ({ {getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && ( {intl.formatMessage(messages.menuManageTags)} diff --git a/src/course-outline/card-header/CardHeader.test.jsx b/src/course-outline/card-header/CardHeader.test.jsx index d589627173..e694db4882 100644 --- a/src/course-outline/card-header/CardHeader.test.jsx +++ b/src/course-outline/card-header/CardHeader.test.jsx @@ -240,6 +240,35 @@ describe('', () => { expect(await findByTestId('subsection-edit-field')).toBeDisabled(); }); + it('check editing is enabled when isDisabledEditField is false', async () => { + const { getByTestId } = renderComponent({ + ...cardHeaderProps, + }); + + expect(getByTestId('subsection-edit-button')).toBeEnabled(); + + // Ensure menu items related to editing are enabled + const menuButton = getByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menuButton)); + expect(await getByTestId('subsection-card-header__menu-configure-button')).not.toHaveAttribute('aria-disabled'); + expect(await getByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled'); + }); + + it('check editing is disabled when isDisabledEditField is true', async () => { + const { getByTestId } = renderComponent({ + ...cardHeaderProps, + isDisabledEditField: true, + }); + + expect(await getByTestId('subsection-edit-button')).toBeDisabled(); + + // Ensure menu items related to editing are disabled + const menuButton = getByTestId('subsection-card-header__menu-button'); + await act(async () => fireEvent.click(menuButton)); + expect(await getByTestId('subsection-card-header__menu-configure-button')).toHaveAttribute('aria-disabled', 'true'); + expect(await getByTestId('subsection-card-header__menu-manage-tags-button')).toHaveAttribute('aria-disabled', 'true'); + }); + it('calls onClickDelete when item is clicked', async () => { const { findByText, findByTestId } = renderComponent(); diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.jsx index 00ac4cf50d..5946133413 100644 --- a/src/course-outline/unit-card/UnitCard.jsx +++ b/src/course-outline/unit-card/UnitCard.jsx @@ -9,6 +9,7 @@ import { useSearchParams } from 'react-router-dom'; import CourseOutlineUnitCardExtraActionsSlot from '../../plugin-slots/CourseOutlineUnitCardExtraActionsSlot'; import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; import { RequestStatus } from '../../data/constants'; +import { isUnitReadOnly } from '../../course-unit/data/utils'; import CardHeader from '../card-header/CardHeader'; import SortableItem from '../drag-helper/SortableItem'; import TitleLink from '../card-header/TitleLink'; @@ -57,6 +58,8 @@ const UnitCard = ({ discussionEnabled, } = unit; + const readOnly = isUnitReadOnly(unit); + // re-create actions object for customizations const actions = { ...unitActions }; // add actions to control display of move up & down menu buton. @@ -175,7 +178,7 @@ const UnitCard = ({ isFormOpen={isFormOpen} closeForm={closeForm} onEditSubmit={handleEditSubmit} - isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS} + isDisabledEditField={readOnly || savingStatus === RequestStatus.IN_PROGRESS} onClickDuplicate={onDuplicateSubmit} titleComponent={titleComponent} namePrefix={namePrefix} @@ -222,6 +225,7 @@ UnitCard.propTypes = { isHeaderVisible: PropTypes.bool, enableCopyPasteUnits: PropTypes.bool, discussionEnabled: PropTypes.bool, + upstream: PropTypes.string, }).isRequired, subsection: PropTypes.shape({ id: PropTypes.string.isRequired, From daa282164c52a7f8b74526457489d56fe826a221 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 24 Apr 2025 13:36:11 +0930 Subject: [PATCH 4/8] feat: disable Unit Settings if isUnitReadOnly --- src/course-unit/header-title/HeaderTitle.jsx | 5 +++-- .../header-title/HeaderTitle.test.jsx | 21 +++++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/course-unit/header-title/HeaderTitle.jsx b/src/course-unit/header-title/HeaderTitle.jsx index 1dc0b563ca..1c318c207a 100644 --- a/src/course-unit/header-title/HeaderTitle.jsx +++ b/src/course-unit/header-title/HeaderTitle.jsx @@ -34,7 +34,7 @@ const HeaderTitle = ({ COURSE_BLOCK_NAMES.component.id, ].includes(currentItemData.category); - const isReadOnly = !!currentItemData.upstream; + const readOnly = !!currentItemData.readOnly; const onConfigureSubmit = (...arg) => { handleConfigureSubmit(currentItemData.id, ...arg, closeConfigureModal); @@ -82,13 +82,14 @@ const HeaderTitle = ({ className="ml-1 flex-shrink-0" iconAs={EditIcon} onClick={handleTitleEdit} - disabled={isReadOnly} + disabled={readOnly} /> ', () => { expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toBeInTheDocument(); expect(getByRole('textbox', { name: messages.ariaLabelButtonEdit.defaultMessage })).toHaveValue(unitTitle); - expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeInTheDocument(); + expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeEnabled(); + expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeEnabled(); + }); + + it('Units sourced from upstream show a disabled edit form and config menu', async () => { + // Override mock unit with one sourced from an upstream library + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + axiosMock + .onGet(getCourseUnitApiUrl(blockId)) + .reply(200, { + ...courseUnitIndexMock, + upstream: 'lct:org:lib:unit:unit-1', + }); + await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch); + + const { getByRole } = renderComponent(); + + expect(getByRole('button', { name: messages.altButtonEdit.defaultMessage })).toBeDisabled(); + expect(getByRole('button', { name: messages.altButtonSettings.defaultMessage })).toBeDisabled(); }); it('calls toggle edit title form by clicking on Edit button', () => { From b5139e001fa6e42ae05167d98a5d7029247ccbce Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 24 Apr 2025 13:46:32 +0930 Subject: [PATCH 5/8] revert: Units sourced from libraries can be published --- src/course-unit/sidebar/PublishControls.tsx | 2 -- .../sidebar/components/sidebar-footer/ActionButtons.tsx | 4 ---- src/course-unit/sidebar/components/sidebar-footer/index.tsx | 3 --- 3 files changed, 9 deletions(-) diff --git a/src/course-unit/sidebar/PublishControls.tsx b/src/course-unit/sidebar/PublishControls.tsx index 3da85a2fe2..2b6372548c 100644 --- a/src/course-unit/sidebar/PublishControls.tsx +++ b/src/course-unit/sidebar/PublishControls.tsx @@ -26,7 +26,6 @@ const PublishControls = ({ blockId }: PublishControlsProps) => { } = useCourseUnitData(unitData); const intl = useIntl(); const { sendMessageToIframe } = useIframe(); - const hasUpstream = !!unitData?.upstream; const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false); const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false); @@ -70,7 +69,6 @@ const PublishControls = ({ blockId }: PublishControlsProps) => { openVisibleModal={openVisibleModal} handlePublishing={handleCourseUnitPublish} visibleToStaffOnly={visibleToStaffOnly} - hasUpstream={hasUpstream} /> void, handlePublishing: () => void, - readOnly: boolean, } const ActionButtons = ({ openDiscardModal, handlePublishing, - readOnly = false, }: ActionButtonsProps) => { const intl = useIntl(); const { @@ -36,7 +34,6 @@ const ActionButtons = ({ className="mt-3.5" variant="outline-primary" onClick={handlePublishing} - disabled={readOnly} > {intl.formatMessage(messages.actionButtonPublishTitle)} @@ -47,7 +44,6 @@ const ActionButtons = ({ variant="link" onClick={openDiscardModal} className="course-unit-sidebar-footer__discard-changes__btn mt-2" - disabled={readOnly} > {intl.formatMessage(messages.actionButtonDiscardChangesTitle)} diff --git a/src/course-unit/sidebar/components/sidebar-footer/index.tsx b/src/course-unit/sidebar/components/sidebar-footer/index.tsx index 7e7f933022..f05591bb07 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/index.tsx +++ b/src/course-unit/sidebar/components/sidebar-footer/index.tsx @@ -12,7 +12,6 @@ interface SidebarFooterProps { openVisibleModal: () => void, handlePublishing: () => void, visibleToStaffOnly: boolean, - hasUpstream?: boolean, } const SidebarFooter = ({ @@ -22,7 +21,6 @@ const SidebarFooter = ({ openDiscardModal, visibleToStaffOnly, displayUnitLocation = false, - hasUpstream = false, }: SidebarFooterProps) => { const intl = useIntl(); @@ -42,7 +40,6 @@ const SidebarFooter = ({ )} From 1a7454dc0494cc4b99558f4ac89df2de14d3c691 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 24 Apr 2025 17:23:55 +0930 Subject: [PATCH 6/8] fix: ignore type lint --- src/course-outline/card-header/CardHeader.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx index 41a023bb1b..eda1ff6be4 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.jsx @@ -136,6 +136,7 @@ const CardHeader = ({ alt={intl.formatMessage(messages.altButtonEdit)} iconAs={EditIcon} onClick={onClickEdit} + // @ts-ignore disabled={isDisabledEditField} /> From ca1817668bfea75a13f46dcbfea284d76c7f85af Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 24 Apr 2025 17:37:23 +0930 Subject: [PATCH 7/8] test: Publish button should be enabled for library-sourced units Fixes test broken by 76979e7377f0a87aa7f0df83b5ab0cb7bda5e259. --- src/course-unit/CourseUnit.test.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 3d98ee1331..4b193c1dc8 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -2220,14 +2220,14 @@ describe('', () => { expect(editButton).toBeInTheDocument(); expect(editButton).toBeDisabled(); - // Disable the "Publish" button + // The "Publish" button should still be enabled const courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); const publishButton = within(courseUnitSidebar).getByRole( 'button', { name: sidebarMessages.actionButtonPublishTitle.defaultMessage }, ); expect(publishButton).toBeInTheDocument(); - expect(publishButton).toBeDisabled(); + expect(publishButton).toBeEnabled(); // Disable the "Manage Tags" button const manageTagsButton = screen.getByRole( From 1ff27c5f0e54d257ac1277f42e44fa62e3f9a046 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 24 Apr 2025 20:55:51 +0930 Subject: [PATCH 8/8] fix: use upstreamInfo.upstreamRef instead of upstream --- src/course-outline/unit-card/UnitCard.jsx | 1 - src/course-unit/CourseUnit.test.jsx | 4 +++- src/course-unit/data/utils.js | 6 +++--- src/course-unit/header-title/HeaderTitle.test.jsx | 4 +++- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.jsx index 5946133413..bd0b58e559 100644 --- a/src/course-outline/unit-card/UnitCard.jsx +++ b/src/course-outline/unit-card/UnitCard.jsx @@ -225,7 +225,6 @@ UnitCard.propTypes = { isHeaderVisible: PropTypes.bool, enableCopyPasteUnits: PropTypes.bool, discussionEnabled: PropTypes.bool, - upstream: PropTypes.string, }).isRequired, subsection: PropTypes.shape({ id: PropTypes.string.isRequired, diff --git a/src/course-unit/CourseUnit.test.jsx b/src/course-unit/CourseUnit.test.jsx index 4b193c1dc8..bd204ddf28 100644 --- a/src/course-unit/CourseUnit.test.jsx +++ b/src/course-unit/CourseUnit.test.jsx @@ -2207,7 +2207,9 @@ describe('', () => { .onGet(getCourseUnitApiUrl(courseId)) .reply(200, { ...courseUnitIndexMock, - upstream: 'lct:org:lib:unit:unit-1', + upstreamInfo: { + upstreamRef: 'lct:org:lib:unit:unit-1', + }, }); await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch); diff --git a/src/course-unit/data/utils.js b/src/course-unit/data/utils.js index 8ecd64c208..891021debd 100644 --- a/src/course-unit/data/utils.js +++ b/src/course-unit/data/utils.js @@ -90,9 +90,9 @@ export const updateXBlockBlockIdToId = (data) => { * * Units sourced from libraries are read-only (temporary, for Teak). * - * @param {object} unit - The unit data, should contain the 'upstream' key if set. + * @param {object} unit - uses the 'upstreamInfo' object if found. * @returns {boolean} True if readOnly, False if editable. */ -export const isUnitReadOnly = ({ upstream }) => ( - upstream && upstream.startsWith('lct:') +export const isUnitReadOnly = ({ upstreamInfo }) => ( + upstreamInfo && upstreamInfo.upstreamRef && upstreamInfo.upstreamRef.startsWith('lct:') ); diff --git a/src/course-unit/header-title/HeaderTitle.test.jsx b/src/course-unit/header-title/HeaderTitle.test.jsx index 41ec683cba..881c24e8ec 100644 --- a/src/course-unit/header-title/HeaderTitle.test.jsx +++ b/src/course-unit/header-title/HeaderTitle.test.jsx @@ -83,7 +83,9 @@ describe('', () => { .onGet(getCourseUnitApiUrl(blockId)) .reply(200, { ...courseUnitIndexMock, - upstream: 'lct:org:lib:unit:unit-1', + upstreamInfo: { + upstreamRef: 'lct:org:lib:unit:unit-1', + }, }); await executeThunk(fetchCourseUnitQuery(blockId), store.dispatch);