Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/authz/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,6 @@ export const COURSE_PERMISSIONS = {
IMPORT_COURSE: 'courses.import_course',
EXPORT_COURSE: 'courses.export_course',
EXPORT_TAGS: 'courses.export_tags',

MANAGE_TAGS: 'courses.manage_tags',
};
21 changes: 21 additions & 0 deletions src/authz/permissionHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getChecklistsPermissions,
getImportExportPermissions,
getViewTeamPermissions,
getTagsPermissions,
} from './permissionHelpers';
import { CONTENT_LIBRARY_PERMISSIONS, COURSE_PERMISSIONS } from './constants';

Expand Down Expand Up @@ -258,6 +259,26 @@ describe('permissionHelpers', () => {
});
});

describe('getTagsPermissions', () => {
it('returns MANAGE_TAGS permission with the correct action and scope', () => {
const result = getTagsPermissions(courseId);

expect(result).toEqual({
canManageTags: {
action: COURSE_PERMISSIONS.MANAGE_TAGS,
scope: courseId,
},
});
});

it('uses the provided courseId as scope', () => {
const customCourseId = 'course-v1:TestOrg+TestCourse+2024';
const result = getTagsPermissions(customCourseId);

expect(result.canManageTags.scope).toBe(customCourseId);
});
});

describe('getViewTeamPermissions', () => {
it('returns course and library view-team permissions with the correct actions', () => {
const result = getViewTeamPermissions();
Expand Down
7 changes: 7 additions & 0 deletions src/authz/permissionHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ export const getImportExportPermissions = (courseId: string) => ({
},
});

export const getTagsPermissions = (courseId: string) => ({
canManageTags: {
action: COURSE_PERMISSIONS.MANAGE_TAGS,
scope: courseId,
},
});

export const getFilesPermissions = (courseId: string) => ({
canViewFiles: {
action: COURSE_PERMISSIONS.VIEW_FILES,
Expand Down
2 changes: 1 addition & 1 deletion src/content-tags-drawer/ContentTagsCollapsible.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,7 +419,7 @@ const ContentTagsCollapsible = ({
)}

<div className="d-flex taxonomy-tags-selector-menu">
{isEditMode && (
{isEditMode && canTagObject && (
<Select
onBlur={handleOnBlur}
styles={{
Expand Down
29 changes: 29 additions & 0 deletions src/content-tags-drawer/ContentTagsCollapsible.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,35 @@ describe('<ContentTagsCollapsible />', () => {
expect(screen.getByText(/add a tag/i)).toBeInTheDocument();
});

it('should not render add tags select in edit mode when not allowed to tag objects', async () => {
await getComponent({
...data,
taxonomyAndTagsData: {
id: 123,
name: 'Taxonomy 1',
canTagObject: false,
contentTags: [
{
value: 'Tag 1',
lineage: ['Tag 1'],
canDeleteObjecttag: true,
},
],
},
});

// Still in edit mode, so delete buttons are shown
expect(
screen.getAllByRole(
'button',
{ name: /delete/i },
).length,
).toBe(1);

// But the add tags select is hidden
expect(screen.queryByText(/add a tag/i)).not.toBeInTheDocument();
});

it('should render "no tags added yet" when expanded in read mode', async () => {
await getComponent({
...data,
Expand Down
24 changes: 24 additions & 0 deletions src/content-tags-drawer/ContentTagsDrawer.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ mockContentData.applyMock();
const {
stagedTagsId,
otherTagsId,
editableOtherTagsId,
languageWithTagsId,
languageWithoutTagsId,
largeTagsId,
Expand Down Expand Up @@ -801,6 +802,29 @@ describe('<ContentTagsDrawer />', () => {
expect(screen.getByText(/tag 3/i)).toBeInTheDocument();
});

it('should show tags staged on an "Other tags" taxonomy', async () => {
renderDrawer(editableOtherTagsId);
expect(await screen.findByText('Other tags')).toBeInTheDocument();

// To edit mode
fireEvent.click(screen.getByRole('button', { name: /edit tags/i }));

// The second "Add a tag" select belongs to the taxonomy under "Other tags";
// the first one belongs to the regular taxonomy rendered above it.
const addTagsSelects = screen.getAllByText(messages.collapsibleAddTagsPlaceholderText.defaultMessage);
expect(addTagsSelects.length).toBe(2);
// Use `mouseDown` instead of `click` since the react-select didn't respond to `click`
fireEvent.mouseDown(addTagsSelects[1]);

// Stage "Tag 1" on the other taxonomy
fireEvent.click(await screen.findByText('Tag 1'));
fireEvent.click(screen.getByRole('button', { name: /add tags/i }));

// The staged tag is merged into the other taxonomy's tags, alongside the fetched one
expect(screen.getByText('Tag 3')).toBeInTheDocument();
expect(screen.getByText('Tag 1')).toBeInTheDocument();
});

it('should show Language Taxonomy', async () => {
renderDrawer(languageWithTagsId);
expect(await screen.findByText('Languages')).toBeInTheDocument();
Expand Down
12 changes: 11 additions & 1 deletion src/content-tags-drawer/ContentTagsDrawerHelper.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
otherTaxonomiesList.push({
canChangeTaxonomy: false,
canDeleteTaxonomy: false,
canTagObject: false,
canTagObject: contentTaxonomyTags.canTagObject,
contentTags: contentTaxonomyTags.tags,
enabled: true,
exportId: contentTaxonomyTags.exportId,
Expand Down Expand Up @@ -342,6 +342,16 @@ export const useCreateContentTagsDrawerContext = (contentId, canTagObject, fetch
...fetchedTags,
...globalStagedContentTags[taxonomyId],
];
} else if (mergedOtherTaxonomies[taxonomyId]) {
const stagedLineages = globalStagedContentTags[taxonomyId].map((t) => t.lineage.slice(0, -1)).flat();
const fetchedTags = mergedOtherTaxonomies[taxonomyId].contentTags.filter((t) =>
!stagedLineages.includes(t.value)
);

mergedOtherTaxonomies[taxonomyId].contentTags = [
...fetchedTags,
...globalStagedContentTags[taxonomyId],
];
}
});

Expand Down
36 changes: 36 additions & 0 deletions src/content-tags-drawer/data/api.mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export async function mockContentTaxonomyTagsData(contentId: string): Promise<an
return thisMock.stagedTags;
case thisMock.otherTagsId:
return thisMock.otherTags;
case thisMock.editableOtherTagsId:
return thisMock.editableOtherTags;
case thisMock.languageWithTagsId:
return thisMock.languageWithTags;
case thisMock.languageWithoutTagsId:
Expand Down Expand Up @@ -89,6 +91,40 @@ mockContentTaxonomyTagsData.otherTags = {
},
],
};
mockContentTaxonomyTagsData.editableOtherTagsId =
'block-v1:StagedTagsOrg+STC1+2023_1+type@vertical+block@editableOtherTagsId';
// Same shape as `otherTags`, but the "other" taxonomy is taggable, so its
// "Add a tag" select is available and tags can be staged onto it.
mockContentTaxonomyTagsData.editableOtherTags = {
taxonomies: [
{
name: 'Taxonomy 1',
taxonomyId: 123,
canTagObject: true,
tags: [
{
value: 'Tag 2',
lineage: ['Tag 2'],
canDeleteObjecttag: true,
},
],
},
{
// Not present in `mockTaxonomyListData.stagedTags`, so it is rendered
// under the "Other tags" section.
name: 'Taxonomy 2',
taxonomyId: 1234,
canTagObject: true,
tags: [
{
value: 'Tag 3',
lineage: ['Tag 3'],
canDeleteObjecttag: true,
},
],
},
],
};
mockContentTaxonomyTagsData.languageWithTagsId =
'block-v1:LanguageTagsOrg+STC1+2023_1+type@vertical+block@languageWithTagsId';
mockContentTaxonomyTagsData.languageWithTags = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ jest.mock('../data/apiHooks', () => ({
}));
jest.mock('../ContentTagsDrawer', () => jest.fn(() => <div>Mocked ContentTagsDrawer</div>));

const RootWrapper = () => (
const RootWrapper = ({ canManageTags = true } = {}) => (
<IntlProvider locale="en" messages={{}}>
<TagsSidebarBody />
<TagsSidebarBody canManageTags={canManageTags} />
</IntlProvider>
);

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

expect(screen.getByText('Mocked ContentTagsDrawer')).toBeInTheDocument();
});

it('should not render Manage tags button when canManageTags is false', () => {
useContentTaxonomyTagsData.mockReturnValue({
isSuccess: true,
data: contentTaxonomyTagsMock[contentId],
});
render(<RootWrapper canManageTags={false} />);

expect(screen.queryByRole('button', { name: /manage tags/i })).not.toBeInTheDocument();
});
});
23 changes: 13 additions & 10 deletions src/content-tags-drawer/tags-sidebar-controls/TagsSidebarBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ import { TagTree } from '../ContentTagsCollapsible';

interface TagsSidebarBodyProps {
readOnly: boolean;
canManageTags?: boolean;
}

const TagsSidebarBody = ({ readOnly }: TagsSidebarBodyProps) => {
const TagsSidebarBody = ({ readOnly, canManageTags = true }: TagsSidebarBodyProps) => {
const intl = useIntl();
const [showManageTags, setShowManageTags] = useState(false);
const contentId = useParams().blockId;
Expand Down Expand Up @@ -97,15 +98,17 @@ const TagsSidebarBody = ({ readOnly }: TagsSidebarBodyProps) => {
</div>
)}

<Button
className="mt-3 ml-2"
variant="outline-primary"
size="sm"
onClick={() => setShowManageTags(true)}
disabled={readOnly}
>
{intl.formatMessage(messages.manageTagsButton)}
</Button>
{canManageTags && (
<Button
className="mt-3 ml-2"
variant="outline-primary"
size="sm"
onClick={() => setShowManageTags(true)}
disabled={readOnly}
>
{intl.formatMessage(messages.manageTagsButton)}
</Button>
)}
</Stack>
</Card.Body>
<ContentTagsDrawerSheet
Expand Down
5 changes: 3 additions & 2 deletions src/content-tags-drawer/tags-sidebar-controls/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import TagsSidebarBody from './TagsSidebarBody';

interface TagsSidebarControlsProps {
readOnly: boolean;
canManageTags?: boolean;
}

const TagsSidebarControls = ({ readOnly }: TagsSidebarControlsProps) => (
const TagsSidebarControls = ({ readOnly, canManageTags = true }: TagsSidebarControlsProps) => (
<>
<TagsSidebarHeader />
<TagsSidebarBody readOnly={readOnly} />
<TagsSidebarBody readOnly={readOnly} canManageTags={canManageTags} />
</>
);

Expand Down
30 changes: 27 additions & 3 deletions src/course-outline/card-header/CardHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,12 @@ const cardHeaderProps = {

let validateUserPermissionsMock;

const mockPermissions = (canEditCourseContent = true) => {
mockWaffleFlags({ enableAuthzCourseAuthoring: !canEditCourseContent });
validateUserPermissionsMock.mockResolvedValue({ canEditCourseContent });
const mockPermissions = (canEditCourseContent = true, canManageTags = true) => {
mockWaffleFlags({ enableAuthzCourseAuthoring: !canEditCourseContent || !canManageTags });
validateUserPermissionsMock.mockResolvedValue({
canEditCourseContent,
canManageTags,
});
};

const renderComponent = (props?: object, entry = '/') => {
Expand Down Expand Up @@ -525,6 +528,27 @@ describe('<CardHeader />', () => {
expect(screen.queryByText('0')).not.toBeInTheDocument();
});

it('hides Manage tags menu item and tag count when canManageTags is false', async () => {
setConfig({
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
mockGetTagsCount.mockResolvedValue({ 12345: 17 });
mockPermissions(true, false);
renderComponent();

// Wait until the permissions have resolved and the menu is available.
const menuButton = await screen.findByRole('button', { name: 'subsection-card-header__menu' });

// Tag count is not rendered
expect(screen.queryByText('17')).not.toBeInTheDocument();

// Manage tags menu item is not rendered
fireEvent.click(menuButton);
expect(await screen.findByText(messages.menuConfigure.defaultMessage)).toBeInTheDocument();
expect(screen.queryByText(messages.menuManageTags.defaultMessage)).not.toBeInTheDocument();
});

it('should render sync button when is ready to sync', () => {
const mockClickSync = jest.fn();

Expand Down
7 changes: 5 additions & 2 deletions src/course-outline/card-header/CardHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import { useEscapeClick } from '@src/hooks';
import { XBlockActions } from '@src/data/types';
import { useUpdateCourseBlockName } from '@src/course-outline/data/apiHooks';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import { useCourseUserPermissions } from '@src/authz/hooks';
import { getTagsPermissions } from '@src/authz/permissionHelpers';
import { ITEM_BADGE_STATUS } from '../constants';
import { scrollToElement } from '../utils';
import CardStatus from './CardStatus';
Expand Down Expand Up @@ -116,6 +118,7 @@ const CardHeader = ({
onClickManageTags?.();
}, [setCurrentPageKey, cardId]);
const { courseId, canEditCourseContent, canPublishCourseContent } = useCourseAuthoringContext();
const { canManageTags } = useCourseUserPermissions(courseId, getTagsPermissions(courseId));
const [isFormOpen, openForm, closeForm] = useToggle(false);
// Set true by any Escape keydown handler; checked in handleEditSubmit
// to prevent blur-after-Escape from saving the dirty titleValue.
Expand Down Expand Up @@ -301,7 +304,7 @@ const CardHeader = ({
{(isVertical || isSequential) && (
<CardStatus status={status} showDiscussionsEnabledBadge={showDiscussionsEnabledBadge || false} />
)}
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && !!contentTagCount && (
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && canManageTags && !!contentTagCount && (
<TagCount count={contentTagCount} onClick={openManageTagsDrawer} />
)}
{extraActionsComponent}
Expand Down Expand Up @@ -354,7 +357,7 @@ const CardHeader = ({
>
{intl.formatMessage(messages.menuConfigure)}
</Dropdown.Item>
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && (
{getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && canManageTags && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-manage-tags-button`}
disabled={editMutation.isPending}
Expand Down
Loading