Skip to content

Commit

Permalink
feat: Adds Library Team modal
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
pomegranited committed Oct 9, 2024
1 parent 5ec863e commit 06fff10
Show file tree
Hide file tree
Showing 14 changed files with 790 additions and 3 deletions.
2 changes: 2 additions & 0 deletions src/library-authoring/LibraryLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -32,6 +33,7 @@ const LibraryLayout = () => {
</Routes>
<CreateCollectionModal />
<ComponentEditorModal />
<LibraryTeamModal />
</LibraryProvider>
);
};
Expand Down
11 changes: 11 additions & 0 deletions src/library-authoring/common/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -48,6 +52,7 @@ const LibraryContext = React.createContext<LibraryContextData | undefined>(undef
export const LibraryProvider = (props: { children?: React.ReactNode, libraryId: string }) => {
const [sidebarBodyComponent, setSidebarBodyComponent] = React.useState<SidebarBodyComponentId | null>(null);
const [currentComponentUsageKey, setCurrentComponentUsageKey] = React.useState<string>();
const [isLibraryTeamModalOpen, openLibraryTeamModal, closeLibraryTeamModal] = useToggle(false);
const [currentCollectionId, setcurrentCollectionId] = React.useState<string>();
const [isCreateCollectionModalOpen, openCreateCollectionModal, closeCreateCollectionModal] = useToggle(false);
const [componentBeingEdited, openComponentEditor] = React.useState<string | undefined>();
Expand Down Expand Up @@ -93,6 +98,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
isLibraryTeamModalOpen,
openLibraryTeamModal,
closeLibraryTeamModal,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
Expand All @@ -109,6 +117,9 @@ export const LibraryProvider = (props: { children?: React.ReactNode, libraryId:
openInfoSidebar,
openComponentInfoSidebar,
currentComponentUsageKey,
isLibraryTeamModalOpen,
openLibraryTeamModal,
closeLibraryTeamModal,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
Expand Down
68 changes: 68 additions & 0 deletions src/library-authoring/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<LibraryTeamMember[]> {
const client = getAuthenticatedHttpClient();
const { data } = await client.get(getLibraryTeamApiUrl(libraryId));
return camelCaseObject(data);

Check warning on line 292 in src/library-authoring/data/api.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/api.ts#L290-L292

Added lines #L290 - L292 were not covered by tests
}

/**
* Add a new member to the library's team by email.
*/
export async function addLibraryTeamMember(memberData: AddLibraryTeamMember): Promise<LibraryTeamMember> {
const client = getAuthenticatedHttpClient();
const url = getLibraryTeamApiUrl(memberData.libraryId);
const { data } = await client.post(url, snakeCaseObject(memberData));
return camelCaseObject(data);

Check warning on line 302 in src/library-authoring/data/api.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/api.ts#L299-L302

Added lines #L299 - L302 were not covered by tests
}

/**
* Delete an existing member from the library's team by username.
*/
export async function deleteLibraryTeamMember(memberData: DeleteLibraryTeamMember): Promise<LibraryTeamMember> {
const client = getAuthenticatedHttpClient();
const url = getLibraryTeamMemberApiUrl(memberData.libraryId, memberData.username);
const { data } = await client.delete(url);
return camelCaseObject(data);

Check warning on line 312 in src/library-authoring/data/api.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/api.ts#L309-L312

Added lines #L309 - L312 were not covered by tests
}

/**
* Update an existing member's access to the library's team by username.
*/
export async function updateLibraryTeamMember(memberData: UpdateLibraryTeamMember): Promise<LibraryTeamMember> {
const client = getAuthenticatedHttpClient();
const url = getLibraryTeamMemberApiUrl(memberData.libraryId, memberData.username);
const { data } = await client.put(url, snakeCaseObject(memberData));
return camelCaseObject(data);

Check warning on line 322 in src/library-authoring/data/api.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/api.ts#L319-L322

Added lines #L319 - L322 were not covered by tests
}

/**
* Paste clipboard content into library.
*/
Expand Down
67 changes: 66 additions & 1 deletion src/library-authoring/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ import {
commitLibraryChanges,
revertLibraryChanges,
updateLibraryMetadata,
getLibraryTeam,
addLibraryTeamMember,
deleteLibraryTeamMember,
updateLibraryTeamMember,
libraryPasteClipboard,
getLibraryBlockMetadata,
getXBlockFields,
Expand Down Expand Up @@ -62,6 +66,11 @@ export const libraryAuthoringQueryKeys = {
'list',
...(customParams ? [customParams] : []),
],
libraryTeam: (libraryId?: string) => [

Check warning on line 69 in src/library-authoring/data/apiHooks.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/apiHooks.ts#L69

Added line #L69 was not covered by tests
...libraryAuthoringQueryKeys.all,
'list',
libraryId,
],
collection: (libraryId?: string, collectionId?: string) => [
...libraryAuthoringQueryKeys.all,
libraryId,
Expand Down Expand Up @@ -136,7 +145,7 @@ export const useUpdateLibraryMetadata = () => {

const newLibraryData = {
...previousLibraryData,
title: data.title,
...data,
};

queryClient.setQueryData(queryKey, newLibraryData);
Expand Down Expand Up @@ -187,6 +196,62 @@ export const useRevertLibraryChanges = () => {
});
};

/**
* Hook to fetch a content library's team members
*/
export const useLibraryTeam = (libraryId: string | undefined) => (
useQuery({

Check warning on line 203 in src/library-authoring/data/apiHooks.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/apiHooks.ts#L203

Added line #L203 was not covered by tests
queryKey: libraryAuthoringQueryKeys.libraryTeam(libraryId),
queryFn: () => getLibraryTeam(libraryId!),

Check warning on line 205 in src/library-authoring/data/apiHooks.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/apiHooks.ts#L205

Added line #L205 was not covered by tests
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);

Check warning on line 215 in src/library-authoring/data/apiHooks.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/apiHooks.ts#L214-L215

Added lines #L214 - L215 were not covered by tests

return useMutation({

Check warning on line 217 in src/library-authoring/data/apiHooks.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/apiHooks.ts#L217

Added line #L217 was not covered by tests
mutationFn: addLibraryTeamMember,
onSettled: () => {
queryClient.invalidateQueries({ queryKey });

Check warning on line 220 in src/library-authoring/data/apiHooks.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/apiHooks.ts#L219-L220

Added lines #L219 - L220 were not covered by tests
},
});
};

/**
* 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);

Check warning on line 230 in src/library-authoring/data/apiHooks.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/apiHooks.ts#L229-L230

Added lines #L229 - L230 were not covered by tests

return useMutation({

Check warning on line 232 in src/library-authoring/data/apiHooks.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/apiHooks.ts#L232

Added line #L232 was not covered by tests
mutationFn: deleteLibraryTeamMember,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });

Check warning on line 235 in src/library-authoring/data/apiHooks.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/apiHooks.ts#L234-L235

Added lines #L234 - L235 were not covered by tests
},
});
};

/**
* 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);

Check warning on line 245 in src/library-authoring/data/apiHooks.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/apiHooks.ts#L244-L245

Added lines #L244 - L245 were not covered by tests

return useMutation({

Check warning on line 247 in src/library-authoring/data/apiHooks.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/apiHooks.ts#L247

Added line #L247 was not covered by tests
mutationFn: updateLibraryTeamMember,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey });

Check warning on line 250 in src/library-authoring/data/apiHooks.ts

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/data/apiHooks.ts#L249-L250

Added lines #L249 - L250 were not covered by tests
},
});
};

export const useLibraryPasteClipboard = () => {
const queryClient = useQueryClient();
return useMutation({
Expand Down
5 changes: 4 additions & 1 deletion src/library-authoring/library-info/LibraryInfo.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,7 +60,9 @@ const RootWrapper = ({ data } : WrapperProps) => (
<IntlProvider locale="en" messages={{}}>
<QueryClientProvider client={queryClient}>
<ToastProvider>
<LibraryInfo library={data} />
<LibraryProvider libraryId={data.id}>
<LibraryInfo library={data} />
</LibraryProvider>
</ToastProvider>
</QueryClientProvider>
</IntlProvider>
Expand Down
9 changes: 8 additions & 1 deletion src/library-authoring/library-info/LibraryInfo.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -11,6 +12,7 @@ type LibraryInfoProps = {

const LibraryInfo = ({ library } : LibraryInfoProps) => {
const intl = useIntl();
const { openLibraryTeamModal } = useLibraryContext();

return (
<Stack direction="vertical" gap={2.5}>
Expand All @@ -22,6 +24,11 @@ const LibraryInfo = ({ library } : LibraryInfoProps) => {
<span>
{library.org}
</span>
{library.canEditLibrary && (
<Button variant="outline-primary" onClick={openLibraryTeamModal}>
{intl.formatMessage(messages.libraryTeamButtonTitle)}
</Button>
)}
</Stack>
<Stack gap={3}>
<span className="font-weight-bold">
Expand Down
5 changes: 5 additions & 0 deletions src/library-authoring/library-info/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
61 changes: 61 additions & 0 deletions src/library-authoring/library-team/AddLibraryTeamMember.tsx
Original file line number Diff line number Diff line change
@@ -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();

Check warning on line 17 in src/library-authoring/library-team/AddLibraryTeamMember.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/library-team/AddLibraryTeamMember.tsx#L16-L17

Added lines #L16 - L17 were not covered by tests

return (

Check warning on line 19 in src/library-authoring/library-team/AddLibraryTeamMember.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/library-team/AddLibraryTeamMember.tsx#L19

Added line #L19 was not covered by tests
<div className="add-user-form" data-testid="add-user-form">
<Formik
initialValues={{ email: '' }}
onSubmit={onSubmit}
validateOnBlur
>
{({ handleSubmit, values }) => (
<Form onSubmit={handleSubmit}>

Check warning on line 27 in src/library-authoring/library-team/AddLibraryTeamMember.tsx

View check run for this annotation

Codecov / codecov/patch

src/library-authoring/library-team/AddLibraryTeamMember.tsx#L27

Added line #L27 was not covered by tests
<Form.Group size="sm" className="form-field">
<h3 className="form-title">{intl.formatMessage(messages.addMemberFormTitle)}</h3>
<Form.Label size="sm" className="form-label font-weight-bold">
{intl.formatMessage(messages.addMemberFormEmailLabel)}
</Form.Label>
<FormikControl
name="email"
value={values.email}
placeholder={intl.formatMessage(messages.addMemberFormEmailPlaceholder, { email: EXAMPLE_USER_EMAIL })}
/>
<Form.Control.Feedback className="form-helper-text">
{intl.formatMessage(messages.addMemberFormEmailHelperText)}
</Form.Control.Feedback>
</Form.Group>
<ActionRow>
<Button variant="tertiary" size="sm" onClick={onCancel}>
{intl.formatMessage(messages.cancelButton)}
</Button>
<Button
size="sm"
type="submit"
disabled={!values.email.length}
>
{intl.formatMessage(messages.addMemberFormSubmitButton)}
</Button>
</ActionRow>
</Form>
)}
</Formik>
</div>
);
};

export default AddLibraryTeamMember;
Loading

0 comments on commit 06fff10

Please sign in to comment.