Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ module.exports = createConfig('jest', {
setupFilesAfterEnv: [
'<rootDir>/src/setupTest.jsx',
],
moduleNameMapper: {
'^@src/(.*)$': '<rootDir>/src/$1',
},
coveragePathIgnorePatterns: [
'src/setupTest.jsx',
'src/i18n',
Expand Down
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 8 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
"scripts": {
"build": "fedx-scripts webpack",
"i18n_extract": "fedx-scripts formatjs extract",
"lint": "fedx-scripts eslint --ext .js --ext .jsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx .",
"lint": "fedx-scripts eslint --ext .js --ext .jsx --ext .ts --ext .tsx .",
"lint:fix": "fedx-scripts eslint --fix --ext .js --ext .jsx --ext .ts --ext .tsx .",
"snapshot": "fedx-scripts jest --updateSnapshot",
"start": "fedx-scripts webpack-dev-server --progress",
"start:with-theme": "paragon install-theme && npm start && npm install",
Expand All @@ -37,10 +37,10 @@
},
"dependencies": {
"@edx/brand": "npm:@openedx/brand-openedx@^1.2.2",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@edx/frontend-component-header": "^6.4.0",
"@edx/frontend-platform": "^8.3.0",
"@edx/openedx-atlas": "^0.7.0",
"@openedx/frontend-plugin-framework": "^1.7.0",
"@openedx/paragon": "^23.4.5",
"@tanstack/react-query": "5.89.0",
"react": "^18.3.1",
Expand All @@ -49,12 +49,13 @@
},
"devDependencies": {
"@edx/browserslist-config": "^1.1.1",
"@openedx/frontend-build": "14.6.2",
"@edx/typescript-config": "1.1.0",
"@types/react": "^18",
"@types/react-dom": "^18",
"@openedx/frontend-build": "14.6.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0"
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18",
"@types/react-dom": "^18"
}
}
27 changes: 27 additions & 0 deletions src/authz-module/components/AuthZLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ReactNode } from 'react';
import { StudioHeader } from '@edx/frontend-component-header';
import AuthZTitle, { AuthZTitleProps } from './AuthZTitle';

interface AuthZLayoutProps extends AuthZTitleProps {
children: ReactNode;
context: {
id: string;
org: string;
title: string;
}
}

const AuthZLayout = ({ children, context, ...props }: AuthZLayoutProps) => (
<>
<StudioHeader

Choose a reason for hiding this comment

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

Is Studio header the right header for this MFE?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, we believe the Studio header is the right choice for the MVP, given that the current functionality is focused solely on Library team management, which fits well within the Studio context. However, we will revisit the header choice in Phase 2, when we start integrating broader scopes and modules within the console.

number={context.id}
org={context.org}
title={context.title}
/>
<AuthZTitle {...props} />
{children}
</>

);

export default AuthZLayout;
62 changes: 62 additions & 0 deletions src/authz-module/components/AuthZTitle.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import AuthZTitle, { AuthZTitleProps } from './AuthZTitle';

describe('AuthZTitle', () => {
const defaultProps: AuthZTitleProps = {
activeLabel: 'Current Page',
pageTitle: 'Page Title',
pageSubtitle: 'Page Subtitle',
};

it('renders without optional fields', () => {
render(<AuthZTitle {...defaultProps} />);
expect(screen.getByText(defaultProps.activeLabel)).toBeInTheDocument();
expect(screen.getByText(defaultProps.pageTitle)).toBeInTheDocument();
expect(screen.getByText(defaultProps.pageSubtitle as string)).toBeInTheDocument();
});

it('renders breadcrumb with links and active label', () => {
const navLinks = [
{ label: 'Root', to: '/' },
{ label: 'Section', to: '/section' },
];

render(<AuthZTitle {...defaultProps} navLinks={navLinks} />);

navLinks.forEach(({ label }) => {
expect(screen.getByText(label)).toBeInTheDocument();
});

expect(screen.getByText(defaultProps.activeLabel)).toBeInTheDocument();
});

it('renders page title', () => {
render(<AuthZTitle {...defaultProps} />);
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent(defaultProps.pageTitle);
});

it('renders page subtitle as ReactNode', () => {
const subtitleNode = <div data-testid="custom-subtitle">Custom Subtitle</div>;
render(<AuthZTitle {...defaultProps} pageSubtitle={subtitleNode} />);
expect(screen.getByTestId('custom-subtitle')).toBeInTheDocument();
});

it('renders action buttons and triggers onClick', () => {
const onClick1 = jest.fn();
const onClick2 = jest.fn();
const actions = [
{ label: 'Save', onClick: onClick1 },
{ label: 'Cancel', onClick: onClick2 },
];

render(<AuthZTitle {...defaultProps} actions={actions} />);

actions.forEach(({ label, onClick }) => {
const button = screen.getByRole('button', { name: label });
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(onClick).toHaveBeenCalled();
});
});
});
50 changes: 50 additions & 0 deletions src/authz-module/components/AuthZTitle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { ReactNode } from 'react';
import {
Breadcrumb, Col, Container, Row, Button, Badge,
} from '@openedx/paragon';

interface BreadcrumbLink {
label: string;
to?: string;
}

interface Action {
label: string;
onClick: () => void;
}

export interface AuthZTitleProps {
activeLabel: string;
pageTitle: string;
pageSubtitle: string | ReactNode;
navLinks?: BreadcrumbLink[];
actions?: Action[];
}

const AuthZTitle = ({
activeLabel, navLinks = [], pageTitle, pageSubtitle, actions = [],
}: AuthZTitleProps) => (
<Container className="p-5 bg-light-100">
<Breadcrumb
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(({ label, onClick }) => <Button key={`authz-header-action-${label}`} onClick={onClick}>{label}</Button>)
}
</div>
</Col>
</Row>
</Container>
);

