Skip to content

Commit 9fef270

Browse files
authored
feat(authz): [FC-0099] create toggle to make a library public read (#25)
* refactor: modify actions to add splitter * feat: add API function to update the library metadata * feat: create a toggle component to make the library public read * refactor: update behaviour depending on user permissions * feat: add success toast * refactor: use stack to display the actions * style: fix typo * style: add mx-5 to divider to avoid wide growth * chore: update paragon to work with useMediaQuery
1 parent 8dc3139 commit 9fef270

File tree

12 files changed

+388
-88
lines changed

12 files changed

+388
-88
lines changed

package-lock.json

Lines changed: 51 additions & 17 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
"@edx/frontend-platform": "^8.3.0",
4242
"@edx/openedx-atlas": "^0.7.0",
4343
"@openedx/frontend-plugin-framework": "^1.7.0",
44-
"@openedx/paragon": "^23.4.5",
44+
"@openedx/paragon": "^23.15.1",
4545
"@tanstack/react-query": "5.89.0",
4646
"lodash.debounce": "^4.0.8",
4747
"react": "^18.3.1",
Lines changed: 52 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
import { ComponentType, isValidElement, ReactNode } from 'react';
1+
import {
2+
ComponentType, isValidElement, ReactNode, Fragment,
3+
} from 'react';
24
import { Link } from 'react-router-dom';
35
import {
46
Breadcrumb, Col, Container, Row, Button, Badge,
7+
Stack,
8+
useMediaQuery,
9+
breakpoints,
510
} from '@openedx/paragon';
611

712
interface BreadcrumbLink {
@@ -23,46 +28,57 @@ export interface AuthZTitleProps {
2328
actions?: (Action | ReactNode)[];
2429
}
2530

31+
export const ActionButton = ({ label, icon, onClick }: Action) => (
32+
<Button
33+
iconBefore={icon}
34+
onClick={onClick}
35+
>
36+
{label}
37+
</Button>
38+
);
39+
2640
const AuthZTitle = ({
2741
activeLabel, navLinks = [], pageTitle, pageSubtitle, actions = [],
28-
}: AuthZTitleProps) => (
29-
<Container className="p-5 bg-light-100">
30-
<Breadcrumb
31-
linkAs={Link}
32-
links={navLinks}
33-
activeLabel={activeLabel}
34-
/>
35-
<Row className="mt-4">
36-
<Col xs={12} md={8} className="mb-4">
37-
<h1 className="text-primary">{pageTitle}</h1>
38-
{typeof pageSubtitle === 'string'
39-
? <h3><Badge className="py-2 px-3 font-weight-normal" variant="light">{pageSubtitle}</Badge></h3>
40-
: pageSubtitle}
41-
</Col>
42-
<Col xs={12} md={4}>
43-
<div className="d-flex justify-content-md-end">
44-
{
45-
actions.map((action) => {
46-
if (isValidElement(action)) {
47-
return action;
48-
}
49-
50-
const { label, icon, onClick } = action as Action;
42+
}: AuthZTitleProps) => {
43+
const isDesktop = useMediaQuery({ minWidth: breakpoints.large.minWidth });
44+
return (
45+
<Container className="p-5 bg-light-100">
46+
<Breadcrumb
47+
linkAs={Link}
48+
links={navLinks}
49+
activeLabel={activeLabel}
50+
/>
51+
<Row className="mt-4">
52+
<Col xs={12} md={7} className="mb-4">
53+
<h1 className="text-primary">{pageTitle}</h1>
54+
{typeof pageSubtitle === 'string'
55+
? <h3><Badge className="py-2 px-3 font-weight-normal" variant="light">{pageSubtitle}</Badge></h3>
56+
: pageSubtitle}
57+
</Col>
58+
<Col xs={12} md={5}>
59+
<Stack className="justify-content-end" direction={isDesktop ? 'horizontal' : 'vertical'}>
60+
{
61+
actions.map((action, index) => {
62+
const content = isValidElement(action)
63+
? action
64+
: <ActionButton {...action as Action} />;
65+
const key = isValidElement(action)
66+
? action.key
67+
: (action as Action).label;
5168
return (
52-
<Button
53-
key={`authz-header-action-${label}`}
54-
iconBefore={icon}
55-
onClick={onClick}
56-
>
57-
{label}
58-
</Button>
69+
<Fragment key={`authz-header-action-${key}`}>
70+
{content}
71+
{(index === actions.length - 1) ? null
72+
: (<hr className="mx-lg-5" />)}
73+
</Fragment>
5974
);
6075
})
6176
}
62-
</div>
63-
</Col>
64-
</Row>
65-
</Container>
66-
);
77+
</Stack>
78+
</Col>
79+
</Row>
80+
</Container>
81+
);
82+
};
6783

6884
export default AuthZTitle;

src/authz-module/data/api.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
22
import { LibraryMetadata, TeamMember } from '@src/types';
3-
import { camelCaseObject } from '@edx/frontend-platform';
3+
import { camelCaseObject, snakeCaseObject } from '@edx/frontend-platform';
44
import { getApiUrl, getStudioApiUrl } from '@src/data/utils';
55

66
export interface QuerySettings {
@@ -85,6 +85,7 @@ export const getLibrary = async (libraryId: string): Promise<LibraryMetadata> =>
8585
org: data.org,
8686
title: data.title,
8787
slug: data.slug,
88+
allowPublicRead: data.allow_public_read,
8889
};
8990
};
9091

@@ -107,3 +108,17 @@ export const revokeUserRoles = async (
107108
const res = await getAuthenticatedHttpClient().delete(url.toString());
108109
return camelCaseObject(res.data);
109110
};
111+
112+
export const updateLibrary = async (libraryId, updatedData): Promise<LibraryMetadata> => {
113+
const { data } = await getAuthenticatedHttpClient().patch(
114+
getStudioApiUrl(`/api/libraries/v2/${libraryId}/`),
115+
snakeCaseObject(updatedData),
116+
);
117+
return {
118+
id: data.id,
119+
org: data.org,
120+
title: data.title,
121+
slug: data.slug,
122+
allowPublicRead: data.allow_public_read,
123+
};
124+
};

src/authz-module/data/hooks.test.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
44
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
55
import {
66
useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, useRevokeUserRoles,
7+
useUpdateLibrary,
78
} from './hooks';
89

910
jest.mock('@edx/frontend-platform/auth', () => ({
@@ -340,3 +341,80 @@ describe('useRevokeUserRoles', () => {
340341
expect(calledUrl.searchParams.get('scope')).toBe(revokeRoleData.scope);
341342
});
342343
});
344+
345+
describe('useUpdateLibrary', () => {
346+
const queryKeyTest = ['org.openedx.frontend.app.adminConsole', 'authz', 'library', 'lib:123'];
347+
348+
beforeEach(() => {
349+
jest.clearAllMocks();
350+
});
351+
352+
it('calls updateLibrary with correct params and updates cache', async () => {
353+
const mockData = { id: 'lib:123', title: 'Library Test' };
354+
getAuthenticatedHttpClient.mockReturnValue({
355+
patch: jest.fn().mockResolvedValue({ data: mockData }),
356+
});
357+
const { result } = renderHook(() => useUpdateLibrary(), { wrapper: createWrapper() });
358+
359+
await act(async () => {
360+
await result.current.mutateAsync({
361+
libraryId: 'lib:123',
362+
updatedData: { title: 'Library Test' },
363+
});
364+
});
365+
366+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
367+
});
368+
369+
it('sets query data on success', async () => {
370+
const mockData = { id: 'lib:123', title: 'Updated Library' };
371+
getAuthenticatedHttpClient.mockReturnValue({
372+
patch: jest.fn().mockResolvedValue({ data: mockData }),
373+
});
374+
375+
const queryClient = new QueryClient();
376+
const wrapper = ({ children }: { children: ReactNode }) => (
377+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
378+
);
379+
380+
const { result } = renderHook(() => useUpdateLibrary(), { wrapper });
381+
382+
await act(async () => {
383+
await result.current.mutateAsync({
384+
libraryId: 'lib:123',
385+
updatedData: { title: 'Updated Library' },
386+
});
387+
});
388+
389+
// verify cache updated with the returned data
390+
expect(queryClient.getQueryData(queryKeyTest)).toEqual(mockData);
391+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
392+
});
393+
394+
it('invalidates query on settled', async () => {
395+
const mockData = { id: 'lib:123', title: 'Final Title' };
396+
getAuthenticatedHttpClient.mockReturnValue({
397+
patch: jest.fn().mockResolvedValue({ data: mockData }),
398+
});
399+
const queryClient = new QueryClient();
400+
const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries');
401+
402+
const wrapper = ({ children }: { children: ReactNode }) => (
403+
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
404+
);
405+
406+
const { result } = renderHook(() => useUpdateLibrary(), { wrapper });
407+
408+
await act(async () => {
409+
await result.current.mutateAsync({
410+
libraryId: 'lib:123',
411+
updatedData: { title: 'Final Title' },
412+
});
413+
});
414+
415+
expect(invalidateSpy).toHaveBeenCalledWith({
416+
queryKey: queryKeyTest,
417+
});
418+
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
419+
});
420+
});

0 commit comments

Comments
 (0)