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
This commit is contained in:
68
package-lock.json
generated
68
package-lock.json
generated
@@ -14,7 +14,7 @@
|
||||
"@edx/frontend-platform": "^8.3.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^23.4.5",
|
||||
"@openedx/paragon": "^23.15.1",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"react": "^18.3.1",
|
||||
@@ -6971,9 +6971,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/paragon": {
|
||||
"version": "23.14.2",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.14.2.tgz",
|
||||
"integrity": "sha512-mBsoH9nwt4VGkoE9y33BrSJsjTzWlKjooWGXeJng4LdFNnBy7bhtEvRENQ9/0L0/trWhEMZffAMP7h9HBfg5EQ==",
|
||||
"version": "23.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-23.15.1.tgz",
|
||||
"integrity": "sha512-uqbKE5pfLLdEaTltd27nyyV/enjOjPkkINES/LRBZXwRgGWhQh+vH2xA+iXigwvGGeWdzuxnJ0lXyfiUR/R7Ig==",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"example",
|
||||
@@ -6985,7 +6985,7 @@
|
||||
"dependencies": {
|
||||
"@popperjs/core": "^2.11.4",
|
||||
"@tokens-studio/sd-transforms": "^1.2.4",
|
||||
"axios": "^0.27.2",
|
||||
"axios": "^0.30.2",
|
||||
"bootstrap": "^4.6.2",
|
||||
"chalk": "^4.1.2",
|
||||
"child_process": "^1.0.2",
|
||||
@@ -6994,7 +6994,7 @@
|
||||
"cli-progress": "^3.12.0",
|
||||
"commander": "^9.4.1",
|
||||
"email-prop-type": "^3.0.0",
|
||||
"file-selector": "^0.6.0",
|
||||
"file-selector": "^0.10.0",
|
||||
"glob": "^8.0.3",
|
||||
"inquirer": "^8.2.5",
|
||||
"js-toml": "^1.0.0",
|
||||
@@ -7019,11 +7019,11 @@
|
||||
"react-loading-skeleton": "^3.1.0",
|
||||
"react-popper": "^2.2.5",
|
||||
"react-proptype-conditional-require": "^1.0.4",
|
||||
"react-responsive": "^8.2.0",
|
||||
"react-responsive": "^10.0.0",
|
||||
"react-table": "^7.7.0",
|
||||
"react-transition-group": "^4.4.2",
|
||||
"sass": "^1.58.3",
|
||||
"style-dictionary": "^4.3.2",
|
||||
"style-dictionary": "^4.4.0",
|
||||
"tabbable": "^5.3.3",
|
||||
"uncontrollable": "^7.2.1",
|
||||
"uuid": "^9.0.0"
|
||||
@@ -7038,13 +7038,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/paragon/node_modules/axios": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
|
||||
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
|
||||
"version": "0.30.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-0.30.2.tgz",
|
||||
"integrity": "sha512-0pE4RQ4UQi1jKY6p7u6i1Tkzqmu+d+/tHS7Q7rKunWLB9WyilBTpHHpXzPNMDj5hTbK0B0PTLSz07yqMBiF6xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.14.9",
|
||||
"form-data": "^4.0.0"
|
||||
"follow-redirects": "^1.15.4",
|
||||
"form-data": "^4.0.4",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/paragon/node_modules/brace-expansion": {
|
||||
@@ -7085,6 +7086,15 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/paragon/node_modules/matchmediaquery": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/matchmediaquery/-/matchmediaquery-0.4.2.tgz",
|
||||
"integrity": "sha512-wrZpoT50ehYOudhDjt/YvUJc6eUzcdFPdmbizfgvswCKNHD1/OBOHYJpHie+HXpu6bSkEGieFMYk6VuutaiRfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"css-mediaquery": "^0.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/paragon/node_modules/minimatch": {
|
||||
"version": "5.1.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
|
||||
@@ -7125,6 +7135,30 @@
|
||||
"postcss": "^8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/paragon/node_modules/react-responsive": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-10.0.1.tgz",
|
||||
"integrity": "sha512-OM5/cRvbtUWEX8le8RCT8scA8y2OPtb0Q/IViEyCEM5FBN8lRrkUOZnu87I88A6njxDldvxG+rLBxWiA7/UM9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hyphenate-style-name": "^1.0.0",
|
||||
"matchmediaquery": "^0.4.2",
|
||||
"prop-types": "^15.6.1",
|
||||
"shallow-equal": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@openedx/paragon/node_modules/shallow-equal": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/shallow-equal/-/shallow-equal-3.1.0.tgz",
|
||||
"integrity": "sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.2.2.tgz",
|
||||
@@ -14489,12 +14523,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.6.0.tgz",
|
||||
"integrity": "sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==",
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-0.10.0.tgz",
|
||||
"integrity": "sha512-iXLQxZTDe9qtBDkpaU4msOWNbh/4JxYSux7BsVxgt+0HBCpj9qPUFjD3SDBPLCJDoU3MsJh1i+CseQ/9488F/A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
"tslib": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"@edx/frontend-platform": "^8.3.0",
|
||||
"@edx/openedx-atlas": "^0.7.0",
|
||||
"@openedx/frontend-plugin-framework": "^1.7.0",
|
||||
"@openedx/paragon": "^23.4.5",
|
||||
"@openedx/paragon": "^23.15.1",
|
||||
"@tanstack/react-query": "5.89.0",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ComponentType, isValidElement, ReactNode } from 'react';
|
||||
import {
|
||||
ComponentType, isValidElement, ReactNode, Fragment,
|
||||
} from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Breadcrumb, Col, Container, Row, Button, Badge,
|
||||
Stack,
|
||||
useMediaQuery,
|
||||
breakpoints,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
interface BreadcrumbLink {
|
||||
@@ -23,46 +28,57 @@ export interface AuthZTitleProps {
|
||||
actions?: (Action | ReactNode)[];
|
||||
}
|
||||
|
||||
export const ActionButton = ({ label, icon, onClick }: Action) => (
|
||||
<Button
|
||||
iconBefore={icon}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const AuthZTitle = ({
|
||||
activeLabel, navLinks = [], pageTitle, pageSubtitle, actions = [],
|
||||
}: AuthZTitleProps) => (
|
||||
<Container className="p-5 bg-light-100">
|
||||
<Breadcrumb
|
||||
linkAs={Link}
|
||||
links={navLinks}
|
||||
activeLabel={activeLabel}
|
||||
/>
|
||||
<Row className="mt-4">
|
||||
<Col xs={12} md={8} className="mb-4">
|
||||
<h1 className="text-primary">{pageTitle}</h1>
|
||||
{typeof pageSubtitle === 'string'
|
||||
? <h3><Badge className="py-2 px-3 font-weight-normal" variant="light">{pageSubtitle}</Badge></h3>
|
||||
: pageSubtitle}
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<div className="d-flex justify-content-md-end">
|
||||
{
|
||||
actions.map((action) => {
|
||||
if (isValidElement(action)) {
|
||||
return action;
|
||||
}
|
||||
|
||||
const { label, icon, onClick } = action as Action;
|
||||
}: AuthZTitleProps) => {
|
||||
const isDesktop = useMediaQuery({ minWidth: breakpoints.large.minWidth });
|
||||
return (
|
||||
<Container className="p-5 bg-light-100">
|
||||
<Breadcrumb
|
||||
linkAs={Link}
|
||||
links={navLinks}
|
||||
activeLabel={activeLabel}
|
||||
/>
|
||||
<Row className="mt-4">
|
||||
<Col xs={12} md={7} className="mb-4">
|
||||
<h1 className="text-primary">{pageTitle}</h1>
|
||||
{typeof pageSubtitle === 'string'
|
||||
? <h3><Badge className="py-2 px-3 font-weight-normal" variant="light">{pageSubtitle}</Badge></h3>
|
||||
: pageSubtitle}
|
||||
</Col>
|
||||
<Col xs={12} md={5}>
|
||||
<Stack className="justify-content-end" direction={isDesktop ? 'horizontal' : 'vertical'}>
|
||||
{
|
||||
actions.map((action, index) => {
|
||||
const content = isValidElement(action)
|
||||
? action
|
||||
: <ActionButton {...action as Action} />;
|
||||
const key = isValidElement(action)
|
||||
? action.key
|
||||
: (action as Action).label;
|
||||
return (
|
||||
<Button
|
||||
key={`authz-header-action-${label}`}
|
||||
iconBefore={icon}
|
||||
onClick={onClick}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
<Fragment key={`authz-header-action-${key}`}>
|
||||
{content}
|
||||
{(index === actions.length - 1) ? null
|
||||
: (<hr className="mx-lg-5" />)}
|
||||
</Fragment>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
</Stack>
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthZTitle;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { LibraryMetadata, TeamMember } from '@src/types';
|
||||
import { camelCaseObject } from '@edx/frontend-platform';
|
||||
import { camelCaseObject, snakeCaseObject } from '@edx/frontend-platform';
|
||||
import { getApiUrl, getStudioApiUrl } from '@src/data/utils';
|
||||
|
||||
export interface QuerySettings {
|
||||
@@ -85,6 +85,7 @@ export const getLibrary = async (libraryId: string): Promise<LibraryMetadata> =>
|
||||
org: data.org,
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
allowPublicRead: data.allow_public_read,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -107,3 +108,17 @@ export const revokeUserRoles = async (
|
||||
const res = await getAuthenticatedHttpClient().delete(url.toString());
|
||||
return camelCaseObject(res.data);
|
||||
};
|
||||
|
||||
export const updateLibrary = async (libraryId, updatedData): Promise<LibraryMetadata> => {
|
||||
const { data } = await getAuthenticatedHttpClient().patch(
|
||||
getStudioApiUrl(`/api/libraries/v2/${libraryId}/`),
|
||||
snakeCaseObject(updatedData),
|
||||
);
|
||||
return {
|
||||
id: data.id,
|
||||
org: data.org,
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
allowPublicRead: data.allow_public_read,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import {
|
||||
useLibrary, usePermissionsByRole, useTeamMembers, useAssignTeamMembersRole, useRevokeUserRoles,
|
||||
useUpdateLibrary,
|
||||
} from './hooks';
|
||||
|
||||
jest.mock('@edx/frontend-platform/auth', () => ({
|
||||
@@ -340,3 +341,80 @@ describe('useRevokeUserRoles', () => {
|
||||
expect(calledUrl.searchParams.get('scope')).toBe(revokeRoleData.scope);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateLibrary', () => {
|
||||
const queryKeyTest = ['org.openedx.frontend.app.adminConsole', 'authz', 'library', 'lib:123'];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('calls updateLibrary with correct params and updates cache', async () => {
|
||||
const mockData = { id: 'lib:123', title: 'Library Test' };
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
patch: jest.fn().mockResolvedValue({ data: mockData }),
|
||||
});
|
||||
const { result } = renderHook(() => useUpdateLibrary(), { wrapper: createWrapper() });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
libraryId: 'lib:123',
|
||||
updatedData: { title: 'Library Test' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sets query data on success', async () => {
|
||||
const mockData = { id: 'lib:123', title: 'Updated Library' };
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
patch: jest.fn().mockResolvedValue({ data: mockData }),
|
||||
});
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useUpdateLibrary(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
libraryId: 'lib:123',
|
||||
updatedData: { title: 'Updated Library' },
|
||||
});
|
||||
});
|
||||
|
||||
// verify cache updated with the returned data
|
||||
expect(queryClient.getQueryData(queryKeyTest)).toEqual(mockData);
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('invalidates query on settled', async () => {
|
||||
const mockData = { id: 'lib:123', title: 'Final Title' };
|
||||
getAuthenticatedHttpClient.mockReturnValue({
|
||||
patch: jest.fn().mockResolvedValue({ data: mockData }),
|
||||
});
|
||||
const queryClient = new QueryClient();
|
||||
const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries');
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useUpdateLibrary(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
libraryId: 'lib:123',
|
||||
updatedData: { title: 'Final Title' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(invalidateSpy).toHaveBeenCalledWith({
|
||||
queryKey: queryKeyTest,
|
||||
});
|
||||
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { LibraryMetadata } from '@src/types';
|
||||
import {
|
||||
assignTeamMembersRole, AssignTeamMembersRoleRequest, getLibrary, getPermissionsByRole, getTeamMembers,
|
||||
GetTeamMembersResponse, PermissionsByRole, QuerySettings, revokeUserRoles, RevokeUserRolesRequest,
|
||||
updateLibrary,
|
||||
} from './api';
|
||||
|
||||
const authzQueryKeys = {
|
||||
@@ -106,3 +107,28 @@ export const useRevokeUserRoles = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* React Query hook to update the library metadata.
|
||||
*
|
||||
* @example
|
||||
* const { mutate: updateLibrary } = useUpdateLibrary();
|
||||
* updateLibrary({ libraryId: 'lib:123', updatedData: { title: 'Library Test' }});
|
||||
*/
|
||||
|
||||
export const useUpdateLibrary = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ libraryId, updatedData }: {
|
||||
libraryId: string;
|
||||
updatedData: Partial<LibraryMetadata>
|
||||
}) => updateLibrary(libraryId, updatedData),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(authzQueryKeys.library(data.id), data);
|
||||
},
|
||||
onSettled: (_data, _error, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: authzQueryKeys.library(variables.libraryId) });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
@use "@openedx/paragon/styles/css/core/custom-media-breakpoints" as paragonCustomMediaBreakpoints;
|
||||
|
||||
.authz-libraries {
|
||||
--height-action-divider: 30px;
|
||||
|
||||
.pgn__breadcrumb li:first-child a {
|
||||
color: var(--pgn-color-breadcrumb-active);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top: var(--pgn-size-border-width) solid var(--pgn-color-border);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (--pgn-size-breakpoint-min-width-lg) {
|
||||
hr {
|
||||
border-right: var(--pgn-size-border-width) solid var(--pgn-color-border);
|
||||
height: var(--height-action-divider);
|
||||
width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
background-color: var(--pgn-color-light-200);
|
||||
}
|
||||
@@ -48,4 +65,4 @@
|
||||
// Move toast to the right
|
||||
left: auto;
|
||||
right: var(--pgn-spacing-toast-container-gutter-lg);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import { screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWrapper } from '@src/setupTest';
|
||||
import { initializeMockApp } from '@edx/frontend-platform/testing';
|
||||
import { useLibrary } from '@src/authz-module/data/hooks';
|
||||
import { useLibrary, useUpdateLibrary } from '@src/authz-module/data/hooks';
|
||||
import { useLibraryAuthZ } from './context';
|
||||
import LibrariesTeamManager from './LibrariesTeamManager';
|
||||
import { ToastManagerProvider } from './ToastManagerContext';
|
||||
|
||||
jest.mock('./context', () => {
|
||||
const actual = jest.requireActual('./context');
|
||||
@@ -18,6 +19,7 @@ const mockedUseLibraryAuthZ = useLibraryAuthZ as jest.Mock;
|
||||
|
||||
jest.mock('@src/authz-module/data/hooks', () => ({
|
||||
useLibrary: jest.fn(),
|
||||
useUpdateLibrary: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./components/TeamTable', () => ({
|
||||
@@ -45,45 +47,56 @@ jest.mock('../components/RoleCard', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const renderTeamManager = () => renderWrapper(<ToastManagerProvider><LibrariesTeamManager /></ToastManagerProvider>);
|
||||
describe('LibrariesTeamManager', () => {
|
||||
const libraryData = {
|
||||
id: 'lib-001',
|
||||
title: 'Test Library',
|
||||
org: 'Test Org',
|
||||
allowPublicRead: false,
|
||||
};
|
||||
const mutate = jest.fn();
|
||||
const libraryAuthZContext = {
|
||||
libraryId: libraryData.id,
|
||||
libraryName: libraryData.title,
|
||||
libraryOrg: libraryData.org,
|
||||
username: 'mockuser',
|
||||
roles: [
|
||||
{
|
||||
name: 'Instructor',
|
||||
description: 'Can manage content.',
|
||||
userCount: 3,
|
||||
permissions: ['view', 'edit'],
|
||||
},
|
||||
],
|
||||
permissions: [
|
||||
{ key: 'view_library', label: 'view', resource: 'library' },
|
||||
{ key: 'edit_library', label: 'edit', resource: 'library' },
|
||||
],
|
||||
resources: [{ key: 'library', label: 'Library' }],
|
||||
canManageTeam: true,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
username: 'admin',
|
||||
},
|
||||
});
|
||||
mockedUseLibraryAuthZ.mockReturnValue({
|
||||
libraryId: 'lib-001',
|
||||
libraryName: 'Mock Library',
|
||||
libraryOrg: 'MockOrg',
|
||||
username: 'mockuser',
|
||||
roles: [
|
||||
{
|
||||
name: 'Instructor',
|
||||
description: 'Can manage content.',
|
||||
userCount: 3,
|
||||
permissions: ['view', 'edit'],
|
||||
},
|
||||
],
|
||||
permissions: [
|
||||
{ key: 'view_library', label: 'view', resource: 'library' },
|
||||
{ key: 'edit_library', label: 'edit', resource: 'library' },
|
||||
],
|
||||
resources: [{ key: 'library', label: 'Library' }],
|
||||
canManageTeam: true,
|
||||
});
|
||||
jest.resetAllMocks();
|
||||
mockedUseLibraryAuthZ.mockReturnValue(libraryAuthZContext);
|
||||
|
||||
(useLibrary as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
title: 'Test Library',
|
||||
org: 'Test Org',
|
||||
},
|
||||
data: libraryData,
|
||||
});
|
||||
(useUpdateLibrary as jest.Mock).mockReturnValue({
|
||||
mutate,
|
||||
isPending: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders tabs and layout content correctly', () => {
|
||||
renderWrapper(<LibrariesTeamManager />);
|
||||
|
||||
renderTeamManager();
|
||||
// Tabs
|
||||
expect(screen.getByRole('tab', { name: /Team Members/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /Roles/i })).toBeInTheDocument();
|
||||
@@ -103,7 +116,7 @@ describe('LibrariesTeamManager', () => {
|
||||
it('renders role cards when "Roles" tab is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWrapper(<LibrariesTeamManager />);
|
||||
renderTeamManager();
|
||||
|
||||
// Click on "Roles" tab
|
||||
const rolesTab = await screen.findByRole('tab', { name: /roles/i });
|
||||
@@ -120,7 +133,7 @@ describe('LibrariesTeamManager', () => {
|
||||
it('renders role matrix when "Permissions" tab is selected', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderWrapper(<LibrariesTeamManager />);
|
||||
renderTeamManager();
|
||||
|
||||
// Click on "Permissions" tab
|
||||
const permissionsTab = await screen.findByRole('tab', { name: /permissions/i });
|
||||
@@ -134,4 +147,43 @@ describe('LibrariesTeamManager', () => {
|
||||
expect(matrixScope.getByText('edit')).toBeInTheDocument();
|
||||
expect(matrixScope.getByText('view')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders allow public library read toggle and change the value by user interaction', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
renderTeamManager();
|
||||
|
||||
const readPublicToggle = await screen.findByRole('switch', { name: /Allow public read/i });
|
||||
|
||||
await user.click(readPublicToggle);
|
||||
expect(mutate).toHaveBeenCalledWith(
|
||||
{
|
||||
libraryId: 'lib-001',
|
||||
updatedData: { allowPublicRead: !libraryData.allowPublicRead },
|
||||
},
|
||||
expect.objectContaining({
|
||||
onSuccess: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not render the toggle if the user can not manage team and the Library Public Read is disabled', () => {
|
||||
(useLibrary as jest.Mock).mockReturnValue({ data: { ...libraryData, allowPublicRead: false } });
|
||||
(useLibraryAuthZ as jest.Mock).mockReturnValue({ ...libraryAuthZContext, canManageTeam: false });
|
||||
|
||||
renderTeamManager();
|
||||
expect(screen.queryByRole('switch', { name: /Allow public read/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the toggle as disabled if the user can not manage team but the Library Public Read is enabled', async () => {
|
||||
(useLibrary as jest.Mock).mockReturnValue({ data: { ...libraryData, allowPublicRead: true } });
|
||||
(useLibraryAuthZ as jest.Mock).mockReturnValue({ ...libraryAuthZContext, canManageTeam: false });
|
||||
|
||||
renderTeamManager();
|
||||
|
||||
const readPublicToggle = await screen.findByRole('switch', { name: /Allow public read/i });
|
||||
|
||||
expect(readPublicToggle).toBeInTheDocument();
|
||||
expect(readPublicToggle).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { AddNewTeamMemberTrigger } from './components/AddNewTeamMemberModal';
|
||||
import { buildPermissionMatrixByResource, buildPermissionMatrixByRole } from './utils';
|
||||
|
||||
import messages from './messages';
|
||||
import PublicReadToggle from './components/PublicReadToggle';
|
||||
|
||||
const LibrariesTeamManager = () => {
|
||||
const intl = useIntl();
|
||||
@@ -46,9 +47,9 @@ const LibrariesTeamManager = () => {
|
||||
pageTitle={pageTitle}
|
||||
pageSubtitle={libraryId}
|
||||
actions={
|
||||
canManageTeam
|
||||
? [<AddNewTeamMemberTrigger libraryId={libraryId} />]
|
||||
: []
|
||||
[<PublicReadToggle libraryId={libraryId} canEditToggle={canManageTeam} key="allow-public-read" />,
|
||||
...(canManageTeam ? [<AddNewTeamMemberTrigger libraryId={libraryId} key="add-new-member" />] : []),
|
||||
]
|
||||
}
|
||||
>
|
||||
<Tabs
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Form } from '@openedx/paragon';
|
||||
import { useLibrary, useUpdateLibrary } from '@src/authz-module/data/hooks';
|
||||
import { useToastManager } from '../ToastManagerContext';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
type PublicReadToggleProps = {
|
||||
libraryId: string;
|
||||
canEditToggle: boolean;
|
||||
};
|
||||
|
||||
const PublicReadToggle = ({ libraryId, canEditToggle }: PublicReadToggleProps) => {
|
||||
const intl = useIntl();
|
||||
const { data: library } = useLibrary(libraryId);
|
||||
const { mutate: updateLibrary, isPending } = useUpdateLibrary();
|
||||
const { handleShowToast } = useToastManager();
|
||||
const onChangeToggle = () => updateLibrary({
|
||||
libraryId,
|
||||
updatedData: { allowPublicRead: !library.allowPublicRead },
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
handleShowToast(intl.formatMessage(messages['libraries.authz.public.read.toggle.success']));
|
||||
},
|
||||
});
|
||||
|
||||
if (!library.allowPublicRead && !canEditToggle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Switch
|
||||
checked={library.allowPublicRead}
|
||||
disabled={!canEditToggle || isPending}
|
||||
onChange={onChangeToggle}
|
||||
helperText={
|
||||
<span>{intl.formatMessage(messages['libraries.authz.public.read.toggle.subtext'])}</span>
|
||||
}
|
||||
>
|
||||
{intl.formatMessage(messages['libraries.authz.public.read.toggle.label'])}
|
||||
</Form.Switch>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicReadToggle;
|
||||
@@ -61,6 +61,21 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Remove',
|
||||
description: 'Libraries AuthZ remove button title',
|
||||
},
|
||||
'libraries.authz.public.read.toggle.label': {
|
||||
id: 'libraries.authz.public.read.toggle.label',
|
||||
defaultMessage: 'Allow public read',
|
||||
description: 'Library label toggle to allow public read',
|
||||
},
|
||||
'libraries.authz.public.read.toggle.subtext': {
|
||||
id: 'libraries.authz.public.read.toggle.subtext',
|
||||
defaultMessage: 'Allows reuse of library content in courses.',
|
||||
description: 'Library description toggle to allow public read',
|
||||
},
|
||||
'libraries.authz.public.read.toggle.success': {
|
||||
id: 'libraries.authz.public.read.toggle.success',
|
||||
defaultMessage: 'The library setting has been updated successfully.',
|
||||
description: 'Success message for allow public read',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface LibraryMetadata {
|
||||
org: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
allowPublicRead: boolean;
|
||||
}
|
||||
|
||||
export interface RoleMetadata {
|
||||
|
||||
Reference in New Issue
Block a user