diff --git a/src/content-tags-drawer/ContentTagsCollapsible.jsx b/src/content-tags-drawer/ContentTagsCollapsible.jsx
index 345fda8e27..8a7374fcc5 100644
--- a/src/content-tags-drawer/ContentTagsCollapsible.jsx
+++ b/src/content-tags-drawer/ContentTagsCollapsible.jsx
@@ -393,16 +393,18 @@ const ContentTagsCollapsible = ({
&& (
{intl.formatMessage(messages.collapsibleNoTagsAddedText)}
-
- { intl.formatMessage(messages.collapsibleAddStagedTagsButtonText) }
-
+ {canTagObject && (
+
+ { intl.formatMessage(messages.collapsibleAddStagedTagsButtonText) }
+
+ )}
)}
@@ -418,7 +420,7 @@ const ContentTagsCollapsible = ({
)}
- {isEditMode && canTagObject && (
+ {isEditMode && (
', () => {
expect(data.toEditMode).toHaveBeenCalledTimes(1);
});
+ it('should not render "add tags" button when expanded and not allowed to tag objects', async () => {
+ await getComponent({
+ ...data,
+ isEditMode: false,
+ taxonomyAndTagsData: {
+ id: 123,
+ name: 'Taxonomy 1',
+ canTagObject: false,
+ contentTags: [],
+ },
+ });
+
+ const expandToggle = screen.getByRole('button', {
+ name: /taxonomy 1/i,
+ });
+ fireEvent.click(expandToggle);
+ expect(screen.queryByText(/no tags added yet/i)).toBeInTheDocument();
+
+ const addTags = screen.queryByRole('button', {
+ name: /add tags/i,
+ });
+ expect(addTags).not.toBeInTheDocument();
+ });
+
it('should call `openCollapsible` when click in the collapsible', async () => {
await getComponent({
...data,
@@ -396,7 +420,7 @@ describe(' ', () => {
expect(data.removeGlobalStagedContentTag).toHaveBeenCalledWith(taxonomyId, 'Tag 3');
});
- it('should call `addRemovedContentTag` when a feched tag is deleted', async () => {
+ it('should call `addRemovedContentTag` when a fetched tag is deleted', async () => {
await getComponent();
const tag = screen.getByText(/tag 2/i);
diff --git a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx
index b1d7a09e15..dcc016ab8a 100644
--- a/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx
+++ b/src/content-tags-drawer/ContentTagsCollapsibleHelper.jsx
@@ -116,7 +116,7 @@ const useContentTagsCollapsibleHelper = (
// State to keep track of the staged tags (and along with ancestors) that should be removed
const [stagedTagsToRemove, setStagedTagsToRemove] = React.useState(/** @type string[] */([]));
- // State to keep track of the global tags (stagged and feched) that should be removed
+ // State to keep track of the global tags (staged and fetched) that should be removed
const [globalTagsToRemove, setGlobalTagsToRemove] = React.useState(/** @type string[] */([]));
// Handles the removal of staged content tags based on what was removed
@@ -140,7 +140,7 @@ const useContentTagsCollapsibleHelper = (
// A new tag has been removed
removeGlobalStagedContentTag(id, tag);
} else if (contentTags.some(t => t.value === tag)) {
- // A feched tag has been removed
+ // A fetched tag has been removed
addRemovedContentTag(id, tag);
}
});
@@ -157,7 +157,7 @@ const useContentTagsCollapsibleHelper = (
explicitStaged.forEach((tag) => {
if (globalStagedRemovedContentTags[id]
&& globalStagedRemovedContentTags[id].includes(tag.value)) {
- // A feched tag that has been removed has been added again
+ // A fetched tag that has been removed has been added again
deleteRemovedContentTag(id, tag.value);
} else {
// New tag added
diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx
index 75ffe35b79..91ad97d8fc 100644
--- a/src/content-tags-drawer/ContentTagsDrawer.test.jsx
+++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx
@@ -44,7 +44,7 @@ jest.mock('react-router-dom', () => ({
const renderDrawer = (contentId, drawerParams = {}) => (
render(
-
+
,
{ path, params: { contentId } },
)
@@ -244,6 +244,29 @@ describe(' ', () => {
expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
});
+ test.each([
+ {
+ variant: 'drawer',
+ editButton: /edit tags/i,
+ },
+ {
+ variant: 'component',
+ editButton: /manage tags/i,
+ },
+ ])(
+ 'should hide "$editButton" button on $variant variant if not allowed to tag object',
+ async ({ variant, editButton }) => {
+ renderDrawer(stagedTagsId, { variant, canTagObject: false });
+ expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
+
+ expect(screen.queryByRole('button', { name: editButton })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /delete/i })).not.toBeInTheDocument();
+ expect(screen.queryByText(/add a tag/i)).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /cancel/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
+ },
+ );
+
it('should test adding a content tag to the staged tags for a taxonomy', async () => {
renderDrawer(stagedTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
@@ -368,7 +391,7 @@ describe(' ', () => {
expect(screen.queryByText(/tag 3/i)).not.toBeInTheDocument();
});
- it('should test delete feched tags and cancel', async () => {
+ it('should test delete fetched tags and cancel', async () => {
renderDrawer(stagedTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
@@ -435,7 +458,7 @@ describe(' ', () => {
expect(screen.queryByText(/tag 3/i)).not.toBeInTheDocument();
});
- it('should test add removed feched tags and cancel', async () => {
+ it('should test add removed fetched tags and cancel', async () => {
renderDrawer(stagedTagsId);
expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument();
diff --git a/src/content-tags-drawer/ContentTagsDrawer.tsx b/src/content-tags-drawer/ContentTagsDrawer.tsx
index 28ab128d94..040cce196b 100644
--- a/src/content-tags-drawer/ContentTagsDrawer.tsx
+++ b/src/content-tags-drawer/ContentTagsDrawer.tsx
@@ -73,7 +73,7 @@ const TaxonomyList = ({ contentId }: TaxonomyListProps) => {
return ;
};
-const ContentTagsDrawerTittle = () => {
+const ContentTagsDrawerTitle = () => {
const intl = useIntl();
const {
isContentDataLoaded,
@@ -100,9 +100,10 @@ const ContentTagsDrawerTittle = () => {
interface ContentTagsDrawerVariantFooterProps {
onClose: () => void,
+ canTagObject: boolean,
}
-const ContentTagsDrawerVariantFooter = ({ onClose }: ContentTagsDrawerVariantFooterProps) => {
+const ContentTagsDrawerVariantFooter = ({ onClose, canTagObject }: ContentTagsDrawerVariantFooterProps) => {
const intl = useIntl();
const {
commitGlobalStagedTagsStatus,
@@ -130,16 +131,18 @@ const ContentTagsDrawerVariantFooter = ({ onClose }: ContentTagsDrawerVariantFoo
? messages.tagsDrawerCancelButtonText
: messages.tagsDrawerCloseButtonText)}
-
- { intl.formatMessage(isEditMode
- ? messages.tagsDrawerSaveButtonText
- : messages.tagsDrawerEditTagsButtonText)}
-
+ {canTagObject && (
+
+ { intl.formatMessage(isEditMode
+ ? messages.tagsDrawerSaveButtonText
+ : messages.tagsDrawerEditTagsButtonText)}
+
+ )}
)
: (
@@ -154,7 +157,7 @@ const ContentTagsDrawerVariantFooter = ({ onClose }: ContentTagsDrawerVariantFoo
);
};
-const ContentTagsComponentVariantFooter = () => {
+const ContentTagsComponentVariantFooter = ({ canTagObject }: { canTagObject: boolean }) => {
const intl = useIntl();
const {
commitGlobalStagedTagsStatus,
@@ -196,13 +199,15 @@ const ContentTagsComponentVariantFooter = () => {
)}
) : (
-
- {intl.formatMessage(messages.manageTagsButton)}
-
+ canTagObject && (
+
+ {intl.formatMessage(messages.manageTagsButton)}
+
+ )
)}
);
@@ -211,6 +216,7 @@ const ContentTagsComponentVariantFooter = () => {
interface ContentTagsDrawerProps {
id?: string;
onClose?: () => void;
+ canTagObject?: boolean;
variant?: 'drawer' | 'component';
}
@@ -226,6 +232,7 @@ interface ContentTagsDrawerProps {
const ContentTagsDrawer = ({
id,
onClose,
+ canTagObject = false,
variant = 'drawer',
}: ContentTagsDrawerProps) => {
const intl = useIntl();
@@ -237,7 +244,7 @@ const ContentTagsDrawer = ({
throw new Error('Error: contentId cannot be null.');
}
- const context = useContentTagsDrawerContext(contentId);
+ const context = useContentTagsDrawerContext(contentId, canTagObject);
const { blockingSheet } = useContext(ContentTagsDrawerSheetContext);
const {
@@ -301,9 +308,9 @@ const ContentTagsDrawer = ({
if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) {
switch (variant) {
case 'drawer':
- return ;
+ return ;
case 'component':
- return ;
+ return ;
default:
return null;
}
@@ -331,7 +338,7 @@ const ContentTagsDrawer = ({
)}
>
{variant === 'drawer' && (
-
+
)}
,
* addStagedContentTag: (taxonomyId: number, addedTag: StagedTagData) => void,
@@ -46,7 +47,7 @@ import { ContentTagsDrawerSheetContext } from './common/context';
* otherTaxonomies: TagsInTaxonomy[],
* }}
*/
-const useContentTagsDrawerContext = (contentId) => {
+const useContentTagsDrawerContext = (contentId, canTagObject) => {
const intl = useIntl();
const org = extractOrgFromContentId(contentId);
@@ -58,9 +59,9 @@ const useContentTagsDrawerContext = (contentId) => {
const [stagedContentTags, setStagedContentTags] = React.useState({});
// When a staged tags on a taxonomy is commitet then is saved on this map.
const [globalStagedContentTags, setGlobalStagedContentTags] = React.useState({});
- // This stores feched tags deleted by the user.
+ // This stores fetched tags deleted by the user.
const [globalStagedRemovedContentTags, setGlobalStagedRemovedContentTags] = React.useState({});
- // Merges feched tags, global staged tags and global removed staged tags
+ // Merges fetched tags, global staged tags and global removed staged tags
const [tagsByTaxonomy, setTagsByTaxonomy] = React.useState(/** @type TagsInTaxonomy[] */ ([]));
// Other taxonomies that the user doesn't have permissions
const [otherTaxonomies, setOtherTaxonomies] = React.useState(/** @type TagsInTaxonomy[] */ ([]));
@@ -79,8 +80,8 @@ const useContentTagsDrawerContext = (contentId) => {
} = useContentTaxonomyTagsData(contentId);
const { data: taxonomyListData, isSuccess: isTaxonomyListLoaded } = useTaxonomyList(org);
- // Tags feched from database
- const { fechedTaxonomies, fechedOtherTaxonomies } = React.useMemo(() => {
+ // Tags fetched from database
+ const { fetchedTaxonomies, fetchedOtherTaxonomies } = React.useMemo(() => {
const sortTaxonomies = (taxonomiesList) => {
const taxonomiesWithData = taxonomiesList.filter(
(t) => t.contentTags.length !== 0,
@@ -115,6 +116,7 @@ const useContentTagsDrawerContext = (contentId) => {
// Initialize list of content tags in taxonomies to populate
const taxonomiesList = taxonomyListData.results.map((taxonomy) => ({
...taxonomy,
+ canTagObject: taxonomy.canTagObject && canTagObject,
contentTags: /** @type {ContentTagData[]} */([]),
}));
@@ -149,13 +151,13 @@ const useContentTagsDrawerContext = (contentId) => {
);
return {
- fechedTaxonomies: sortTaxonomies(filteredTaxonomies),
- fechedOtherTaxonomies: otherTaxonomiesList,
+ fetchedTaxonomies: sortTaxonomies(filteredTaxonomies),
+ fetchedOtherTaxonomies: otherTaxonomiesList,
};
}
return {
- fechedTaxonomies: [],
- fechedOtherTaxonomies: [],
+ fetchedTaxonomies: [],
+ fetchedOtherTaxonomies: [],
};
}, [taxonomyListData, contentTaxonomyTagsData]);
@@ -230,28 +232,28 @@ const useContentTagsDrawerContext = (contentId) => {
const openAllCollapsible = React.useCallback(() => {
const updatedState = {};
- fechedTaxonomies.forEach((taxonomy) => {
+ fetchedTaxonomies.forEach((taxonomy) => {
updatedState[taxonomy.id] = true;
});
- fechedOtherTaxonomies.forEach((taxonomy) => {
+ fetchedOtherTaxonomies.forEach((taxonomy) => {
updatedState[taxonomy.id] = true;
});
setColapsibleStates(updatedState);
- }, [fechedTaxonomies, setColapsibleStates]);
+ }, [fetchedTaxonomies, setColapsibleStates]);
// Set initial state of collapsible based on content tags
const setCollapsibleToInitalState = React.useCallback(() => {
const updatedState = {};
- fechedTaxonomies.forEach((taxonomy) => {
+ fetchedTaxonomies.forEach((taxonomy) => {
// Taxonomy with content tags must be open
updatedState[taxonomy.id] = taxonomy.contentTags.length !== 0;
});
- fechedOtherTaxonomies.forEach((taxonomy) => {
+ fetchedOtherTaxonomies.forEach((taxonomy) => {
// Taxonomy with content tags must be open
updatedState[taxonomy.id] = taxonomy.contentTags.length !== 0;
});
setColapsibleStates(updatedState);
- }, [fechedTaxonomies, setColapsibleStates]);
+ }, [fetchedTaxonomies, setColapsibleStates]);
// Changes the drawer mode to edit
const toEditMode = React.useCallback(() => {
@@ -339,14 +341,14 @@ const useContentTagsDrawerContext = (contentId) => {
}
}
- // Updates `tagsByTaxonomy` merged feched tags, global staged tags
+ // Updates `tagsByTaxonomy` merged fetched tags, global staged tags
// and global removed staged tags.
React.useEffect(() => {
- const mergedTags = cloneDeep(fechedTaxonomies).reduce((acc, obj) => (
+ const mergedTags = cloneDeep(fetchedTaxonomies).reduce((acc, obj) => (
{ ...acc, [obj.id]: obj }
), {});
- const mergedOtherTaxonomies = cloneDeep(fechedOtherTaxonomies).reduce((acc, obj) => (
+ const mergedOtherTaxonomies = cloneDeep(fetchedOtherTaxonomies).reduce((acc, obj) => (
{ ...acc, [obj.id]: obj }
), {});
@@ -355,10 +357,10 @@ const useContentTagsDrawerContext = (contentId) => {
// TODO test this
// Filter out applied tags that should become implicit because a child tag was committed
const stagedLineages = globalStagedContentTags[taxonomyId].map((t) => t.lineage.slice(0, -1)).flat();
- const fechedTags = mergedTags[taxonomyId].contentTags.filter((t) => !stagedLineages.includes(t.value));
+ const fetchedTags = mergedTags[taxonomyId].contentTags.filter((t) => !stagedLineages.includes(t.value));
mergedTags[taxonomyId].contentTags = [
- ...fechedTags,
+ ...fetchedTags,
...globalStagedContentTags[taxonomyId],
];
}
@@ -377,8 +379,8 @@ const useContentTagsDrawerContext = (contentId) => {
});
// It is constructed this way to maintain the order
- // of the list `fechedTaxonomies`
- const mergedTagsArray = fechedTaxonomies.map(obj => mergedTags[obj.id]);
+ // of the list `fetchedTaxonomies`
+ const mergedTagsArray = fetchedTaxonomies.map(obj => mergedTags[obj.id]);
setTagsByTaxonomy(mergedTagsArray);
setOtherTaxonomies(Object.values(mergedOtherTaxonomies));
@@ -408,8 +410,8 @@ const useContentTagsDrawerContext = (contentId) => {
}
}
}, [
- fechedTaxonomies,
- fechedOtherTaxonomies,
+ fetchedTaxonomies,
+ fetchedOtherTaxonomies,
globalStagedContentTags,
globalStagedRemovedContentTags,
]);
diff --git a/src/content-tags-drawer/ContentTagsDrawerSheet.jsx b/src/content-tags-drawer/ContentTagsDrawerSheet.jsx
index c37b7581f3..f4661fa26c 100644
--- a/src/content-tags-drawer/ContentTagsDrawerSheet.jsx
+++ b/src/content-tags-drawer/ContentTagsDrawerSheet.jsx
@@ -12,6 +12,10 @@ const ContentTagsDrawerSheet = ({ id, onClose, showSheet }) => {
blockingSheet, setBlockingSheet,
}), [blockingSheet, setBlockingSheet]);
+ // ContentTagsDrawerSheet is only used when editing Courses/Course Units,
+ // so we assume it's ok to edit the object tags too.
+ const canTagObject = true;
+
return (
{
diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx
index bd0d911ed5..ab3f67d303 100644
--- a/src/library-authoring/LibraryAuthoringPage.test.tsx
+++ b/src/library-authoring/LibraryAuthoringPage.test.tsx
@@ -14,6 +14,7 @@ import mockEmptyResult from '../search-modal/__mocks__/empty-search-result.json'
import {
mockContentLibrary,
mockGetCollectionMetadata,
+ mockGetLibraryTeam,
mockXBlockFields,
} from './data/api.mocks';
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
@@ -24,6 +25,7 @@ import { getLibraryCollectionsApiUrl } from './data/api';
mockGetCollectionMetadata.applyMock();
mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
+mockGetLibraryTeam.applyMock();
mockXBlockFields.applyMock();
mockBroadcastChannel();
@@ -304,6 +306,24 @@ describe(' ', () => {
expect(screen.queryByText('(Never Published)')).not.toBeInTheDocument();
});
+ it('should show "Manage Access" button in Library Info that opens the Library Team modal', async () => {
+ await renderLibraryPage();
+ const manageAccess = screen.getByRole('button', { name: /manage access/i });
+
+ expect(manageAccess).not.toBeDisabled();
+ fireEvent.click(manageAccess);
+
+ expect(await screen.findByText('Library Team')).toBeInTheDocument();
+ });
+
+ it('should not show "Manage Access" button in Library Info to users who cannot edit the library', async () => {
+ const libraryId = mockContentLibrary.libraryIdReadOnly;
+ render( , { path, params: { libraryId } });
+
+ const manageAccess = screen.queryByRole('button', { name: /manage access/i });
+ expect(manageAccess).not.toBeInTheDocument();
+ });
+
it('show the "View All" button when viewing library with many components', async () => {
await renderLibraryPage();
diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx
index 78d60674ae..653e98cf11 100644
--- a/src/library-authoring/LibraryLayout.tsx
+++ b/src/library-authoring/LibraryLayout.tsx
@@ -7,6 +7,7 @@ import {
import LibraryAuthoringPage from './LibraryAuthoringPage';
import { LibraryProvider } from './common/context';
import { CreateCollectionModal } from './create-collection';
+import { LibraryTeamModal } from './library-team';
import LibraryCollectionPage from './collections/LibraryCollectionPage';
import { ComponentEditorModal } from './components/ComponentEditorModal';
@@ -32,6 +33,7 @@ const LibraryLayout = () => {
+
);
};
diff --git a/src/library-authoring/common/context.tsx b/src/library-authoring/common/context.tsx
index aea78f54f8..6e8fd3efd4 100644
--- a/src/library-authoring/common/context.tsx
+++ b/src/library-authoring/common/context.tsx
@@ -18,6 +18,10 @@ export interface LibraryContextData {
openInfoSidebar: () => void;
openComponentInfoSidebar: (usageKey: string) => void;
currentComponentUsageKey?: string;
+ // "Library Team" modal
+ isLibraryTeamModalOpen: boolean;
+ openLibraryTeamModal: () => void;
+ closeLibraryTeamModal: () => void;
// "Create New Collection" modal
isCreateCollectionModalOpen: boolean;
openCreateCollectionModal: () => void;
@@ -48,6 +52,7 @@ const LibraryContext = React.createContext(undef
export const LibraryProvider = (props: { children?: React.ReactNode, libraryId: string }) => {
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState(null);
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState();
+ const [isLibraryTeamModalOpen, openLibraryTeamModal, closeLibraryTeamModal] = useToggle(false);
const [currentCollectionId, setcurrentCollectionId] = React.useState();
const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false);
const [componentBeingEdited, openComponentEditor] = React.useState();
@@ -92,6 +97,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
+ isLibraryTeamModalOpen,
+ openLibraryTeamModal,
+ closeLibraryTeamModal,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
@@ -108,6 +116,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
+ isLibraryTeamModalOpen,
+ openLibraryTeamModal,
+ closeLibraryTeamModal,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
diff --git a/src/library-authoring/component-info/ComponentInfo.tsx b/src/library-authoring/component-info/ComponentInfo.tsx
index 19257b5de6..7a8404ae80 100644
--- a/src/library-authoring/component-info/ComponentInfo.tsx
+++ b/src/library-authoring/component-info/ComponentInfo.tsx
@@ -49,7 +49,7 @@ const ComponentInfo = ({ usageKey }: ComponentInfoProps) => {
-
+
diff --git a/src/library-authoring/component-info/ComponentManagement.test.tsx b/src/library-authoring/component-info/ComponentManagement.test.tsx
index 3ce44cc341..d0bc35caeb 100644
--- a/src/library-authoring/component-info/ComponentManagement.test.tsx
+++ b/src/library-authoring/component-info/ComponentManagement.test.tsx
@@ -11,7 +11,9 @@ import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.
import { LibraryProvider } from '../common/context';
jest.mock('../../content-tags-drawer', () => ({
- ContentTagsDrawer: () => Mocked ContentTagsDrawer
,
+ ContentTagsDrawer: ({ canTagObject }: { canTagObject: boolean }) => (
+ Mocked {canTagObject ? 'editable' : 'read-only'} ContentTagsDrawer
+ ),
}));
/*
@@ -56,15 +58,27 @@ describe(' ', () => {
).toBeInTheDocument();
});
- it('should render the tagging info', async () => {
- setConfig({
- ...getConfig(),
- ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
- });
- render( );
- expect(await screen.findByText('Tags (0)')).toBeInTheDocument();
- expect(screen.queryByText('Mocked ContentTagsDrawer')).toBeInTheDocument();
- });
+ test.each([
+ {
+ canEdit: true,
+ expected: 'editable',
+ },
+ {
+ canEdit: false,
+ expected: 'read-only',
+ },
+ ])(
+ 'should render the tagging info as $expected',
+ async ({ canEdit, expected }) => {
+ setConfig({
+ ...getConfig(),
+ ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
+ });
+ render( );
+ expect(await screen.findByText('Tags (0)')).toBeInTheDocument();
+ expect(screen.queryByText(`Mocked ${expected} ContentTagsDrawer`)).toBeInTheDocument();
+ },
+ );
it('should not render draft status', async () => {
setConfig({
diff --git a/src/library-authoring/component-info/ComponentManagement.tsx b/src/library-authoring/component-info/ComponentManagement.tsx
index ec04145975..eb339b2298 100644
--- a/src/library-authoring/component-info/ComponentManagement.tsx
+++ b/src/library-authoring/component-info/ComponentManagement.tsx
@@ -13,9 +13,10 @@ import ManageCollections from './ManageCollections';
interface ComponentManagementProps {
usageKey: string;
+ canEdit?: boolean;
}
-const ComponentManagement = ({ usageKey }: ComponentManagementProps) => {
+const ComponentManagement = ({ usageKey, canEdit = false }: ComponentManagementProps) => {
const intl = useIntl();
const { data: componentMetadata } = useLibraryBlockMetadata(usageKey);
const { data: componentTags } = useContentTaxonomyTagsData(usageKey);
@@ -65,6 +66,7 @@ const ComponentManagement = ({ usageKey }: ComponentManagementProps) => {
)}
diff --git a/src/library-authoring/data/api.mocks.ts b/src/library-authoring/data/api.mocks.ts
index 86c124afc6..e0429b108e 100644
--- a/src/library-authoring/data/api.mocks.ts
+++ b/src/library-authoring/data/api.mocks.ts
@@ -356,3 +356,44 @@ export async function mockXBlockAssets(): ReturnType jest.spyOn(api, 'getXBlockAssets').mockImplementation(mockXBlockAssets);
+
+/**
+ * Mock for `getLibraryTeam()`
+ *
+ * Use `mockGetLibraryTeam.applyMock()` to apply it to the whole test suite.
+ */
+export async function mockGetLibraryTeam(libraryId: string): Promise {
+ switch (libraryId) {
+ case mockContentLibrary.libraryIdThatNeverLoads:
+ // Return a promise that never resolves, to simulate never loading:
+ return new Promise(() => {});
+ default:
+ return [
+ mockGetLibraryTeam.adminMember,
+ mockGetLibraryTeam.authorMember,
+ mockGetLibraryTeam.readerMember,
+ ];
+ }
+}
+mockGetLibraryTeam.adminMember = {
+ username: 'admin-user',
+ email: 'admin@domain.tld',
+ accessLevel: 'admin' as api.LibraryAccessLevel,
+};
+mockGetLibraryTeam.authorMember = {
+ username: 'author-user',
+ email: 'author@domain.tld',
+ accessLevel: 'author' as api.LibraryAccessLevel,
+};
+mockGetLibraryTeam.readerMember = {
+ username: 'reader-user',
+ email: 'reader@domain.tld',
+ accessLevel: 'read' as api.LibraryAccessLevel,
+};
+mockGetLibraryTeam.notMember = {
+ username: 'not-user',
+ email: 'not@domain.tld',
+};
+
+/** Apply this mock. Returns a spy object that can tell you if it's been called. */
+mockGetLibraryTeam.applyMock = () => jest.spyOn(api, 'getLibraryTeam').mockImplementation(mockGetLibraryTeam);
diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts
index 528ccfa450..253df12e2d 100644
--- a/src/library-authoring/data/api.ts
+++ b/src/library-authoring/data/api.ts
@@ -13,6 +13,16 @@ export const getContentLibraryApiUrl = (libraryId: string) => `${getApiBaseUrl()
*/
export const getCreateLibraryBlockUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/blocks/`;
+/**
+ * Get the URL for the content library team API.
+ */
+export const getLibraryTeamApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/team/`;
+
+/**
+ * Get the URL for updating/deleting a content library team member.
+ */
+export const getLibraryTeamMemberApiUrl = (libraryId: string, username: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/team/user/${username}/`;
+
/**
* Get the URL for library block metadata.
*/
@@ -91,6 +101,29 @@ export interface ContentLibrary {
updated: string | null;
}
+export type LibraryAccessLevel = 'read' | 'author' | 'admin';
+
+export interface LibraryTeamMember {
+ username: string;
+ email: string;
+ accessLevel: LibraryAccessLevel,
+}
+
+export interface AddLibraryTeamMember {
+ libraryId: string,
+ email: string;
+ accessLevel: LibraryAccessLevel,
+}
+
+export interface DeleteLibraryTeamMember {
+ libraryId: string,
+ username: string;
+}
+
+export interface UpdateLibraryTeamMember extends DeleteLibraryTeamMember {
+ accessLevel: LibraryAccessLevel,
+}
+
export interface Collection {
id: number;
key: string;
@@ -265,6 +298,45 @@ export async function revertLibraryChanges(libraryId: string) {
await client.delete(getCommitLibraryChangesUrl(libraryId));
}
+/**
+ * Fetch content library's team by library ID.
+ */
+export async function getLibraryTeam(libraryId: string): Promise {
+ const client = getAuthenticatedHttpClient();
+ const { data } = await client.get(getLibraryTeamApiUrl(libraryId));
+ return camelCaseObject(data);
+}
+
+/**
+ * Add a new member to the library's team by email.
+ */
+export async function addLibraryTeamMember(memberData: AddLibraryTeamMember): Promise {
+ const client = getAuthenticatedHttpClient();
+ const url = getLibraryTeamApiUrl(memberData.libraryId);
+ const { data } = await client.post(url, snakeCaseObject(memberData));
+ return camelCaseObject(data);
+}
+
+/**
+ * Delete an existing member from the library's team by username.
+ */
+export async function deleteLibraryTeamMember(memberData: DeleteLibraryTeamMember): Promise {
+ const client = getAuthenticatedHttpClient();
+ const url = getLibraryTeamMemberApiUrl(memberData.libraryId, memberData.username);
+ const { data } = await client.delete(url);
+ return camelCaseObject(data);
+}
+
+/**
+ * Update an existing member's access to the library's team by username.
+ */
+export async function updateLibraryTeamMember(memberData: UpdateLibraryTeamMember): Promise {
+ const client = getAuthenticatedHttpClient();
+ const url = getLibraryTeamMemberApiUrl(memberData.libraryId, memberData.username);
+ const { data } = await client.put(url, snakeCaseObject(memberData));
+ return camelCaseObject(data);
+}
+
/**
* Paste clipboard content into library.
*/
diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts
index f405497efb..de43624855 100644
--- a/src/library-authoring/data/apiHooks.ts
+++ b/src/library-authoring/data/apiHooks.ts
@@ -19,6 +19,10 @@ import {
commitLibraryChanges,
revertLibraryChanges,
updateLibraryMetadata,
+ getLibraryTeam,
+ addLibraryTeamMember,
+ deleteLibraryTeamMember,
+ updateLibraryTeamMember,
libraryPasteClipboard,
getLibraryBlockMetadata,
getXBlockFields,
@@ -64,6 +68,11 @@ export const libraryAuthoringQueryKeys = {
'list',
...(customParams ? [customParams] : []),
],
+ libraryTeam: (libraryId?: string) => [
+ ...libraryAuthoringQueryKeys.all,
+ 'list',
+ libraryId,
+ ],
collection: (libraryId?: string, collectionId?: string) => [
...libraryAuthoringQueryKeys.all,
libraryId,
@@ -138,7 +147,7 @@ export const useUpdateLibraryMetadata = () => {
const newLibraryData = {
...previousLibraryData,
- title: data.title,
+ ...camelCaseObject(data),
};
queryClient.setQueryData(queryKey, newLibraryData);
@@ -189,6 +198,62 @@ export const useRevertLibraryChanges = () => {
});
};
+/**
+ * Hook to fetch a content library's team members
+ */
+export const useLibraryTeam = (libraryId: string | undefined) => (
+ useQuery({
+ queryKey: libraryAuthoringQueryKeys.libraryTeam(libraryId),
+ queryFn: () => getLibraryTeam(libraryId!),
+ enabled: libraryId !== undefined,
+ })
+);
+
+/**
+ * Hook to add a new member to a content library's team
+ */
+export const useAddLibraryTeamMember = (libraryId: string | undefined) => {
+ const queryClient = useQueryClient();
+ const queryKey = libraryAuthoringQueryKeys.libraryTeam(libraryId);
+
+ return useMutation({
+ mutationFn: addLibraryTeamMember,
+ onSettled: () => {
+ queryClient.invalidateQueries({ queryKey });
+ },
+ });
+};
+
+/**
+ * Hook to delete an existing member from a content library's team
+ */
+export const useDeleteLibraryTeamMember = (libraryId: string | undefined) => {
+ const queryClient = useQueryClient();
+ const queryKey = libraryAuthoringQueryKeys.libraryTeam(libraryId);
+
+ return useMutation({
+ mutationFn: deleteLibraryTeamMember,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey });
+ },
+ });
+};
+
+/**
+ * Hook to update an existing member's access in a content library's team
+ */
+export const useUpdateLibraryTeamMember = (libraryId: string | undefined) => {
+ const queryClient = useQueryClient();
+ const queryKey = libraryAuthoringQueryKeys.libraryTeam(libraryId);
+
+ return useMutation({
+ mutationFn: updateLibraryTeamMember,
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey });
+ },
+ });
+};
+
export const useLibraryPasteClipboard = () => {
const queryClient = useQueryClient();
return useMutation({
diff --git a/src/library-authoring/library-info/LibraryInfo.test.tsx b/src/library-authoring/library-info/LibraryInfo.test.tsx
index 09c8350e08..d7715107f8 100644
--- a/src/library-authoring/library-info/LibraryInfo.test.tsx
+++ b/src/library-authoring/library-info/LibraryInfo.test.tsx
@@ -12,6 +12,7 @@ import {
waitFor,
} from '@testing-library/react';
import LibraryInfo from './LibraryInfo';
+import { LibraryProvider } from '../common/context';
import { ToastProvider } from '../../generic/toast-context';
import { ContentLibrary, getCommitLibraryChangesUrl } from '../data/api';
import initializeStore from '../../store';
@@ -59,7 +60,9 @@ const RootWrapper = ({ data } : WrapperProps) => (
-
+
+
+
@@ -238,10 +241,27 @@ describe(' ', () => {
expect(discardButton).toBeDisabled();
});
- it('discard changes btn should be enabled for new libraries if components are added', async () => {
+ it('publish and discard changes btns should be enabled for new libraries if components are added', async () => {
render( );
+ const publishButton = screen.getByRole('button', { name: /publish/i });
const discardButton = screen.getByRole('button', { name: /discard changes/i });
+ expect(publishButton).not.toBeDisabled();
expect(discardButton).not.toBeDisabled();
});
+
+ it('publish and discard changes btns should be absent for users who cannot edit the library', async () => {
+ const data = {
+ ...libraryData,
+ lastPublished: null,
+ numBlocks: 2,
+ canEditLibrary: false,
+ };
+ render( );
+ const publishButton = screen.queryByRole('button', { name: /publish/i });
+ const discardButton = screen.queryByRole('button', { name: /discard changes/i });
+
+ expect(publishButton).not.toBeInTheDocument();
+ expect(discardButton).not.toBeInTheDocument();
+ });
});
diff --git a/src/library-authoring/library-info/LibraryInfo.tsx b/src/library-authoring/library-info/LibraryInfo.tsx
index e8e190a05d..a95b3b4b50 100644
--- a/src/library-authoring/library-info/LibraryInfo.tsx
+++ b/src/library-authoring/library-info/LibraryInfo.tsx
@@ -1,8 +1,9 @@
import React from 'react';
-import { Stack } from '@openedx/paragon';
+import { Button, Stack } from '@openedx/paragon';
import { FormattedDate, useIntl } from '@edx/frontend-platform/i18n';
import messages from './messages';
import LibraryPublishStatus from './LibraryPublishStatus';
+import { useLibraryContext } from '../common/context';
import { ContentLibrary } from '../data/api';
type LibraryInfoProps = {
@@ -11,6 +12,7 @@ type LibraryInfoProps = {
const LibraryInfo = ({ library } : LibraryInfoProps) => {
const intl = useIntl();
+ const { openLibraryTeamModal } = useLibraryContext();
return (
@@ -22,6 +24,11 @@ const LibraryInfo = ({ library } : LibraryInfoProps) => {
{library.org}
+ {library.canEditLibrary && (
+
+ {intl.formatMessage(messages.libraryTeamButtonTitle)}
+
+ )}
diff --git a/src/library-authoring/library-info/LibraryPublishStatus.tsx b/src/library-authoring/library-info/LibraryPublishStatus.tsx
index 0765b0e04f..468bf13883 100644
--- a/src/library-authoring/library-info/LibraryPublishStatus.tsx
+++ b/src/library-authoring/library-info/LibraryPublishStatus.tsx
@@ -38,8 +38,8 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => {
return (
);
};
diff --git a/src/library-authoring/library-info/messages.ts b/src/library-authoring/library-info/messages.ts
index 1be61a8ebd..59e5eaa673 100644
--- a/src/library-authoring/library-info/messages.ts
+++ b/src/library-authoring/library-info/messages.ts
@@ -11,6 +11,11 @@ const messages = defineMessages({
defaultMessage: 'Organization',
description: 'Title for Organization section in Library info sidebar.',
},
+ libraryTeamButtonTitle: {
+ id: 'course-authoring.library-authoring.sidebar.info.library-team.button.title',
+ defaultMessage: 'Manage Access',
+ description: 'Title to use for the button that allows viewing/editing the Library Team user access.',
+ },
libraryHistorySectionTitle: {
id: 'course-authoring.library-authoring.sidebar.info.history.title',
defaultMessage: 'Library History',
diff --git a/src/library-authoring/library-team/AddLibraryTeamMember.tsx b/src/library-authoring/library-team/AddLibraryTeamMember.tsx
new file mode 100644
index 0000000000..baabcd32a8
--- /dev/null
+++ b/src/library-authoring/library-team/AddLibraryTeamMember.tsx
@@ -0,0 +1,65 @@
+import { useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Button,
+ Form,
+ ActionRow,
+} from '@openedx/paragon';
+import { Formik } from 'formik';
+import * as Yup from 'yup';
+
+import messages from './messages';
+import FormikControl from '../../generic/FormikControl';
+import { EXAMPLE_USER_EMAIL } from './constants';
+
+const AddLibraryTeamMember = ({ onSubmit, onCancel }: {
+ onSubmit: ({ email } : { email: string }) => void,
+ onCancel: () => void,
+}) => {
+ const intl = useIntl();
+
+ return (
+
+
+ {({ handleSubmit, values }) => (
+
+ {intl.formatMessage(messages.addMemberFormTitle)}
+ {intl.formatMessage(messages.addMemberFormEmailLabel)}}
+ placeholder={intl.formatMessage(messages.addMemberFormEmailPlaceholder, { email: EXAMPLE_USER_EMAIL })}
+ />
+
+ {intl.formatMessage(messages.addMemberFormEmailHelperText)}
+
+
+
+
+ {intl.formatMessage(messages.cancelButton)}
+
+
+ {intl.formatMessage(messages.addMemberFormSubmitButton)}
+
+
+
+ )}
+
+
+ );
+};
+
+export default AddLibraryTeamMember;
diff --git a/src/library-authoring/library-team/LibraryTeam.test.tsx b/src/library-authoring/library-team/LibraryTeam.test.tsx
new file mode 100644
index 0000000000..51678eaf3e
--- /dev/null
+++ b/src/library-authoring/library-team/LibraryTeam.test.tsx
@@ -0,0 +1,215 @@
+import { initializeMockApp } from '@edx/frontend-platform';
+import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
+import MockAdapter from 'axios-mock-adapter';
+import userEvent from '@testing-library/user-event';
+import {
+ initializeMocks,
+ render,
+ screen,
+ waitFor,
+} from '../../testUtils';
+import { mockContentLibrary, mockGetLibraryTeam } from '../data/api.mocks';
+import {
+ getContentLibraryApiUrl,
+ getLibraryTeamApiUrl,
+ getLibraryTeamMemberApiUrl,
+} from '../data/api';
+import { LibraryProvider } from '../common/context';
+import LibraryTeam from './LibraryTeam';
+
+mockContentLibrary.applyMock();
+mockGetLibraryTeam.applyMock();
+
+describe(' ', () => {
+ beforeEach(() => {
+ initializeMocks();
+ });
+
+ const { libraryId } = mockContentLibrary;
+ const renderLibraryTeam = async () => {
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByRole('switch', { name: /Allow public read/i })).toBeInTheDocument();
+ });
+
+ expect(screen.getByText(mockGetLibraryTeam.adminMember.username)).toBeInTheDocument();
+ expect(screen.getByText(mockGetLibraryTeam.adminMember.email)).toBeInTheDocument();
+ expect(screen.getByText('Admin')).toBeInTheDocument();
+
+ expect(screen.getByText(mockGetLibraryTeam.authorMember.username)).toBeInTheDocument();
+ expect(screen.getByText(mockGetLibraryTeam.authorMember.email)).toBeInTheDocument();
+ expect(screen.getByText('Author')).toBeInTheDocument();
+
+ expect(screen.getByText(mockGetLibraryTeam.readerMember.username)).toBeInTheDocument();
+ expect(screen.getByText(mockGetLibraryTeam.readerMember.email)).toBeInTheDocument();
+ expect(screen.getByText('Read Only')).toBeInTheDocument();
+ };
+
+ it('shows a spinner while loading the Library Team data', async () => {
+ render(
+
+
+ ,
+ );
+
+ const spinner = screen.getByRole('status');
+ expect(spinner.textContent).toEqual('Loading...');
+ });
+
+ it('shows the library team in read-only mode to non-Admin, non-Staff users', async () => {
+ // Authenticate as a non-Staff user who is not on the library team
+ initializeMockApp({
+ authenticatedUser: {
+ administrator: false,
+ roles: [],
+ ...mockGetLibraryTeam.notMember,
+ },
+ });
+ await renderLibraryTeam();
+
+ // Current user is not a global admin or a Library Team Admin, so all edit buttons should be absent
+ expect(screen.queryByText('You!')).not.toBeInTheDocument();
+ expect(screen.getByRole('switch', { name: /Allow public read/i })).toBeDisabled();
+ expect(screen.queryByRole('button', { name: 'Make Admin' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Make Author' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Make Reader' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'New team member' })).not.toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: 'Delete team member' })).not.toBeInTheDocument();
+ });
+
+ test.each([
+ {
+ label: 'Library Team Admin',
+ user: {
+ administrator: false,
+ roles: [],
+ ...mockGetLibraryTeam.adminMember,
+ },
+ expectYou: true,
+ },
+ {
+ label: 'Global Staff',
+ user: {
+ administrator: true,
+ roles: [],
+ ...mockGetLibraryTeam.notMember,
+ },
+ expectYou: false,
+ },
+ ])(
+ 'allows $label users to edit library team members',
+ async ({ user: authenticatedUser, expectYou }) => {
+ initializeMockApp({ authenticatedUser });
+ await renderLibraryTeam();
+
+ const youLabel = screen.queryByText('You!');
+ if (expectYou) {
+ expect(youLabel).toBeInTheDocument();
+ } else {
+ expect(youLabel).not.toBeInTheDocument();
+ }
+
+ // Single Admin user cannot be demoted or deleted.
+ expect(screen.getByText('Promote another member to Admin to change this user\'s access rights.')).toBeInTheDocument();
+
+ // Author + Reader can be made Admin
+ expect(screen.getAllByRole('button', { name: 'Make Admin' }).length).toBe(2);
+ // Reader can be made Author
+ expect(screen.getByRole('button', { name: 'Make Author' })).toBeInTheDocument();
+ // Author can be made Reader
+ expect(screen.getByRole('button', { name: 'Make Reader' })).toBeInTheDocument();
+ // Author + Reader can be deleted
+ expect(screen.getAllByRole('button', { name: 'Delete team member' }).length).toBe(2);
+ },
+ );
+
+ it('allows library to be made "public read"', async () => {
+ const url = getContentLibraryApiUrl(libraryId);
+ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock.onPatch(url).reply(204);
+
+ await renderLibraryTeam();
+ const checkbox = screen.getByRole('switch', { name: /Allow public read/i });
+ userEvent.click(checkbox);
+
+ await waitFor(() => {
+ expect(axiosMock.history.patch.length).toEqual(1);
+ expect(axiosMock.history.patch[0].data).toBe(
+ `{"id":"${libraryId}","allow_public_read":true}`,
+ );
+ });
+ });
+
+ it('allows new library team members to be added', async () => {
+ const url = getLibraryTeamApiUrl(libraryId);
+ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock.onPost(url).reply(204, {});
+
+ await renderLibraryTeam();
+
+ let addButton = screen.getByRole('button', { name: 'New team member' });
+ userEvent.click(addButton);
+
+ const cancelButton = screen.getByRole('button', { name: /cancel/i });
+ userEvent.click(cancelButton);
+ await waitFor(() => {
+ expect(axiosMock.history.post.length).toEqual(0);
+ });
+
+ addButton = screen.getByRole('button', { name: 'New team member' });
+ userEvent.click(addButton);
+ const emailInput = screen.getByRole('textbox', { name: 'User\'s email address' });
+ userEvent.click(emailInput);
+ userEvent.type(emailInput, 'another@user.tld');
+
+ const saveButton = screen.getByRole('button', { name: /add member/i });
+ userEvent.click(saveButton);
+
+ await waitFor(() => {
+ expect(axiosMock.history.post.length).toEqual(1);
+ expect(axiosMock.history.post[0].data).toBe(
+ `{"library_id":"${libraryId}","email":"another@user.tld","access_level":"read"}`,
+ );
+ });
+ });
+
+ it('allows library team member roles to be changed', async () => {
+ const { username } = mockGetLibraryTeam.readerMember;
+ const url = getLibraryTeamMemberApiUrl(libraryId, username);
+ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock.onPut(url).reply(204, {});
+
+ await renderLibraryTeam();
+
+ const makeAuthor = screen.getByRole('button', { name: 'Make Author' });
+ userEvent.click(makeAuthor);
+
+ await waitFor(() => {
+ expect(axiosMock.history.put.length).toEqual(1);
+ expect(axiosMock.history.put[0].data).toBe(
+ `{"library_id":"${libraryId}","username":"${username}","access_level":"author"}`,
+ );
+ });
+ });
+
+ it('allows library team members to be deleted', async () => {
+ const { username } = mockGetLibraryTeam.authorMember;
+ const url = getLibraryTeamMemberApiUrl(libraryId, username);
+ const axiosMock = new MockAdapter(getAuthenticatedHttpClient());
+ axiosMock.onDelete(url).reply(204, {});
+
+ await renderLibraryTeam();
+
+ const deleteMember = screen.getAllByRole('button', { name: 'Delete team member' })[0];
+ userEvent.click(deleteMember);
+
+ await waitFor(() => {
+ expect(axiosMock.history.delete.length).toEqual(1);
+ });
+ });
+});
diff --git a/src/library-authoring/library-team/LibraryTeam.tsx b/src/library-authoring/library-team/LibraryTeam.tsx
new file mode 100644
index 0000000000..b41be5acf2
--- /dev/null
+++ b/src/library-authoring/library-team/LibraryTeam.tsx
@@ -0,0 +1,215 @@
+import React, { useCallback, useContext } from 'react';
+import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
+import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
+import {
+ Button,
+ Container,
+ Form,
+ useToggle,
+} from '@openedx/paragon';
+import { Add as IconAdd } from '@openedx/paragon/icons';
+
+import AlertError from '../../generic/alert-error';
+import Loading from '../../generic/Loading';
+import { ToastContext } from '../../generic/toast-context';
+import { useLibraryContext } from '../common/context';
+import { LibraryAccessLevel } from '../data/api';
+import {
+ useContentLibrary,
+ useLibraryTeam,
+ useAddLibraryTeamMember,
+ useDeleteLibraryTeamMember,
+ useUpdateLibraryTeamMember,
+ useUpdateLibraryMetadata,
+} from '../data/apiHooks';
+import LibraryTeamMember from './LibraryTeamMember';
+import AddLibraryTeamMember from './AddLibraryTeamMember';
+import { LibraryRole } from './constants';
+import messages from './messages';
+
+const LibraryTeam: React.FC> = () => {
+ const {
+ libraryId,
+ } = useLibraryContext();
+
+ const intl = useIntl();
+
+ const {
+ data: libraryData,
+ isLoading: isLibraryLoading,
+ } = useContentLibrary(libraryId);
+
+ const {
+ data: libraryTeamMembers,
+ isLoading: isTeamLoading,
+ isError,
+ error,
+ } = useLibraryTeam(libraryId);
+
+ const [
+ isAddLibraryTeamMemberOpen,
+ openAddLibraryTeamMember,
+ closeAddLibraryTeamMember,
+ ] = useToggle(false);
+
+ const { showToast } = useContext(ToastContext);
+
+ const addMember = useAddLibraryTeamMember(libraryId);
+ const onAddMember = useCallback(
+ (data: { email: string }) => {
+ const { email } = data;
+ addMember.mutateAsync({
+ libraryId,
+ email,
+ // New members are created as Readers
+ accessLevel: LibraryRole.Reader.toString() as LibraryAccessLevel,
+ }).then(() => {
+ showToast(intl.formatMessage(messages.addMemberSuccess));
+ }).catch(() => {
+ showToast(intl.formatMessage(messages.addMemberError));
+ });
+ closeAddLibraryTeamMember();
+ },
+ [libraryId, libraryTeamMembers],
+ );
+
+ const updateMember = useUpdateLibraryTeamMember(libraryId);
+ const onChangeRole = useCallback(
+ (username: string, role: LibraryRole) => {
+ updateMember.mutateAsync({
+ libraryId,
+ username,
+ accessLevel: role.toString() as LibraryAccessLevel,
+ }).then(() => {
+ showToast(intl.formatMessage(messages.updateMemberSuccess));
+ }).catch(() => {
+ showToast(intl.formatMessage(messages.updateMemberError));
+ });
+ },
+ [libraryId, libraryTeamMembers],
+ );
+
+ const deleteMember = useDeleteLibraryTeamMember(libraryId);
+ const onDeleteRole = useCallback(
+ (username: string) => {
+ deleteMember.mutateAsync({
+ libraryId,
+ username,
+ }).then(() => {
+ showToast(intl.formatMessage(messages.deleteMemberSuccess));
+ }).catch(() => {
+ showToast(intl.formatMessage(messages.deleteMemberError));
+ });
+ },
+ [libraryId, libraryTeamMembers],
+ );
+
+ const updateLibrary = useUpdateLibraryMetadata();
+ const onChangeAllowPublicRead = useCallback(
+ (event) => {
+ const allowPublicRead = event.target.checked;
+ if (libraryData && allowPublicRead !== libraryData.allowPublicRead) {
+ updateLibrary.mutateAsync({
+ id: libraryId,
+ allow_public_read: allowPublicRead,
+ }).then(() => {
+ showToast(intl.formatMessage(messages.updateLibrarySuccess));
+ }).catch(() => {
+ showToast(intl.formatMessage(messages.updateLibraryError));
+ });
+ }
+ },
+ [libraryData],
+ );
+
+ if (isLibraryLoading || isTeamLoading) {
+ return ;
+ }
+
+ const { email: currentUserEmail, administrator: isGlobalStaff } = getAuthenticatedUser();
+ const isLibraryAdmin = libraryTeamMembers ? (
+ libraryTeamMembers.filter(
+ ({ email, accessLevel }) => (
+ accessLevel === LibraryRole.Admin.toString() && email === currentUserEmail
+ ),
+ ).length === 1
+ ) : false;
+ const canChangeRoles = libraryData ? libraryData.canEditLibrary && (isLibraryAdmin || isGlobalStaff) : false;
+
+ // Is there only one Admin member in the Team? We'll prevent that user from being demoted/deleted.
+ const singleAdmin = libraryTeamMembers ? (
+ libraryTeamMembers.filter(
+ ({ accessLevel }) => accessLevel === LibraryRole.Admin.toString(),
+ ).length === 1
+ ) : false;
+
+ return (
+
+ {!isAddLibraryTeamMemberOpen && (
+
+
+
+
+
+
+
+
+ {intl.formatMessage(messages.allowPublicReadHelperText)}
+
+
+ {canChangeRoles && (
+
+
+
+
+
+ )}
+
+ )}
+ {canChangeRoles && isAddLibraryTeamMemberOpen && (
+
+ )}
+
+
+ {libraryTeamMembers && libraryTeamMembers.length ? (
+ libraryTeamMembers.map(({ username, accessLevel, email }) => (
+
+ ))
+ ) : }
+
+
+ {isError && }
+
+ );
+};
+
+export default LibraryTeam;
diff --git a/src/library-authoring/library-team/LibraryTeamMember.tsx b/src/library-authoring/library-team/LibraryTeamMember.tsx
new file mode 100644
index 0000000000..115d5f32b7
--- /dev/null
+++ b/src/library-authoring/library-team/LibraryTeamMember.tsx
@@ -0,0 +1,116 @@
+import React from 'react';
+import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
+import {
+ Badge,
+ Button,
+ Icon,
+ IconButtonWithTooltip,
+ MailtoLink,
+} from '@openedx/paragon';
+import { DeleteOutline } from '@openedx/paragon/icons';
+
+import messages from './messages';
+import {
+ LibraryRole,
+ ROLE_LABEL,
+ CHANGE_ROLE_LABEL,
+ ROLE_BADGE_VARIANT,
+ ROLE_BUTTON_VARIANT,
+} from './constants';
+
+const MemberBadge = ({
+ role,
+ isCurrentUser,
+}: {
+ role: LibraryRole,
+ isCurrentUser: boolean,
+}) => {
+ const roleMessage = ROLE_LABEL[role] ?? ROLE_LABEL[LibraryRole.Unknown];
+ const variant = ROLE_BADGE_VARIANT[role] ?? ROLE_BADGE_VARIANT[LibraryRole.Unknown];
+
+ return (
+
+ {roleMessage && }
+ {isCurrentUser && (
+
+
+
+ )}
+
+ );
+};
+
+const LibraryTeamMember = ({
+ username,
+ email,
+ accessLevel,
+ isCurrentUser,
+ isSingleAdmin,
+ canChangeRoles,
+ onChangeRole,
+ onDeleteRole,
+}: {
+ username: string,
+ email: string,
+ accessLevel: string,
+ canChangeRoles: boolean,
+ isCurrentUser: boolean,
+ isSingleAdmin: boolean,
+ onChangeRole: (username: string, role: LibraryRole) => void,
+ onDeleteRole: (username: string) => void,
+}) => {
+ const intl = useIntl();
+
+ const role: LibraryRole = Object.values(LibraryRole).find((value) => value === accessLevel) ?? LibraryRole.Unknown;
+ const availableRoles: LibraryRole[] = Object.values(LibraryRole).filter((value) => (
+ value !== accessLevel && value !== LibraryRole.Unknown
+ ));
+
+ // Don't allow the only Admin user to be demoted or deleted
+ const canChangeThisMember = canChangeRoles && !isSingleAdmin;
+
+ return (
+ // Share some styles from course-team for consistency
+
+
+
+ {username}
+ {email}
+
+ {canChangeThisMember ? (
+
+ {availableRoles && availableRoles.length && availableRoles.map((newRole) => (
+ onChangeRole(username, newRole)}
+ >
+ {intl.formatMessage(CHANGE_ROLE_LABEL[newRole])}
+
+ ))}
+
+ onDeleteRole(username)}
+ iconAs={Icon}
+ alt={intl.formatMessage(messages.deleteMember)}
+ title={intl.formatMessage(messages.deleteMember)}
+ />
+
+ ) : (
+ // We prevent the user from removing the last remaining Admin
+ // user so that someone can still administrate this Library,
+ // so show a message explaining why.
+ canChangeRoles && (
+
+
+
+ )
+ )}
+
+ );
+};
+
+export default LibraryTeamMember;
diff --git a/src/library-authoring/library-team/LibraryTeamModal.tsx b/src/library-authoring/library-team/LibraryTeamModal.tsx
new file mode 100644
index 0000000000..5b29eafdc1
--- /dev/null
+++ b/src/library-authoring/library-team/LibraryTeamModal.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+
+import { StandardModal } from '@openedx/paragon';
+import { useIntl } from '@edx/frontend-platform/i18n';
+
+import { useLibraryContext } from '../common/context';
+import LibraryTeam from './LibraryTeam';
+import messages from './messages';
+
+export const LibraryTeamModal: React.FC> = () => {
+ const intl = useIntl();
+ const {
+ isLibraryTeamModalOpen,
+ closeLibraryTeamModal,
+ } = useLibraryContext();
+
+ // Show Library Team modal in full screen
+ return (
+
+
+
+ );
+};
+
+export default LibraryTeamModal;
diff --git a/src/library-authoring/library-team/constants.ts b/src/library-authoring/library-team/constants.ts
new file mode 100644
index 0000000000..0a45fd54ce
--- /dev/null
+++ b/src/library-authoring/library-team/constants.ts
@@ -0,0 +1,42 @@
+import messages from './messages';
+
+// Enum values match the possible LibraryTeamMember accessLevel values
+export enum LibraryRole {
+ Admin = 'admin',
+ Author = 'author',
+ Reader = 'read',
+ Unknown = 'unknown',
+}
+
+export const ROLE_LABEL = {
+ [LibraryRole.Admin]: messages.roleAdmin,
+ [LibraryRole.Author]: messages.roleAuthor,
+ [LibraryRole.Reader]: messages.roleReader,
+ [LibraryRole.Unknown]: messages.roleUnknown,
+};
+
+export const CHANGE_ROLE_LABEL = {
+ [LibraryRole.Admin]: messages.makeMemberAdmin,
+ [LibraryRole.Author]: messages.makeMemberAuthor,
+ [LibraryRole.Reader]: messages.makeMemberReader,
+ [LibraryRole.Unknown]: { // Won't be used.
+ id: 'library-team-unknown-role',
+ defaultMessage: '',
+ },
+};
+
+export const ROLE_BADGE_VARIANT = {
+ [LibraryRole.Admin]: 'info',
+ [LibraryRole.Author]: 'dark',
+ [LibraryRole.Reader]: 'light',
+ [LibraryRole.Unknown]: 'danger',
+};
+
+export const ROLE_BUTTON_VARIANT = {
+ [LibraryRole.Admin]: 'primary',
+ [LibraryRole.Author]: 'secondary',
+ [LibraryRole.Reader]: 'tertiary',
+ [LibraryRole.Unknown]: 'danger',
+};
+
+export const EXAMPLE_USER_EMAIL = 'username@domain.com';
diff --git a/src/library-authoring/library-team/index.tsx b/src/library-authoring/library-team/index.tsx
new file mode 100644
index 0000000000..568c65321a
--- /dev/null
+++ b/src/library-authoring/library-team/index.tsx
@@ -0,0 +1,2 @@
+// eslint-disable-next-line import/prefer-default-export
+export { default as LibraryTeamModal } from './LibraryTeamModal';
diff --git a/src/library-authoring/library-team/messages.ts b/src/library-authoring/library-team/messages.ts
new file mode 100644
index 0000000000..6bf6a8c363
--- /dev/null
+++ b/src/library-authoring/library-team/messages.ts
@@ -0,0 +1,159 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ modalTitle: {
+ id: 'course-authoring.library-authoring.library-team.title',
+ defaultMessage: 'Library Team',
+ description: 'Title of the Library Team modal',
+ },
+ modalClose: {
+ id: 'course-authoring.library-authoring.library-team.close',
+ defaultMessage: 'Close',
+ description: 'Title of the Library Team modal close button',
+ },
+ noMembersFound: {
+ id: 'course-authoring.library-authoring.library-team.no-members',
+ defaultMessage: 'This library\'s team has no members yet.',
+ description: 'Text to show in the Library Team modal if no team members are found for this library.',
+ },
+ addTeamMemberButton: {
+ id: 'course-authoring.library-authoring.library-team.add-member',
+ defaultMessage: 'New team member',
+ descriptior: 'Title of the Library Team modal "Add member" button',
+ },
+ allowPublicReadLabel: {
+ id: 'course-authoring.library-authoring.library-team.allow-public-read',
+ defaultMessage: 'Allow public read',
+ descriptior: 'Title of the Library Team modal "Allow public read" on/off switch',
+ },
+ allowPublicReadHelperText: {
+ id: 'course-authoring.library-authoring.library-team.allow-public-read-helper',
+ defaultMessage: 'Allows anyone with Studio access to view this library and use its content in their courses.',
+ descriptior: 'Helper text for the Library Team modal "Allow public read" on/off switch',
+ },
+ addMemberFormTitle: {
+ id: 'course-authoring.library-authoring.add-member-form.title',
+ defaultMessage: 'Add a user to your library\'s team',
+ description: 'Title of the Library Team modal "Add member" form',
+ },
+ addMemberFormEmailLabel: {
+ id: 'course-authoring.library-authoring.add-member-form.email-label',
+ defaultMessage: 'User\'s email address',
+ description: 'Label for the email field in the Library Team modal "Add member" form',
+ },
+ addMemberFormEmailPlaceholder: {
+ id: 'course-authoring.library-authoring.add-member-form.email-placeholder',
+ defaultMessage: 'example: {email}',
+ description: 'Placeholder text for the email field in the Library Team modal "Add member" form',
+ },
+ addMemberFormEmailHelperText: {
+ id: 'course-authoring.library-authoring.add-member-form.email-helper-text',
+ defaultMessage: 'Provide the email address of the user you want to add',
+ description: 'Helper text for the email field in the Library Team modal "Add member" form',
+ },
+ addMemberFormSubmitButton: {
+ id: 'course-authoring.library-authoring.library-team.save-member',
+ defaultMessage: 'Add Member',
+ description: 'Title of the Submit button on the Library Team modal "Add member" form',
+ },
+ cancelButton: {
+ id: 'course-authoring.library-authoring.library-team.cancel',
+ defaultMessage: 'Cancel',
+ description: 'Title of the Cancel button on the Library Team modal "Add member" form',
+ },
+ deleteMember: {
+ id: 'course-authoring.library-authoring.library-team.delete-member',
+ defaultMessage: 'Delete team member',
+ description: 'Title of the Library Team modal "Delete member" button',
+ },
+ cannotChangeRoleSingleAdmin: {
+ id: 'course-authoring.library-authoring.library-team.cannot-changerole-single-admin',
+ defaultMessage: 'Promote another member to Admin to change this user\'s access rights.',
+ description: (
+ 'Shown to Library Admins if there\'s only one Admin in the Team,'
+ + ' explaining why this member cannot be changed yet.'
+ ),
+ },
+ makeMemberAdmin: {
+ id: 'course-authoring.library-authoring.library-team.make-member-admin',
+ defaultMessage: 'Make Admin',
+ description: 'Title of the Library Team modal button to give a member an Admin role',
+ },
+ makeMemberAuthor: {
+ id: 'course-authoring.library-authoring.library-team.make-member-author',
+ defaultMessage: 'Make Author',
+ description: 'Title of the Library Team modal button to give a member an Author role',
+ },
+ makeMemberReader: {
+ id: 'course-authoring.library-authoring.library-team.make-member-reader',
+ defaultMessage: 'Make Reader',
+ description: 'Title of the Library Team modal button to give a member an Read-Only role',
+ },
+ roleAdmin: {
+ id: 'course-authoring.library-authoring.library-team.admin-role',
+ defaultMessage: 'Admin',
+ description: 'Label to use for the "Administrator" Library role',
+ },
+ roleAuthor: {
+ id: 'course-authoring.library-authoring.library-team.author-role',
+ defaultMessage: 'Author',
+ description: 'Label to use for the "Author" Library role',
+ },
+ roleReader: {
+ id: 'course-authoring.library-authoring.library-team.read-only-role',
+ defaultMessage: 'Read Only',
+ description: 'Label to use for the "Read Only" Library role',
+ },
+ roleUnknown: {
+ id: 'course-authoring.library-authoring.library-team.unknown-role',
+ defaultMessage: 'Unknown',
+ description: 'Label to use for an unknown Library role',
+ },
+ roleYou: {
+ id: 'course-authoring.library-authoring.library-team.you-role',
+ defaultMessage: 'You!',
+ description: 'Label to use when labeling the current user\'s Library role',
+ },
+ addMemberSuccess: {
+ id: 'course-authoring.library-authoring.library-team.add-member-success',
+ defaultMessage: 'Team Member added',
+ description: 'Message shown when a Library Team member is successfully added',
+ },
+ addMemberError: {
+ id: 'course-authoring.library-authoring.library-team.add-member-error',
+ defaultMessage: 'Error adding Team Member',
+ description: 'Message shown when an error occurs while adding a Library Team member',
+ },
+ deleteMemberSuccess: {
+ id: 'course-authoring.library-authoring.library-team.delete-member-success',
+ defaultMessage: 'Team Member deleted',
+ description: 'Message shown when a Library Team member is successfully deleted',
+ },
+ deleteMemberError: {
+ id: 'course-authoring.library-authoring.library-team.delete-member-error',
+ defaultMessage: 'Error deleting Team Member',
+ description: 'Message shown when an error occurs while updating a Library Team member',
+ },
+ updateMemberSuccess: {
+ id: 'course-authoring.library-authoring.library-team.update-member-success',
+ defaultMessage: 'Team Member updated',
+ description: 'Message shown when a Library Team member is successfully updated',
+ },
+ updateMemberError: {
+ id: 'course-authoring.library-authoring.library-team.update-member-error',
+ defaultMessage: 'Error updating Team Member',
+ description: 'Message shown when an error occurs while updating a Library Team member',
+ },
+ updateLibrarySuccess: {
+ id: 'course-authoring.library-authoring.library-team.update-library-success',
+ defaultMessage: 'Library updated',
+ description: 'Message shown when a Library\'s metadata is successfully updated',
+ },
+ updateLibraryError: {
+ id: 'course-authoring.library-authoring.library-team.update-library-error',
+ defaultMessage: 'Error updating Library',
+ description: 'Message shown when an error occurs while updating a Library\'s metadata',
+ },
+});
+
+export default messages;