Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI to manage users/permissions for the content libraries [FC-0062] #1362

Merged
merged 10 commits into from
Oct 15, 2024
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 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 @@
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 @@
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 @@
commitLibraryChanges,
revertLibraryChanges,
updateLibraryMetadata,
getLibraryTeam,
addLibraryTeamMember,
deleteLibraryTeamMember,
updateLibraryTeamMember,
libraryPasteClipboard,
getLibraryBlockMetadata,
getXBlockFields,
Expand Down Expand Up @@ -62,6 +66,11 @@
'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 @@

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

queryClient.setQueryData(queryKey, newLibraryData);
Expand Down Expand Up @@ -187,6 +196,62 @@
});
};

/**
* 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
24 changes: 22 additions & 2 deletions 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 Expand Up @@ -238,10 +241,27 @@ describe('<LibraryInfo />', () => {
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(<RootWrapper data={{ ...libraryData, lastPublished: null, numBlocks: 2 }} />);
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(<RootWrapper data={data} />);
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();
});
});
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
4 changes: 2 additions & 2 deletions src/library-authoring/library-info/LibraryPublishStatus.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@ const LibraryPublishStatus = ({ library } : LibraryPublishStatusProps) => {
return (
<StatusWidget
{...library}
onCommit={commit}
onRevert={revert}
onCommit={library.canEditLibrary ? commit : undefined}
onRevert={library.canEditLibrary ? revert : undefined}
/>
);
};
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
Loading