From 5ec863ed41d9fcb0cbaa0b4db86b9e9879c73060 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Wed, 9 Oct 2024 19:39:02 +1030 Subject: [PATCH 1/8] fix: don't show Publish/Revert button unless user can edit the library --- .../library-info/LibraryInfo.test.tsx | 19 ++++++++++++++++++- .../library-info/LibraryPublishStatus.tsx | 4 ++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/library-authoring/library-info/LibraryInfo.test.tsx b/src/library-authoring/library-info/LibraryInfo.test.tsx index 09c8350e08..11cd2a58b2 100644 --- a/src/library-authoring/library-info/LibraryInfo.test.tsx +++ b/src/library-authoring/library-info/LibraryInfo.test.tsx @@ -238,10 +238,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/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 ( ); }; From 06fff106a5a948a364ed27f06fa318c8ed76a5a6 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Wed, 9 Oct 2024 21:43:10 +1030 Subject: [PATCH 2/8] feat: Adds Library Team modal which allows Library Admins to add Readers, Authors, and other Admin users to the list of people who can access a Library. * Readers can only see the library, but not its Team. * Authors can see the Library Team, but cannot alter it. * Admins can update the Library Team. Modal is triggered from a button on the LibraryInfo sidebar which is only accessible to users who can edit the library. --- src/library-authoring/LibraryLayout.tsx | 2 + src/library-authoring/common/context.tsx | 11 + src/library-authoring/data/api.ts | 68 ++++++ src/library-authoring/data/apiHooks.ts | 67 +++++- .../library-info/LibraryInfo.test.tsx | 5 +- .../library-info/LibraryInfo.tsx | 9 +- .../library-info/messages.ts | 5 + .../library-team/AddLibraryTeamMember.tsx | 61 +++++ .../library-team/LibraryTeam.tsx | 213 ++++++++++++++++++ .../library-team/LibraryTeamMember.tsx | 114 ++++++++++ .../library-team/LibraryTeamModal.tsx | 31 +++ .../library-team/constants.ts | 42 ++++ src/library-authoring/library-team/index.tsx | 2 + .../library-team/messages.ts | 163 ++++++++++++++ 14 files changed, 790 insertions(+), 3 deletions(-) create mode 100644 src/library-authoring/library-team/AddLibraryTeamMember.tsx create mode 100644 src/library-authoring/library-team/LibraryTeam.tsx create mode 100644 src/library-authoring/library-team/LibraryTeamMember.tsx create mode 100644 src/library-authoring/library-team/LibraryTeamModal.tsx create mode 100644 src/library-authoring/library-team/constants.ts create mode 100644 src/library-authoring/library-team/index.tsx create mode 100644 src/library-authoring/library-team/messages.ts 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 5c4b1938db..b84384b808 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(); @@ -93,6 +98,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId: openInfoSidebar, openComponentInfoSidebar, currentComponentUsageKey, + isLibraryTeamModalOpen, + openLibraryTeamModal, + closeLibraryTeamModal, isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal, @@ -109,6 +117,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId: openInfoSidebar, openComponentInfoSidebar, currentComponentUsageKey, + isLibraryTeamModalOpen, + openLibraryTeamModal, + closeLibraryTeamModal, isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal, diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 1c609722b9..85d6702fa2 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. */ @@ -86,6 +96,25 @@ export interface ContentLibrary { updated: string | null; } +export interface AddLibraryTeamMember { + libraryId: string, + email: string; + accessLevel: string; +} + +export interface LibraryTeamMember extends AddLibraryTeamMember { + username: string; +} + +export interface DeleteLibraryTeamMember { + libraryId: string, + username: string; +} + +export interface UpdateLibraryTeamMember extends DeleteLibraryTeamMember { + accessLevel: string; +} + export interface Collection { id: number; key: string; @@ -254,6 +283,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 42a1f53a34..404770e488 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, @@ -62,6 +66,11 @@ export const libraryAuthoringQueryKeys = { 'list', ...(customParams ? [customParams] : []), ], + libraryTeam: (libraryId?: string) => [ + ...libraryAuthoringQueryKeys.all, + 'list', + libraryId, + ], collection: (libraryId?: string, collectionId?: string) => [ ...libraryAuthoringQueryKeys.all, libraryId, @@ -136,7 +145,7 @@ export const useUpdateLibraryMetadata = () => { const newLibraryData = { ...previousLibraryData, - title: data.title, + ...data, }; queryClient.setQueryData(queryKey, newLibraryData); @@ -187,6 +196,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 11cd2a58b2..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) => ( - + + + 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 && ( + + )} 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..911df39cf4 --- /dev/null +++ b/src/library-authoring/library-team/AddLibraryTeamMember.tsx @@ -0,0 +1,61 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, + Form, + ActionRow, +} from '@openedx/paragon'; +import { Formik } from 'formik'; + +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)} + + + + {intl.formatMessage(messages.addMemberFormEmailHelperText)} + +
+ + + + +
+ )} +
+
+ ); +}; + +export default AddLibraryTeamMember; diff --git a/src/library-authoring/library-team/LibraryTeam.tsx b/src/library-authoring/library-team/LibraryTeam.tsx new file mode 100644 index 0000000000..e4ddc3131c --- /dev/null +++ b/src/library-authoring/library-team/LibraryTeam.tsx @@ -0,0 +1,213 @@ +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 { + 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(), + }).then(() => { + showToast(intl.formatMessage(messages.addMemberSuccess)); + }).catch(() => { + showToast(intl.formatMessage(messages.addMemberError)); + }); + closeAddLibraryTeamMember(); + }, + [libraryId, libraryTeamMembers, showToast, intl], + ); + + const updateMember = useUpdateLibraryTeamMember(libraryId); + const onChangeRole = useCallback( + (username: string, role: LibraryRole) => { + updateMember.mutateAsync({ + libraryId, + username, + accessLevel: role.toString(), + }).then(() => { + showToast(intl.formatMessage(messages.updateMemberSuccess)); + }).catch(() => { + showToast(intl.formatMessage(messages.updateMemberError)); + }); + }, + [libraryId, libraryTeamMembers, showToast, intl], + ); + + 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, showToast, intl], + ); + + 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, showToast, intl], + ); + + 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; + + 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..9113275475 --- /dev/null +++ b/src/library-authoring/library-team/LibraryTeamMember.tsx @@ -0,0 +1,114 @@ +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) => ( + + ))} + + onDeleteRole(username)} + iconAs={Icon} + alt={intl.formatMessage(messages.deleteMember)} + title={intl.formatMessage(messages.deleteMember)} + /> +
+ ) : ( + // Explain why this user cannot change the single Admin member + 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..983350628e --- /dev/null +++ b/src/library-authoring/library-team/messages.ts @@ -0,0 +1,163 @@ +import { defineMessages as _defineMessages } from '@edx/frontend-platform/i18n'; +import type { defineMessages as defineMessagesType } from 'react-intl'; + +// frontend-platform currently doesn't provide types... do it ourselves. +const defineMessages = _defineMessages as typeof defineMessagesType; + +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; From 7436e450437dc9e08900c37f44fb2e5896e558b6 Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Thu, 10 Oct 2024 13:52:48 +1030 Subject: [PATCH 3/8] fix: address PR review * camel-case the updated library data before storing it in the cache * add validation for the email on the Add Team Member form * remove showToast from the useCallback context (console errors) * key field only needs to be locally unique * updates comments on "don't demote/delete the single Admin member" --- src/library-authoring/data/apiHooks.ts | 2 +- .../library-team/AddLibraryTeamMember.tsx | 6 ++++++ src/library-authoring/library-team/LibraryTeam.tsx | 9 +++++---- src/library-authoring/library-team/LibraryTeamMember.tsx | 6 ++++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/library-authoring/data/apiHooks.ts b/src/library-authoring/data/apiHooks.ts index 404770e488..99bcd6d226 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -145,7 +145,7 @@ export const useUpdateLibraryMetadata = () => { const newLibraryData = { ...previousLibraryData, - ...data, + ...camelCaseObject(data), }; queryClient.setQueryData(queryKey, newLibraryData); diff --git a/src/library-authoring/library-team/AddLibraryTeamMember.tsx b/src/library-authoring/library-team/AddLibraryTeamMember.tsx index 911df39cf4..f068475338 100644 --- a/src/library-authoring/library-team/AddLibraryTeamMember.tsx +++ b/src/library-authoring/library-team/AddLibraryTeamMember.tsx @@ -5,6 +5,7 @@ import { ActionRow, } from '@openedx/paragon'; import { Formik } from 'formik'; +import * as Yup from 'yup'; import messages from './messages'; import FormikControl from '../../generic/FormikControl'; @@ -21,6 +22,11 @@ const AddLibraryTeamMember = ({ onSubmit, onCancel }: { {({ handleSubmit, values }) => ( diff --git a/src/library-authoring/library-team/LibraryTeam.tsx b/src/library-authoring/library-team/LibraryTeam.tsx index e4ddc3131c..9452271c21 100644 --- a/src/library-authoring/library-team/LibraryTeam.tsx +++ b/src/library-authoring/library-team/LibraryTeam.tsx @@ -69,7 +69,7 @@ const LibraryTeam: React.FC> = () => { }); closeAddLibraryTeamMember(); }, - [libraryId, libraryTeamMembers, showToast, intl], + [libraryId, libraryTeamMembers], ); const updateMember = useUpdateLibraryTeamMember(libraryId); @@ -85,7 +85,7 @@ const LibraryTeam: React.FC> = () => { showToast(intl.formatMessage(messages.updateMemberError)); }); }, - [libraryId, libraryTeamMembers, showToast, intl], + [libraryId, libraryTeamMembers], ); const deleteMember = useDeleteLibraryTeamMember(libraryId); @@ -100,7 +100,7 @@ const LibraryTeam: React.FC> = () => { showToast(intl.formatMessage(messages.deleteMemberError)); }); }, - [libraryId, libraryTeamMembers, showToast, intl], + [libraryId, libraryTeamMembers], ); const updateLibrary = useUpdateLibraryMetadata(); @@ -118,7 +118,7 @@ const LibraryTeam: React.FC> = () => { }); } }, - [libraryData, showToast, intl], + [libraryData], ); if (isLibraryLoading || isTeamLoading) { @@ -135,6 +135,7 @@ const LibraryTeam: React.FC> = () => { ) : 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(), diff --git a/src/library-authoring/library-team/LibraryTeamMember.tsx b/src/library-authoring/library-team/LibraryTeamMember.tsx index 9113275475..115d5f32b7 100644 --- a/src/library-authoring/library-team/LibraryTeamMember.tsx +++ b/src/library-authoring/library-team/LibraryTeamMember.tsx @@ -82,7 +82,7 @@ const LibraryTeamMember = ({ {availableRoles && availableRoles.length && availableRoles.map((newRole) => ( + {canTagObject && ( + + )}

)} @@ -418,7 +420,7 @@ const ContentTagsCollapsible = ({ )}
- {isEditMode && canTagObject && ( + {isEditMode && (