Skip to content

Commit 9824502

Browse files
authored
feat: disallow edits to units in courses that are sourced from a library (#1833)
1 parent d6b51ec commit 9824502

File tree

19 files changed

+216
-81
lines changed

19 files changed

+216
-81
lines changed

src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.jsx renamed to src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.tsx

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
// @ts-check
2-
import React, { useState, useMemo } from 'react';
1+
import { useState, useMemo } from 'react';
32
import {
43
Card, Stack, Button, Collapsible, Icon,
54
} from '@openedx/paragon';
@@ -10,10 +9,19 @@ import { ContentTagsDrawerSheet } from '..';
109

1110
import messages from '../messages';
1211
import { useContentTaxonomyTagsData } from '../data/apiHooks';
12+
import type { ContentTaxonomyTagData, Tag } from '../data/types';
1313
import { LoadingSpinner } from '../../generic/Loading';
1414
import TagsTree from '../TagsTree';
1515

16-
const TagsSidebarBody = () => {
16+
interface TagsSidebarBodyProps {
17+
readOnly: boolean
18+
}
19+
20+
type TagTree = {
21+
[key: string]: { children: TagTree, canChangeObjecttag: boolean, canDeleteObjecttag: boolean }
22+
};
23+
24+
const TagsSidebarBody = ({ readOnly }: TagsSidebarBodyProps) => {
1725
const intl = useIntl();
1826
const [showManageTags, setShowManageTags] = useState(false);
1927
const contentId = useParams().blockId;
@@ -24,8 +32,8 @@ const TagsSidebarBody = () => {
2432
isSuccess: isContentTaxonomyTagsLoaded,
2533
} = useContentTaxonomyTagsData(contentId || '');
2634

27-
const buildTagsTree = (contentTags) => {
28-
const resultTree = {};
35+
const buildTagsTree = (contentTags: Tag[]) => {
36+
const resultTree: TagTree = {};
2937
contentTags.forEach(item => {
3038
let currentLevel = resultTree;
3139

@@ -46,7 +54,7 @@ const TagsSidebarBody = () => {
4654
};
4755

4856
const tree = useMemo(() => {
49-
const result = [];
57+
const result: (Omit<ContentTaxonomyTagData, 'tags'> & { tags: TagTree })[] = [];
5058
if (isContentTaxonomyTagsLoaded && contentTaxonomyTagsData) {
5159
contentTaxonomyTagsData.taxonomies.forEach((taxonomy) => {
5260
result.push({
@@ -88,7 +96,13 @@ const TagsSidebarBody = () => {
8896
</div>
8997
)}
9098

91-
<Button className="mt-3 ml-2" variant="outline-primary" size="sm" onClick={() => setShowManageTags(true)}>
99+
<Button
100+
className="mt-3 ml-2"
101+
variant="outline-primary"
102+
size="sm"
103+
onClick={() => setShowManageTags(true)}
104+
disabled={readOnly}
105+
>
92106
{intl.formatMessage(messages.manageTagsButton)}
93107
</Button>
94108
</Stack>
@@ -102,6 +116,4 @@ const TagsSidebarBody = () => {
102116
);
103117
};
104118

105-
TagsSidebarBody.propTypes = {};
106-
107119
export default TagsSidebarBody;

src/content-tags-drawer/tags-sidebar-controls/index.jsx renamed to src/content-tags-drawer/tags-sidebar-controls/index.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import TagsSidebarHeader from './TagsSidebarHeader';
22
import TagsSidebarBody from './TagsSidebarBody';
33

4-
const TagsSidebarControls = () => (
4+
interface TagsSidebarControlsProps {
5+
readOnly: boolean,
6+
}
7+
8+
const TagsSidebarControls = ({ readOnly }: TagsSidebarControlsProps) => (
59
<>
610
<TagsSidebarHeader />
7-
<TagsSidebarBody />
11+
<TagsSidebarBody readOnly={readOnly} />
812
</>
913
);
1014

src/course-outline/card-header/CardHeader.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ const CardHeader = ({
136136
alt={intl.formatMessage(messages.altButtonEdit)}
137137
iconAs={EditIcon}
138138
onClick={onClickEdit}
139+
// @ts-ignore
140+
disabled={isDisabledEditField}
139141
/>
140142
</>
141143
)}
@@ -178,13 +180,15 @@ const CardHeader = ({
178180
</Dropdown.Item>
179181
<Dropdown.Item
180182
data-testid={`${namePrefix}-card-header__menu-configure-button`}
183+
disabled={isDisabledEditField}
181184
onClick={onClickConfigure}
182185
>
183186
{intl.formatMessage(messages.menuConfigure)}
184187
</Dropdown.Item>
185188
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
186189
<Dropdown.Item
187190
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
191+
disabled={isDisabledEditField}
188192
onClick={openManageTagsDrawer}
189193
>
190194
{intl.formatMessage(messages.menuManageTags)}

src/course-outline/card-header/CardHeader.test.jsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,35 @@ describe('<CardHeader />', () => {
240240
expect(await findByTestId('subsection-edit-field')).toBeDisabled();
241241
});
242242

243+
it('check editing is enabled when isDisabledEditField is false', async () => {
244+
const { getByTestId } = renderComponent({
245+
...cardHeaderProps,
246+
});
247+
248+
expect(getByTestId('subsection-edit-button')).toBeEnabled();
249+
250+
// Ensure menu items related to editing are enabled
251+
const menuButton = getByTestId('subsection-card-header__menu-button');
252+
await act(async () => fireEvent.click(menuButton));
253+
expect(await getByTestId('subsection-card-header__menu-configure-button')).not.toHaveAttribute('aria-disabled');
254+
expect(await getByTestId('subsection-card-header__menu-manage-tags-button')).not.toHaveAttribute('aria-disabled');
255+
});
256+
257+
it('check editing is disabled when isDisabledEditField is true', async () => {
258+
const { getByTestId } = renderComponent({
259+
...cardHeaderProps,
260+
isDisabledEditField: true,
261+
});
262+
263+
expect(await getByTestId('subsection-edit-button')).toBeDisabled();
264+
265+
// Ensure menu items related to editing are disabled
266+
const menuButton = getByTestId('subsection-card-header__menu-button');
267+
await act(async () => fireEvent.click(menuButton));
268+
expect(await getByTestId('subsection-card-header__menu-configure-button')).toHaveAttribute('aria-disabled', 'true');
269+
expect(await getByTestId('subsection-card-header__menu-manage-tags-button')).toHaveAttribute('aria-disabled', 'true');
270+
});
271+
243272
it('calls onClickDelete when item is clicked', async () => {
244273
const { findByText, findByTestId } = renderComponent();
245274

src/course-outline/unit-card/UnitCard.jsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useSearchParams } from 'react-router-dom';
99
import CourseOutlineUnitCardExtraActionsSlot from '../../plugin-slots/CourseOutlineUnitCardExtraActionsSlot';
1010
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
1111
import { RequestStatus } from '../../data/constants';
12+
import { isUnitReadOnly } from '../../course-unit/data/utils';
1213
import CardHeader from '../card-header/CardHeader';
1314
import SortableItem from '../drag-helper/SortableItem';
1415
import TitleLink from '../card-header/TitleLink';
@@ -57,6 +58,8 @@ const UnitCard = ({
5758
discussionEnabled,
5859
} = unit;
5960

61+
const readOnly = isUnitReadOnly(unit);
62+
6063
// re-create actions object for customizations
6164
const actions = { ...unitActions };
6265
// add actions to control display of move up & down menu buton.
@@ -175,7 +178,7 @@ const UnitCard = ({
175178
isFormOpen={isFormOpen}
176179
closeForm={closeForm}
177180
onEditSubmit={handleEditSubmit}
178-
isDisabledEditField={savingStatus === RequestStatus.IN_PROGRESS}
181+
isDisabledEditField={readOnly || savingStatus === RequestStatus.IN_PROGRESS}
179182
onClickDuplicate={onDuplicateSubmit}
180183
titleComponent={titleComponent}
181184
namePrefix={namePrefix}

src/course-unit/CourseUnit.jsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ const CourseUnit = ({ courseId }) => {
4040
const { blockId } = useParams();
4141
const intl = useIntl();
4242
const {
43+
courseUnit,
4344
isLoading,
4445
sequenceId,
4546
unitTitle,
@@ -75,6 +76,8 @@ const CourseUnit = ({ courseId }) => {
7576
} = useCourseUnit({ courseId, blockId });
7677
const layoutGrid = useLayoutGrid(unitCategory, isUnitLibraryType);
7778

79+
const readOnly = !!courseUnit.readOnly;
80+
7881
useEffect(() => {
7982
document.title = getPageHeadTitle('', unitTitle);
8083
}, [unitTitle]);
@@ -195,14 +198,16 @@ const CourseUnit = ({ courseId }) => {
195198
courseVerticalChildren={courseVerticalChildren.children}
196199
handleConfigureSubmit={handleConfigureSubmit}
197200
/>
198-
<AddComponent
199-
parentLocator={blockId}
200-
isSplitTestType={isSplitTestType}
201-
isUnitVerticalType={isUnitVerticalType}
202-
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
203-
addComponentTemplateData={addComponentTemplateData}
204-
/>
205-
{showPasteXBlock && canPasteComponent && isUnitVerticalType && (
201+
{!readOnly && (
202+
<AddComponent
203+
parentLocator={blockId}
204+
isSplitTestType={isSplitTestType}
205+
isUnitVerticalType={isUnitVerticalType}
206+
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
207+
addComponentTemplateData={addComponentTemplateData}
208+
/>
209+
)}
210+
{!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && (
206211
<PasteComponent
207212
clipboardData={sharedClipboardData}
208213
onClick={
@@ -227,6 +232,7 @@ const CourseUnit = ({ courseId }) => {
227232
blockId={blockId}
228233
unitTitle={unitTitle}
229234
xBlocks={courseVerticalChildren.children}
235+
readOnly={readOnly}
230236
/>
231237
)}
232238
{isSplitTestType && (

src/course-unit/CourseUnit.test.jsx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2195,4 +2195,51 @@ describe('<CourseUnit />', () => {
21952195
.toHaveBeenCalledWith(`/course/${courseId}/editor/html/${targetBlockId}`, { replace: true });
21962196
});
21972197
});
2198+
2199+
it('renders units from libraries with some components read-only', async () => {
2200+
setConfig({
2201+
...getConfig(),
2202+
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
2203+
});
2204+
render(<RootWrapper />);
2205+
2206+
axiosMock
2207+
.onGet(getCourseUnitApiUrl(courseId))
2208+
.reply(200, {
2209+
...courseUnitIndexMock,
2210+
upstreamInfo: {
2211+
upstreamRef: 'lct:org:lib:unit:unit-1',
2212+
},
2213+
});
2214+
await executeThunk(fetchCourseUnitQuery(courseId), store.dispatch);
2215+
2216+
// Disable the "Edit" button
2217+
const unitHeaderTitle = screen.getByTestId('unit-header-title');
2218+
const editButton = within(unitHeaderTitle).getByRole(
2219+
'button',
2220+
{ name: 'Edit' },
2221+
);
2222+
expect(editButton).toBeInTheDocument();
2223+
expect(editButton).toBeDisabled();
2224+
2225+
// The "Publish" button should still be enabled
2226+
const courseUnitSidebar = screen.getByTestId('course-unit-sidebar');
2227+
const publishButton = within(courseUnitSidebar).getByRole(
2228+
'button',
2229+
{ name: sidebarMessages.actionButtonPublishTitle.defaultMessage },
2230+
);
2231+
expect(publishButton).toBeInTheDocument();
2232+
expect(publishButton).toBeEnabled();
2233+
2234+
// Disable the "Manage Tags" button
2235+
const manageTagsButton = screen.getByRole(
2236+
'button',
2237+
{ name: tagsDrawerMessages.manageTagsButton.defaultMessage },
2238+
);
2239+
expect(manageTagsButton).toBeInTheDocument();
2240+
expect(manageTagsButton).toBeDisabled();
2241+
2242+
// Does not render the "Add Components" section
2243+
expect(screen.queryByText(addComponentMessages.title.defaultMessage)).not.toBeInTheDocument();
2244+
});
21982245
});

src/course-unit/add-component/AddComponent.jsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -208,10 +208,6 @@ const AddComponent = ({
208208
return null;
209209
};
210210

211-
AddComponent.defaultProps = {
212-
addComponentTemplateData: {},
213-
};
214-
215211
AddComponent.propTypes = {
216212
isSplitTestType: PropTypes.bool.isRequired,
217213
isUnitVerticalType: PropTypes.bool.isRequired,

src/course-unit/data/thunk.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,14 +38,16 @@ import {
3838
updateCourseOutlineInfoLoadingStatus,
3939
updateMovedXBlockParams,
4040
} from './slice';
41-
import { getNotificationMessage } from './utils';
41+
import { getNotificationMessage, isUnitReadOnly } from './utils';
4242

4343
export function fetchCourseUnitQuery(courseId) {
4444
return async (dispatch) => {
4545
dispatch(updateLoadingCourseUnitStatus({ status: RequestStatus.IN_PROGRESS }));
4646

4747
try {
4848
const courseUnit = await getCourseUnitData(courseId);
49+
courseUnit.readOnly = isUnitReadOnly(courseUnit);
50+
4951
dispatch(fetchCourseItemSuccess(courseUnit));
5052
dispatch(updateLoadingCourseUnitStatus({ status: RequestStatus.SUCCESSFUL }));
5153
return true;

src/course-unit/data/utils.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,3 +84,15 @@ export const updateXBlockBlockIdToId = (data) => {
8484

8585
return updatedData;
8686
};
87+
88+
/**
89+
* Returns whether the given Unit should be read-only.
90+
*
91+
* Units sourced from libraries are read-only (temporary, for Teak).
92+
*
93+
* @param {object} unit - uses the 'upstreamInfo' object if found.
94+
* @returns {boolean} True if readOnly, False if editable.
95+
*/
96+
export const isUnitReadOnly = ({ upstreamInfo }) => (
97+
upstreamInfo && upstreamInfo.upstreamRef && upstreamInfo.upstreamRef.startsWith('lct:')
98+
);

0 commit comments

Comments
 (0)