Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 52 additions & 36 deletions src/authz-module/components/AuthZTitle.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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;
17 changes: 16 additions & 1 deletion src/authz-module/data/api.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
};
};

Expand All @@ -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,
};
};
78 changes: 78 additions & 0 deletions src/authz-module/data/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down Expand Up @@ -340,3 +341,80 @@ describe('useRevokeUserRoles', () => {
expect(calledUrl.searchParams.get('scope')).toBe(revokeRoleData.scope);
});
});

describe('useUpdateLibrary', () => {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These tests appear to only be testing that the title can be updated properly. Not blocking, but it'd be nice to have tests verifying each part of LibraryMetadata that can be updated via this endpoint is updated properly.

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();
});
});
26 changes: 26 additions & 0 deletions src/authz-module/data/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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) });
},
});
};
19 changes: 18 additions & 1 deletion src/authz-module/index.scss
Original file line number Diff line number Diff line change
@@ -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);
}
Expand Down Expand Up @@ -48,4 +65,4 @@
// Move toast to the right
left: auto;
right: var(--pgn-spacing-toast-container-gutter-lg);
}
}
Loading