Skip to content

Commit 71db3e1

Browse files
committed
feat: validate unit tags manage nd add test
1 parent 6dee6cc commit 71db3e1

12 files changed

Lines changed: 260 additions & 23 deletions

File tree

src/authz/permissionHelpers.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getChecklistsPermissions,
1313
getImportExportPermissions,
1414
getViewTeamPermissions,
15+
getTagsPermissions,
1516
} from './permissionHelpers';
1617
import { CONTENT_LIBRARY_PERMISSIONS, COURSE_PERMISSIONS } from './constants';
1718

@@ -254,6 +255,26 @@ describe('permissionHelpers', () => {
254255
});
255256
});
256257

258+
describe('getTagsPermissions', () => {
259+
it('returns MANAGE_TAGS permission with the correct action and scope', () => {
260+
const result = getTagsPermissions(courseId);
261+
262+
expect(result).toEqual({
263+
canManageTags: {
264+
action: COURSE_PERMISSIONS.MANAGE_TAGS,
265+
scope: courseId,
266+
},
267+
});
268+
});
269+
270+
it('uses the provided courseId as scope', () => {
271+
const customCourseId = 'course-v1:TestOrg+TestCourse+2024';
272+
const result = getTagsPermissions(customCourseId);
273+
274+
expect(result.canManageTags.scope).toBe(customCourseId);
275+
});
276+
});
277+
257278
describe('getViewTeamPermissions', () => {
258279
it('returns course and library view-team permissions with the correct actions', () => {
259280
const result = getViewTeamPermissions();

src/content-tags-drawer/ContentTagsCollapsible.test.jsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,35 @@ describe('<ContentTagsCollapsible />', () => {
256256
expect(screen.getByText(/add a tag/i)).toBeInTheDocument();
257257
});
258258

259+
it('should not render add tags select in edit mode when not allowed to tag objects', async () => {
260+
await getComponent({
261+
...data,
262+
taxonomyAndTagsData: {
263+
id: 123,
264+
name: 'Taxonomy 1',
265+
canTagObject: false,
266+
contentTags: [
267+
{
268+
value: 'Tag 1',
269+
lineage: ['Tag 1'],
270+
canDeleteObjecttag: true,
271+
},
272+
],
273+
},
274+
});
275+
276+
// Still in edit mode, so delete buttons are shown
277+
expect(
278+
screen.getAllByRole(
279+
'button',
280+
{ name: /delete/i },
281+
).length,
282+
).toBe(1);
283+
284+
// But the add tags select is hidden
285+
expect(screen.queryByText(/add a tag/i)).not.toBeInTheDocument();
286+
});
287+
259288
it('should render "no tags added yet" when expanded in read mode', async () => {
260289
await getComponent({
261290
...data,

src/content-tags-drawer/ContentTagsDrawerHelper.jsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,7 +344,9 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
344344
];
345345
} else if (mergedOtherTaxonomies[taxonomyId]) {
346346
const stagedLineages = globalStagedContentTags[taxonomyId].map((t) => t.lineage.slice(0, -1)).flat();
347-
const fetchedTags = mergedOtherTaxonomies[taxonomyId].contentTags.filter((t) => !stagedLineages.includes(t.value));
347+
const fetchedTags = mergedOtherTaxonomies[taxonomyId].contentTags.filter((t) =>
348+
!stagedLineages.includes(t.value)
349+
);
348350

349351
mergedOtherTaxonomies[taxonomyId].contentTags = [
350352
...fetchedTags,

src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.test.jsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ jest.mock('../data/apiHooks', () => ({
1515
}));
1616
jest.mock('../ContentTagsDrawer', () => jest.fn(() => <div>Mocked ContentTagsDrawer</div>));
1717

18-
const RootWrapper = () => (
18+
const RootWrapper = ({ canManageTags = true } = {}) => (
1919
<IntlProvider locale="en" messages={{}}>
20-
<TagsSidebarBody />
20+
<TagsSidebarBody canManageTags={canManageTags} />
2121
</IntlProvider>
2222
);
2323

@@ -52,4 +52,14 @@ describe('<TagSidebarBody>', () => {
5252

5353
expect(screen.getByText('Mocked ContentTagsDrawer')).toBeInTheDocument();
5454
});
55+
56+
it('should not render Manage tags button when canManageTags is false', () => {
57+
useContentTaxonomyTagsData.mockReturnValue({
58+
isSuccess: true,
59+
data: contentTaxonomyTagsMock[contentId],
60+
});
61+
render(<RootWrapper canManageTags={false} />);
62+
63+
expect(screen.queryByRole('button', { name: /manage tags/i })).not.toBeInTheDocument();
64+
});
5565
});

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { setConfig, getConfig } from '@edx/frontend-platform';
22

3+
import { useCourseUserPermissions } from '@src/authz/hooks';
34
import { ITEM_BADGE_STATUS } from '@src/course-outline/constants';
45
import {
56
act,
@@ -39,6 +40,11 @@ jest.mock('@src/course-outline/data/apiHooks', () => ({
3940
useUpdateCourseBlockName: () => useUpdateCourseBlockNameMock,
4041
}));
4142

43+
jest.mock('@src/authz/hooks', () => ({
44+
...jest.requireActual('@src/authz/hooks'),
45+
useCourseUserPermissions: jest.fn(),
46+
}));
47+
4248
const cardHeaderProps = {
4349
title: 'Some title',
4450
status: ITEM_BADGE_STATUS.live,
@@ -100,6 +106,11 @@ const renderComponent = (props?: object, entry = '/') => {
100106
describe('<CardHeader />', () => {
101107
beforeEach(() => {
102108
setupCardTestMocks();
109+
jest.mocked(useCourseUserPermissions).mockReturnValue({
110+
canManageTags: true,
111+
isLoading: false,
112+
isAuthzEnabled: false,
113+
} as ReturnType<typeof useCourseUserPermissions>);
103114
useUpdateCourseBlockNameMock.isPending = false;
104115
useUpdateCourseBlockNameMock.mutate.mockClear();
105116
useUpdateCourseBlockNameMock.mutateAsync.mockClear();
@@ -515,6 +526,28 @@ describe('<CardHeader />', () => {
515526
expect(screen.queryByText('0')).not.toBeInTheDocument();
516527
});
517528

529+
it('hides Manage tags menu item and tag count when canManageTags is false', async () => {
530+
setConfig({
531+
...getConfig(),
532+
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
533+
});
534+
jest.mocked(useCourseUserPermissions).mockReturnValue({
535+
canManageTags: false,
536+
isLoading: false,
537+
isAuthzEnabled: false,
538+
} as ReturnType<typeof useCourseUserPermissions>);
539+
mockGetTagsCount.mockResolvedValue({ 12345: 17 });
540+
renderComponent();
541+
542+
// Tag count is not rendered
543+
expect(screen.queryByText('17')).not.toBeInTheDocument();
544+
545+
// Manage tags menu item is not rendered
546+
const menuButton = await screen.findByTestId('subsection-card-header__menu-button');
547+
fireEvent.click(menuButton);
548+
expect(screen.queryByText(messages.menuManageTags.defaultMessage)).not.toBeInTheDocument();
549+
});
550+
518551
it('should render sync button when is ready to sync', () => {
519552
const mockClickSync = jest.fn();
520553

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ import { useEscapeClick } from '@src/hooks';
3030
import { XBlockActions } from '@src/data/types';
3131
import { useUpdateCourseBlockName } from '@src/course-outline/data/apiHooks';
3232
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
33+
import { useCourseUserPermissions } from '@src/authz/hooks';
34+
import { getTagsPermissions } from '@src/authz/permissionHelpers';
3335
import { ITEM_BADGE_STATUS } from '../constants';
3436
import { scrollToElement } from '../utils';
3537
import CardStatus from './CardStatus';
@@ -118,6 +120,7 @@ const CardHeader = ({
118120
onClickManageTags?.();
119121
}, [setCurrentPageKey, cardId]);
120122
const { courseId } = useCourseAuthoringContext();
123+
const { canManageTags } = useCourseUserPermissions(courseId, getTagsPermissions(courseId));
121124
const [isFormOpen, openForm, closeForm] = useToggle(false);
122125
// Set true by any Escape keydown handler; checked in handleEditSubmit
123126
// to prevent blur-after-Escape from saving the dirty titleValue.
@@ -300,7 +303,7 @@ const CardHeader = ({
300303
{(isVertical || isSequential) && (
301304
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge || false} />
302305
)}
303-
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && !!contentTagCount && (
306+
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && canManageTags && !!contentTagCount && (
304307
<TagCount count={contentTagCount} onClick={openManageTagsDrawer} />
305308
)}
306309
{extraActionsComponent}
@@ -349,7 +352,7 @@ const CardHeader = ({
349352
>
350353
{intl.formatMessage(messages.menuConfigure)}
351354
</Dropdown.Item>
352-
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
355+
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && canManageTags && (
353356
<Dropdown.Item
354357
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
355358
disabled={editMutation.isPending}

src/course-outline/outline-sidebar/OutlineAlignSidebar.test.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { render, screen, initializeMocks } from '@src/testUtils';
22

3+
import { useCourseUserPermissions } from '@src/authz/hooks';
34
import * as CourseAuthoringContext from '@src/CourseAuthoringContext';
45
import * as CourseOutlineContext from '@src/course-outline/CourseOutlineContext';
56
import * as CourseDetailsApi from '@src/data/apiHooks';
@@ -8,13 +9,18 @@ import * as OutlineSidebarContext from './OutlineSidebarContext';
89
import { OutlineAlignSidebar } from './OutlineAlignSidebar';
910

1011
jest.mock('@src/content-tags-drawer', () => ({
11-
ContentTagsDrawer: jest.fn(({ id, variant }) => (
12+
ContentTagsDrawer: jest.fn(({ id, variant, readOnly }) => (
1213
<div data-testid="content-tags-drawer">
13-
drawer-mock-{id}-{variant}
14+
drawer-mock-{id}-{variant}-{String(readOnly)}
1415
</div>
1516
)),
1617
}));
1718

19+
jest.mock('@src/authz/hooks', () => ({
20+
...jest.requireActual('@src/authz/hooks'),
21+
useCourseUserPermissions: jest.fn(),
22+
}));
23+
1824
describe('OutlineAlignSidebar', () => {
1925
const setCurrentSelection = jest.fn();
2026
const clearSelection = jest.fn();
@@ -25,6 +31,11 @@ describe('OutlineAlignSidebar', () => {
2531

2632
beforeEach(() => {
2733
initializeMocks();
34+
jest.mocked(useCourseUserPermissions).mockReturnValue({
35+
canManageTags: true,
36+
isLoading: false,
37+
isAuthzEnabled: false,
38+
} as ReturnType<typeof useCourseUserPermissions>);
2839
setCurrentSelection.mockReset();
2940
clearSelection.mockReset();
3041
openContainerSidebar.mockReset();
@@ -80,7 +91,20 @@ describe('OutlineAlignSidebar', () => {
8091

8192
expect(drawer).toBeInTheDocument();
8293
expect(drawer).toHaveTextContent(
83-
'drawer-mock-block-v1:test+course+run+type@sequential+block@seq1-component',
94+
'drawer-mock-block-v1:test+course+run+type@sequential+block@seq1-component-false',
95+
);
96+
});
97+
98+
it('renders ContentTagsDrawer in read-only mode when user cannot manage tags', () => {
99+
jest.mocked(useCourseUserPermissions).mockReturnValue({
100+
canManageTags: false,
101+
isLoading: false,
102+
isAuthzEnabled: false,
103+
} as ReturnType<typeof useCourseUserPermissions>);
104+
render(<OutlineAlignSidebar />);
105+
106+
expect(screen.getByTestId('content-tags-drawer')).toHaveTextContent(
107+
'drawer-mock-block-v1:test+course+run+type@sequential+block@seq1-component-true',
84108
);
85109
});
86110

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { fireEvent, initializeMocks, render, screen } from '@src/testUtils';
2+
import { useCourseUserPermissions } from '@src/authz/hooks';
3+
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
4+
import { mockContentTaxonomyTagsData } from '@src/content-tags-drawer/data/api.mocks';
5+
import { CourseInfoSidebar } from './CourseInfoSidebar';
6+
7+
const courseId = mockContentTaxonomyTagsData.otherTagsId;
8+
9+
jest.mock('@src/authz/hooks', () => ({
10+
...jest.requireActual('@src/authz/hooks'),
11+
useCourseUserPermissions: jest.fn(),
12+
}));
13+
14+
jest.mock('@src/course-outline/data/apiHooks', () => ({
15+
...jest.requireActual('@src/course-outline/data/apiHooks'),
16+
useCourseDetails: () => ({ data: { title: 'Test Course' } }),
17+
}));
18+
19+
jest.mock('@src/search-manager', () => ({
20+
useGetBlockTypes: () => ({ data: [] }),
21+
}));
22+
23+
jest.mock('../OutlineSidebarContext', () => ({
24+
...jest.requireActual('../OutlineSidebarContext'),
25+
useOutlineSidebarContext: () => ({
26+
currentTabKey: 'info',
27+
setCurrentTabKey: jest.fn(),
28+
}),
29+
}));
30+
31+
mockContentTaxonomyTagsData.applyMock();
32+
33+
const renderComponent = () =>
34+
render(<CourseInfoSidebar />, {
35+
extraWrapper: ({ children }) => (
36+
<CourseAuthoringProvider courseId={courseId}>
37+
{children}
38+
</CourseAuthoringProvider>
39+
),
40+
});
41+
42+
describe('<CourseInfoSidebar />', () => {
43+
beforeEach(() => {
44+
initializeMocks();
45+
jest.mocked(useCourseUserPermissions).mockReturnValue({
46+
canManageTags: true,
47+
isLoading: false,
48+
isAuthzEnabled: false,
49+
} as ReturnType<typeof useCourseUserPermissions>);
50+
});
51+
52+
it('shows the Manage tags action when user can manage tags', async () => {
53+
renderComponent();
54+
expect(await screen.findByText('Taxonomy Alignments')).toBeInTheDocument();
55+
56+
const taxonomySection = screen.getByText('Taxonomy Alignments').closest('.pgn__hstack') as HTMLElement;
57+
const toggle = taxonomySection.querySelector('.dropdown button') as HTMLButtonElement;
58+
expect(toggle).not.toBeNull();
59+
fireEvent.click(toggle);
60+
61+
expect(await screen.findByText('Manage tags')).toBeInTheDocument();
62+
});
63+
64+
it('hides the Manage tags action when user cannot manage tags', async () => {
65+
jest.mocked(useCourseUserPermissions).mockReturnValue({
66+
canManageTags: false,
67+
isLoading: false,
68+
isAuthzEnabled: false,
69+
} as ReturnType<typeof useCourseUserPermissions>);
70+
renderComponent();
71+
expect(await screen.findByText('Taxonomy Alignments')).toBeInTheDocument();
72+
73+
const taxonomySection = screen.getByText('Taxonomy Alignments').closest('.pgn__hstack') as HTMLElement;
74+
expect(taxonomySection.querySelector('.dropdown')).toBeNull();
75+
expect(screen.queryByText('Manage tags')).not.toBeInTheDocument();
76+
});
77+
});

src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,14 @@ const DetailsTab = () => {
4444
<SidebarSection
4545
title={intl.formatMessage(messages.sidebarSectionTaxonomy)}
4646
icon={Tag}
47-
actions={canManageTags ? [
48-
{
49-
label: intl.formatMessage(messages.sidebarSectionTaxonomyManageTags),
50-
onClick: openManageTagsDrawer,
51-
},
52-
] : undefined}
47+
actions={canManageTags ?
48+
[
49+
{
50+
label: intl.formatMessage(messages.sidebarSectionTaxonomyManageTags),
51+
onClick: openManageTagsDrawer,
52+
},
53+
] :
54+
undefined}
5355
>
5456
<ContentTagsSnippet contentId={courseId} />
5557
</SidebarSection>

0 commit comments

Comments
 (0)