-
Notifications
You must be signed in to change notification settings - Fork 7
feat(authz): [FC-0099] create LibrariesUserManager view to manage roles for a specific user #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 7 commits
329c021
5100031
e37902a
914d4b6
783b3a6
591f12b
a2d200b
71d4c3a
bcdb656
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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; |
| 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); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,87 @@ | ||
| import { fireEvent, screen } from '@testing-library/react'; | ||
| import { renderWrapper } from '@src/setupTest'; | ||
| 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', () => { | ||
| 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(); | ||
|
|
||
| fireEvent.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(); | ||
| }); | ||
| }); | ||
| 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; | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 The use of the prop is relevant for a different PR #7 |
||||||||
| } | ||||||||
|
|
||||||||
| interface RoleCardProps extends CardTitleProps { | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: missing empty line
Suggested change
|
||||||||
| 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 && userCounter !== undefined && ( | ||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you just do |
||||||||
| <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; | ||||||||
| 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; |
| 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', | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We are promoting the use userEvent instead of fireEvent to handle user interactions since it simulates a full user interaction, rather than just a single event, leading to more realistic and robust tests https://testing-library.com/docs/user-event/intro/