From bd2159312cb6e415102a24b712fcb9acd04a8aec Mon Sep 17 00:00:00 2001 From: Jillian Vogel Date: Fri, 4 Oct 2024 18:45:57 +0930 Subject: [PATCH] feat: initial version of the LibraryTeam modal --- src/library-authoring/LibraryLayout.tsx | 2 + src/library-authoring/common/context.tsx | 11 ++ src/library-authoring/data/api.ts | 19 ++++ src/library-authoring/data/apiHooks.ts | 17 +++ .../library-info/LibraryInfo.test.tsx | 5 +- .../library-info/LibraryInfo.tsx | 7 +- .../library-info/messages.ts | 5 + .../library-team/AddLibraryTeamMember.tsx | 28 +++++ .../library-team/LibraryTeam.tsx | 107 ++++++++++++++++++ .../library-team/LibraryTeamMember.tsx | 91 +++++++++++++++ .../library-team/LibraryTeamModal.tsx | 47 ++++++++ .../library-team/constants.ts | 22 ++++ .../library-team/context.tsx | 77 +++++++++++++ src/library-authoring/library-team/index.tsx | 2 + .../library-team/messages.ts | 85 ++++++++++++++ 15 files changed, 523 insertions(+), 2 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/context.tsx 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 225acddb87..36405b21fd 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -13,6 +13,11 @@ 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 library block metadata. */ @@ -78,6 +83,12 @@ export interface ContentLibrary { updated: string | null; } +export interface LibraryTeamMember { + username: string; + email: string; + groupName: string; +} + export interface Collection { id: number; key: string; @@ -246,6 +257,14 @@ export async function revertLibraryChanges(libraryId: string) { await client.delete(getCommitLibraryChangesUrl(libraryId)); } +/** + * Fetch a content library's team by its ID. + */ +export async function getLibraryTeam(libraryId: string): Promise { + const { data } = await getAuthenticatedHttpClient().get(getLibraryTeamApiUrl(libraryId)); + 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 27fad43023..921d863011 100644 --- a/src/library-authoring/data/apiHooks.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -19,6 +19,7 @@ import { commitLibraryChanges, revertLibraryChanges, updateLibraryMetadata, + getLibraryTeam, libraryPasteClipboard, getLibraryBlockMetadata, getXBlockFields, @@ -58,6 +59,11 @@ export const libraryAuthoringQueryKeys = { 'list', ...(customParams ? [customParams] : []), ], + libraryTeam: (libraryId?: string) => [ + ...libraryAuthoringQueryKeys.all, + 'list', + libraryId, + ], collection: (libraryId?: string, collectionId?: string) => [ ...libraryAuthoringQueryKeys.all, libraryId, @@ -181,6 +187,17 @@ 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, + }) +); + 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..7e5440fdb8 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..38aa1befce 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,9 @@ const LibraryInfo = ({ library } : LibraryInfoProps) => { {library.org} + 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..edea8ec4d6 --- /dev/null +++ b/src/library-authoring/library-team/AddLibraryTeamMember.tsx @@ -0,0 +1,28 @@ +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { + Button, +} from '@openedx/paragon'; + +import messages from './messages'; + +const AddLibraryTeamMember = ({ + onSave, + onCancel, +}: { + onSave: () => void, + onCancel: () => void, +}) => { + return ( +
+
Add form goes here
+ + +
+ ); +}; + +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..76cd483a98 --- /dev/null +++ b/src/library-authoring/library-team/LibraryTeam.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { FormattedMessage } from '@edx/frontend-platform/i18n'; +import { Button, Container } from '@openedx/paragon'; +import { Add as IconAdd } from '@openedx/paragon/icons'; + +import Loading from '../../generic/Loading'; +import AlertError from '../../generic/alert-error'; +import { useContentLibrary, useLibraryTeam } from '../data/apiHooks'; +import LibraryTeamMember from './LibraryTeamMember'; +import AddLibraryTeamMember from './AddLibraryTeamMember'; +import { LibraryRole } from './constants'; +import { LibraryTeamProvider, useLibraryTeamContext } from './context'; +import messages from './messages'; + +const _LibraryTeam = () => { + const { + libraryId, + isAddLibraryTeamMemberOpen, + openAddLibraryTeamMember, + closeAddLibraryTeamMember, + } = useLibraryTeamContext(); + + const { + data: libraryData, + isLoading: isLibraryLoading, + } = useContentLibrary(libraryId); + + const { + data: libraryTeamUsers, + isLoading: isTeamLoading, + isError, + error, + } = useLibraryTeam(libraryId); + + if (isLibraryLoading || isTeamLoading) { + return ; + } + + const canChangeRole = libraryData ? libraryData.canEditLibrary : false; + const changeRole = (email: string, role: LibraryRole) => { + alert(`Change ${email}'s role to ${role}`); + }; + const onAddMember = (email: string, role: LibraryRole) => { + alert(`Add ${email} ${role}`); + closeAddLibraryTeamMember(); + }; + const onDeleteRole = (email: string) => { + alert(`Delete ${email} role`); + }; + const onMakeAdmin = (email) => { + changeRole(email, LibraryRole.admin); + }; + const onMakeAuthor = (email) => { + changeRole(email, LibraryRole.author); + }; + const onMakeReader = (email) => { + changeRole(email, LibraryRole.read); + }; + + return ( + + {canChangeRole && ( + + )} +
+ {canChangeRole && isAddLibraryTeamMemberOpen && ( + + )} +
+ {libraryTeamUsers && libraryTeamUsers.length ? ( + libraryTeamUsers.map(({ username, accessLevel: role, email }) => ( + + )) + ) : } +
+
+ {isError && ()} +
+ ); +}; + +const LibraryTeam = ({ libraryId }: { libraryId: string }) => ( + + <_LibraryTeam /> + +); + +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..c608e5eb46 --- /dev/null +++ b/src/library-authoring/library-team/LibraryTeamMember.tsx @@ -0,0 +1,91 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; +import { + Badge, + Button, + Icon, + IconButtonWithTooltip, + MailtoLink, +} from '@openedx/paragon'; +import { DeleteOutline } from '@openedx/paragon/icons'; + +import messages from './messages'; +import { LibraryRole, ROLE_LABEL, ROLE_BADGE_VARIANT } from './constants'; +import { useLibraryTeamContext } from './context'; +import EditLibraryTeamMember from './EditLibraryTeamMember'; + +const LibraryTeamMember = ({ + userName, + email, + role, + onMakeAdmin, + onMakeAuthor, + onMakeReader, + onDeleteRole, +}: { + userName: string, + role: LibraryRole, + email: string, + onMakeAdmin?: (email: string) => void, + onMakeAuthor?: (email: string) => void, + onMakeReader?: (email: string) => void, + onDeleteRole?: (email: string) => void, +}) => { + const intl = useIntl(); + const isAdminRole = role === LibraryRole.admin.value; + const roleMessage = ROLE_LABEL[role]; + const badgeVariant = ROLE_BADGE_VARIANT[role] ?? ''; + const { email: currentUserEmail } = getAuthenticatedUser(); + + const { + editLibraryTeamMember, + isEditLibraryTeamMemberOpen, + openEditLibraryTeamMember, + closeEditLibraryTeamMember, + } = useLibraryTeamContext(); + + return ( + // Share some styles from course-team-member for consistency +
+
+ + {roleMessage && intl.formatMessage(roleMessage)} + {currentUserEmail === email && ( + {intl.formatMessage(messages.roleYou)} + )} + + {userName} + {email} +
+
+ {onMakeAdmin && ( + + )} + {onMakeAuthor && ( + + )} + {onMakeReader && ( + + )} + {onDeleteRole && ( + onDeleteRole(email)} + iconAs={Icon} + alt={intl.formatMessage(messages.deleteMember)} + data-testid="delete-button" + /> + )} +
+
+ ); +}; + +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..d9c5643b8e --- /dev/null +++ b/src/library-authoring/library-team/LibraryTeamModal.tsx @@ -0,0 +1,47 @@ +import React from 'react'; + +import { ActionRow, ModalDialog } 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 { + libraryId, + isLibraryTeamModalOpen, + closeLibraryTeamModal, + } = useLibraryContext(); + + // Show Library Team modal in full screen + return ( + + + + {intl.formatMessage(messages.modalTitle)} + + + + + + + + + {intl.formatMessage(messages.modalClose)} + + + + + ); +}; + +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..f431fba210 --- /dev/null +++ b/src/library-authoring/library-team/constants.ts @@ -0,0 +1,22 @@ +import messages from './messages'; + +export enum LibraryRole { + admin = 'admin', + author = 'author', + read = 'read', + unknown = 'unknown', +} + +export const ROLE_LABEL = { + admin: messages.roleAdmin, + author: messages.roleAuthor, + read: messages.roleReader, + unknown: messages.roleUnknown, +}; + +export const ROLE_BADGE_VARIANT = { + admin: 'info', + author: 'dark', + read: 'light', + unknown: 'warning', +}; diff --git a/src/library-authoring/library-team/context.tsx b/src/library-authoring/library-team/context.tsx new file mode 100644 index 0000000000..c7a1ac6bac --- /dev/null +++ b/src/library-authoring/library-team/context.tsx @@ -0,0 +1,77 @@ +import { useToggle } from '@openedx/paragon'; +import React from 'react'; + +export interface LibraryTeamContextData { + /** The ID of the current library */ + libraryId: string; + // Add Team Member + isAddLibraryTeamMemberOpen: boolean; + openAddLibraryTeamMember: () => void; + closeAddLibraryTeamMember: () => void; + // Edit Team Member (with email) + editLibraryTeamMember: string | null; + isEditLibraryTeamMemberOpen: boolean; + openEditLibraryTeamMember: (email: string) => void; + closeEditLibraryTeamMember: () => void; +} + +/** + * Library Team Context. + * + * Get this using `useLibraryTeamContext()` + * + */ +const LibraryTeamContext = React.createContext(undefined); + +/** + * React component to provide `LibraryTeamContext` + */ +export const LibraryTeamProvider = (props: { children?: React.ReactNode, libraryId: string }) => { + const [isAddLibraryTeamMemberOpen, openAddLibraryTeamMember, closeAddLibraryTeamMember] = useToggle(false); + const [editLibraryTeamMember, _setEditLibraryTeamMember] = React.useState(null); + const [isEditLibraryTeamMemberOpen, _openEditLibraryTeamMember, _closeEditLibraryTeamMember] = useToggle(false); + const openEditLibraryTeamMember = (email: string) => { + console.log("edit ", email); + _setEditLibraryTeamMember(email); + _openEditLibraryTeamMember(); + }; + const closeEditLibraryTeamMember = () => { + _setEditLibraryTeamMember(null); + _closeEditLibraryTeamMember(); + }; + + const context = React.useMemo(() => ({ + libraryId: props.libraryId, + isAddLibraryTeamMemberOpen, + openAddLibraryTeamMember, + closeAddLibraryTeamMember, + editLibraryTeamMember, + isEditLibraryTeamMemberOpen, + openEditLibraryTeamMember, + closeEditLibraryTeamMember, + }), [ + props.libraryId, + isAddLibraryTeamMemberOpen, + openAddLibraryTeamMember, + closeAddLibraryTeamMember, + editLibraryTeamMember, + isEditLibraryTeamMemberOpen, + openEditLibraryTeamMember, + closeEditLibraryTeamMember, + ]); + + return ( + + {props.children} + + ); +}; + +export function useLibraryTeamContext(): LibraryTeamContextData { + const ctx = React.useContext(LibraryTeamContext); + if (ctx === undefined) { + /* istanbul ignore next */ + throw new Error('useLibraryTeamContext() was used in a component without a ancestor.'); + } + return ctx; +} 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..15d123693a --- /dev/null +++ b/src/library-authoring/library-team/messages.ts @@ -0,0 +1,85 @@ +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.modals.library-team.title', + defaultMessage: 'Library Team', + description: 'Title of the Library Team modal', + }, + modalClose: { + id: 'course-authoring.library-authoring.modals.library-team.close', + defaultMessage: 'Close', + description: 'Title of the Library Team modal close button', + }, + noMembersFound: { + id: 'course-authoring.library-authoring.modals.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.', + }, + addNewMember: { + id: 'course-authoring.library-authoring.modals.library-team.add-member', + defaultMessage: 'New team member', + description: 'Title of the Library Team modal "Add member" button', + }, + saveMember: { + id: 'course-authoring.library-authoring.modals.library-team.save-member', + defaultMessage: 'Save', + description: 'Title of the Library Team modal "Save member" button', + }, + cancel: { + id: 'course-authoring.library-authoring.modals.library-team.cancel', + defaultMessage: 'Cancel', + description: 'Title of the Library Team modal "Cancel save member" button', + }, + deleteMember: { + id: 'course-authoring.library-authoring.modals.library-team.delete-member', + defaultMessage: 'Delete team member', + description: 'Title of the Library Team modal "Delete member" button', + }, + makeMemberAdmin: { + id: 'course-authoring.library-authoring.modals.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.modals.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.modals.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.modals.library-team.admin-role', + defaultMessage: 'Admin', + description: 'Label to use for the "Administrator" Library role', + }, + roleAuthor: { + id: 'course-authoring.library-authoring.modals.library-team.author-role', + defaultMessage: 'Author', + description: 'Label to use for the "Author" Library role', + }, + roleReader: { + id: 'course-authoring.library-authoring.modals.library-team.read-only-role', + defaultMessage: 'Read Only', + description: 'Label to use for the "Read Only" Library role', + }, + roleUnknown: { + id: 'course-authoring.library-authoring.modals.library-team.unknown-role', + defaultMessage: 'Unknown', + description: 'Label to use for an unknown Library role', + }, + roleYou: { + id: 'course-authoring.library-authoring.modals.library-team.you-role', + defaultMessage: 'You!', + description: 'Label to use when labeling the current user\'s Library role', + }, +}); + +export default messages;