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
10 changes: 8 additions & 2 deletions src/authz-module/components/AuthZTitle.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import React from 'react';
import { ReactNode } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import AuthZTitle, { AuthZTitleProps } from './AuthZTitle';

jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
Link: ({ children, to }:{ children:ReactNode, to:string }) => <a href={to}>{children}</a>,
}));

describe('AuthZTitle', () => {
const defaultProps: AuthZTitleProps = {
activeLabel: 'Current Page',
Expand All @@ -24,8 +29,9 @@ describe('AuthZTitle', () => {

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

navLinks.forEach(({ label }) => {
navLinks.forEach(({ label, to }) => {
expect(screen.getByText(label)).toBeInTheDocument();
expect(screen.getByText(label)).toHaveAttribute('href', expect.stringContaining(to));
});

expect(screen.getByText(defaultProps.activeLabel)).toBeInTheDocument();
Expand Down
2 changes: 2 additions & 0 deletions src/authz-module/components/AuthZTitle.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import { Link } from 'react-router-dom';
import {
Breadcrumb, Col, Container, Row, Button, Badge,
} from '@openedx/paragon';
Expand Down Expand Up @@ -26,6 +27,7 @@ const AuthZTitle = ({
}: AuthZTitleProps) => (
<Container className="p-5 bg-light-100">
<Breadcrumb
linkAs={Link}
links={navLinks}
activeLabel={activeLabel}
/>
Expand Down
41 changes: 41 additions & 0 deletions src/authz-module/components/RoleCard/PermissionsRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { ComponentType } from 'react';
import {
Chip, Col, Row,
} from '@openedx/paragon';
import { actionsDictionary, ActionKey } from './constants';

interface Action {
key: string;
label?: string;
disabled?: boolean;
}

interface PermissionRowProps {
resourceLabel: string;
actions: Action[];
}

const PermissionRow = ({ resourceLabel, actions }: PermissionRowProps) => (
<Row className="row align-items-center border px-2 py-2">
<Col md={3}>
<span className="small font-weight-bold">{resourceLabel}</span>
</Col>
<Col>
<div className="w-100 d-flex flex-wrap">
{actions.map(action => (
<Chip
key={action.key}
iconBefore={actionsDictionary[action.key as ActionKey] as ComponentType}
disabled={action.disabled}
className="mr-4 my-2 px-3 bg-primary-100 border-0 permission-chip"
variant="light"
>
{action.label}
</Chip>
))}
</div>
</Col>
</Row>
);

export default PermissionRow;
18 changes: 18 additions & 0 deletions src/authz-module/components/RoleCard/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {
Add, Delete, DownloadDone, Edit, ManageAccounts, Sync, Tag, Visibility,
} from '@openedx/paragon/icons';

export const actionsDictionary = {
create: Add,
edit: Edit,
delete: Delete,
import: Sync,
publish: DownloadDone,
view: Visibility,
reuse: Sync,
tag: Tag,
team: ManageAccounts,
};

export type ActionKey = keyof typeof actionsDictionary;
export const actionKeys = Object.keys(actionsDictionary);
89 changes: 89 additions & 0 deletions src/authz-module/components/RoleCard/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { screen } from '@testing-library/react';
import { renderWrapper } from '@src/setupTest';
import userEvent from '@testing-library/user-event';
import RoleCard from '.';

jest.mock('@openedx/paragon/icons', () => ({
Delete: () => <svg data-testid="delete-icon" />,
Person: () => <svg data-testid="person-icon" />,
}));

jest.mock('./constants', () => ({
actionsDictionary: {
view: () => <svg data-testid="view-icon" />,
manage: () => <svg data-testid="manage-icon" />,
},
}));

describe('RoleCard', () => {
const defaultProps = {
title: 'Admin',
objectName: 'Test Library',
description: 'Can manage everything',
showDelete: true,
userCounter: 2,
permissions: [
{
key: 'library',
label: 'Library Resource',
actions: [
{ key: 'view', label: 'View' },
{ key: 'manage', label: 'Manage', disabled: true },
],
},
],
};

it('renders all role card sections correctly', async () => {
const user = userEvent.setup();
renderWrapper(<RoleCard {...defaultProps} />);

// Title
expect(screen.getByText('Admin')).toBeInTheDocument();

// User counter with icon
expect(screen.getByText('2')).toBeInTheDocument();
expect(screen.getByTestId('person-icon')).toBeInTheDocument();

// Subtitle (object name)
expect(screen.getByText('Test Library')).toBeInTheDocument();

// Description
expect(screen.getByText('Can manage everything')).toBeInTheDocument();

// Delete button
expect(screen.getByRole('button', { name: /delete role action/i })).toBeInTheDocument();

// Collapsible title
expect(screen.getByText('Permissions')).toBeInTheDocument();

await user.click(screen.getByText('Permissions'));

// Resource label
expect(screen.getByText('Library Resource')).toBeInTheDocument();

// Action chips
expect(screen.getByText('View')).toBeInTheDocument();
expect(screen.getByText('Manage')).toBeInTheDocument();

// Action icons
expect(screen.getByTestId('view-icon')).toBeInTheDocument();
expect(screen.getByTestId('manage-icon')).toBeInTheDocument();
});

it('does not show delete button when showDelete is false', () => {
renderWrapper(<RoleCard {...defaultProps} showDelete={false} />);
expect(screen.queryByRole('button', { name: /delete role action/i })).not.toBeInTheDocument();
});

it('handles no userCounter gracefully', () => {
renderWrapper(<RoleCard {...defaultProps} userCounter={null} />);
expect(screen.queryByTestId('person-icon')).not.toBeInTheDocument();
expect(screen.queryByText('2')).not.toBeInTheDocument();
});

it('handles empty permissions gracefully', () => {
renderWrapper(<RoleCard {...defaultProps} permissions={[]} />);
expect(screen.queryByText('Library Resource')).not.toBeInTheDocument();
});
});
68 changes: 68 additions & 0 deletions src/authz-module/components/RoleCard/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Card, Collapsible, Container, Icon, IconButton,
} from '@openedx/paragon';
import { Delete, Person } from '@openedx/paragon/icons';
import PermissionRow from './PermissionsRow';
import messages from './messages';

interface CardTitleProps {
title: string;
userCounter?: number | null;

Choose a reason for hiding this comment

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

I don't see this property being actually passed down, anywhere. The only instance of RoleCard is in LibrariesUserManager, and it doesn't seem to need userCounter. Will a use for it be added later?

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, this is a reusable component that displays actions when is in the LibrariesUserManagement view, and information when is in the LibrariesTeamManager view.

The use of the prop is relevant for a different PR #7

}

interface RoleCardProps extends CardTitleProps {

Choose a reason for hiding this comment

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

Nit: missing empty line

Suggested change
interface RoleCardProps extends CardTitleProps {
interface RoleCardProps extends CardTitleProps {

objectName?: string | null;
description: string;
showDelete?: boolean;
permissions: any[];
}

const CardTitle = ({ title, userCounter }: CardTitleProps) => (
<div className="d-flex align-items-center">
<span className="mr-4 text-primary">{title}</span>
{userCounter !== null && (
<span className="d-flex align-items-center font-weight-normal">
<Icon src={Person} className="mr-1" />
{userCounter}
</span>
)}
</div>
);

const RoleCard = ({
title, objectName, description, showDelete, permissions, userCounter,
}: RoleCardProps) => {
const intl = useIntl();

return (
<Card className="container-mw-lg mx-auto mb-4">
<Card.Header
title={<CardTitle title={title} userCounter={userCounter} />}
subtitle={(objectName && <span className="text-info-400 lead">{objectName}</span>) || ''}
actions={
showDelete && <IconButton variant="danger" alt="Delete role action" src={Delete} />
}
/>
<Card.Section>
{description}
</Card.Section>
<Collapsible
title={intl.formatMessage(messages['authz.permissions.title'])}
>
<Container>
{permissions.map(({ key, label, actions }) => (
<PermissionRow
key={`${title}-${key}`}
resourceLabel={label}
actions={actions}
/>

))}
</Container>
</Collapsible>
</Card>
);
};

export default RoleCard;
51 changes: 51 additions & 0 deletions src/authz-module/components/RoleCard/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { defineMessages } from '@edx/frontend-platform/i18n';

const messages = defineMessages({
'authz.permissions.title': {
id: 'authz.permissions.title',
defaultMessage: 'Permissions',
description: 'Title for the permissions section in the role card',
},
'authz.permissions.actions.create': {
id: 'authz.permissions.actions.create',
defaultMessage: 'Create {resource}',
description: 'Default label for the create action',
},
'authz.permissions.actions.edit': {
id: 'authz.permissions.actions.edit',
defaultMessage: 'Edit {resource}',
description: 'Default label for the edit action',
},
'authz.permissions.actions.import': {
id: 'authz.permissions.actions.import',
defaultMessage: 'Import {resource}',
description: 'Default label for the import action',
},
'authz.permissions.actions.delete': {
id: 'authz.permissions.actions.delete',
defaultMessage: 'Delete {resource}',
description: 'Default label for the delete action',
},
'authz.permissions.actions.manage': {
id: 'authz.permissions.actions.manage',
defaultMessage: 'Manage {resource}',
description: 'Default label for the manage action',
},
'authz.permissions.actions.publish': {
id: 'authz.permissions.actions.publish',
defaultMessage: 'Publish {resource}',
description: 'Default label for the publish action',
},
'authz.permissions.actions.view': {
id: 'authz.permissions.actions.view',
defaultMessage: 'View {resource}',
description: 'Default label for the view action',
},
'authz.permissions.actions.reuse': {
id: 'authz.permissions.actions.reuse',
defaultMessage: 'Reuse {resource}',
description: 'Default label for the reuse action',
},
});

export default messages;
2 changes: 1 addition & 1 deletion src/authz-module/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const ROUTES = {
LIBRARIES_TEAM_PATH: '/libraries/:libraryId',
LIBRARIES_USER_PATH: '/libraries/user/:username',
LIBRARIES_USER_PATH: '/libraries/:libraryId/:username',
};
29 changes: 28 additions & 1 deletion src/authz-module/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,31 @@
.tab-content {
background-color: var(--pgn-color-light-200);
}
}

.collapsible-card {
border: none;

.collapsible-body {
padding: 0;
}
}

.collapsible-trigger {
background-color: var(--pgn-color-info-100);
border: none;
border-radius: 0 !important;
color: var(--pgn-color-primary-base);
padding: 1rem 2rem 1rem 1rem;
}

.permission-chip {
.pgn__chip__label {
font-weight: var(--pgn-typography-font-weight-base);
}

svg {
width: var(--pgn-size-icon-xs);
height: var(--pgn-size-icon-xs);
}
}
}
Loading