export default AuthZTitle;
4 changes: 4 additions & 0 deletions src/authz-module/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const ROUTES = {
LIBRARIES_TEAM_PATH: '/libraries/:libraryId',
LIBRARIES_USER_PATH: '/libraries/user/:username',
};
26 changes: 26 additions & 0 deletions src/authz-module/data/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { LibraryMetadata, TeamMember } from '@src/types';
import { camelCaseObject } from '@edx/frontend-platform';
import { getApiUrl, getStudioApiUrl } from '@src/data/utils';

export interface GetTeamMembersResponse {
members: TeamMember[];
totalCount: number;
}

// TODO: replece api path once is created
export const getTeamMembers = async (object: string): Promise<TeamMember[]> => {
const { data } = await getAuthenticatedHttpClient().get(getApiUrl(`/api/authz/v1/roles/users?scope=${object}`));
return camelCaseObject(data.results);
};

// TODO: this should be replaced in the future with Console API
export const getLibrary = async (libraryId: string): Promise<LibraryMetadata> => {
const { data } = await getAuthenticatedHttpClient().get(getStudioApiUrl(`/api/libraries/v2/${libraryId}/`));
return {
id: data.id,
org: data.org,
title: data.title,
slug: data.slug,
};
};
125 changes: 125 additions & 0 deletions src/authz-module/data/hooks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { ReactNode } from 'react';
import { act, renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { useLibrary, useTeamMembers } from './hooks';

jest.mock('@edx/frontend-platform/auth', () => ({
getAuthenticatedHttpClient: jest.fn(),
}));

const mockMembers = [
{
displayName: 'Alice',
username: 'user1',
email: 'alice@example.com',
roles: ['admin', 'author'],
},
{
displayName: 'Bob',
username: 'user2',
email: 'bob@example.com',
roles: ['collaborator'],
},
];

const mockLibrary = {
id: 'lib:123',
org: 'demo-org',
title: 'Test Library',
slug: 'test-library',
};

const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});

const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider
client={queryClient}
>{children}
</QueryClientProvider>
);

return wrapper;
};

describe('useTeamMembers', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('returns data when API call succeeds', async () => {
getAuthenticatedHttpClient.mockReturnValue({
get: jest.fn().mockResolvedValue({ data: { results: mockMembers } }),
});

const { result } = renderHook(() => useTeamMembers('lib:123'), {
wrapper: createWrapper(),
});

await waitFor(() => expect(result.current.isSuccess).toBe(true));

expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.data).toEqual(mockMembers);
});

it('handles error when API call fails', async () => {
getAuthenticatedHttpClient.mockReturnValue({
get: jest.fn().mockRejectedValue(new Error('API failure')),
});

const { result } = renderHook(() => useTeamMembers('lib:123'), {
wrapper: createWrapper(),
});

await waitFor(() => expect(result.current.isError).toBe(true));

expect(getAuthenticatedHttpClient).toHaveBeenCalled();
expect(result.current.error).toBeDefined();
expect(result.current.data).toBeUndefined();
});
});

describe('useLibrary', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('returns metadata on success', async () => {
getAuthenticatedHttpClient.mockReturnValue({
get: jest.fn().mockResolvedValueOnce({ data: mockLibrary }),
});

const { result } = renderHook(
() => useLibrary('lib123'),
{ wrapper: createWrapper() },
);
await waitFor(() => {
expect(result.current.data).toEqual(mockLibrary);
expect(getAuthenticatedHttpClient).toHaveBeenCalled();
});
});

it('throws on error', () => {
getAuthenticatedHttpClient.mockReturnValue({
get: jest.fn().mockRejectedValue(new Error('Not found')),
});

const wrapper = createWrapper();
try {
act(() => {
renderHook(() => useLibrary('lib123'), { wrapper });
});
} catch (e) {
expect(e).toEqual(new Error('Not found'));
}

expect(getAuthenticatedHttpClient).toHaveBeenCalled();
});
});
Loading