From 15d6c5fb7afeb15aa4a6dda64a2691625b8f7f01 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 27 May 2020 17:14:38 -0400 Subject: [PATCH 1/2] Adds disassociate functionality and an empty state for Sys Admin --- awx/ui_next/src/api/index.js | 3 + awx/ui_next/src/api/models/Roles.js | 23 ++ .../Team/TeamAccess/TeamAccessList.jsx | 102 ++++- .../Team/TeamAccess/TeamAccessList.test.jsx | 340 +++++++++++------ .../Team/TeamAccess/TeamAccessListItem.jsx | 35 +- .../TeamAccess/TeamAccessListItem.test.jsx | 30 +- .../User/UserAccess/UserAccessList.jsx | 102 ++++- .../User/UserAccess/UserAccessList.test.jsx | 356 +++++++++++------- .../User/UserAccess/UserAccessListItem.jsx | 35 +- .../UserAccess/UserAccessListItem.test.jsx | 31 +- 10 files changed, 764 insertions(+), 293 deletions(-) create mode 100644 awx/ui_next/src/api/models/Roles.js diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index e5f1f3455747..690b643b6e5f 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -17,6 +17,7 @@ import Organizations from './models/Organizations'; import Projects from './models/Projects'; import ProjectUpdates from './models/ProjectUpdates'; import Root from './models/Root'; +import Roles from './models/Roles'; import Schedules from './models/Schedules'; import SystemJobs from './models/SystemJobs'; import Teams from './models/Teams'; @@ -47,6 +48,7 @@ const OrganizationsAPI = new Organizations(); const ProjectsAPI = new Projects(); const ProjectUpdatesAPI = new ProjectUpdates(); const RootAPI = new Root(); +const RolesAPI = new Roles(); const SchedulesAPI = new Schedules(); const SystemJobsAPI = new SystemJobs(); const TeamsAPI = new Teams(); @@ -78,6 +80,7 @@ export { ProjectsAPI, ProjectUpdatesAPI, RootAPI, + RolesAPI, SchedulesAPI, SystemJobsAPI, TeamsAPI, diff --git a/awx/ui_next/src/api/models/Roles.js b/awx/ui_next/src/api/models/Roles.js new file mode 100644 index 000000000000..5846d9fa7974 --- /dev/null +++ b/awx/ui_next/src/api/models/Roles.js @@ -0,0 +1,23 @@ +import Base from '../Base'; + +class Roles extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/roles/'; + } + + disassociateUserRole(roleId, userId) { + return this.http.post(`${this.baseUrl}/${roleId}/users/`, { + disassociate: true, + id: userId, + }); + } + + disassociateTeamRole(roleId, teamId) { + return this.http.post(`${this.baseUrl}/${roleId}/teams/`, { + disassociate: true, + id: teamId, + }); + } +} +export default Roles; diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx index 516c090cdda1..106cac80ffef 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx @@ -4,17 +4,25 @@ import { useLocation, useParams } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Button } from '@patternfly/react-core'; -import { TeamsAPI } from '../../../api'; - -import useRequest from '../../../util/useRequest'; +import { + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + Title, +} from '@patternfly/react-core'; +import { CubesIcon } from '@patternfly/react-icons'; +import { TeamsAPI, RolesAPI } from '../../../api'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; import DataListToolbar from '../../../components/DataListToolbar'; import PaginatedDataList from '../../../components/PaginatedDataList'; import { getQSConfig, parseQueryString } from '../../../util/qs'; +import ErrorDetail from '../../../components/ErrorDetail'; +import AlertModal from '../../../components/AlertModal'; import TeamAccessListItem from './TeamAccessListItem'; import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd'; -const QS_CONFIG = getQSConfig('team', { +const QS_CONFIG = getQSConfig('roles', { page: 1, page_size: 20, order_by: 'id', @@ -24,6 +32,7 @@ function TeamAccessList({ i18n }) { const [isWizardOpen, setIsWizardOpen] = useState(false); const { search } = useLocation(); const { id } = useParams(); + const [roleToDisassociate, setRoleToDisassociate] = useState(null); const { isLoading, @@ -60,6 +69,21 @@ function TeamAccessList({ i18n }) { setIsWizardOpen(false); fetchRoles(); }; + const { + isLoading: isDisassociateLoading, + deleteItems: disassociateRole, + deletionError: disassociationError, + clearDeletionError: clearDisassociationError, + } = useDeleteItems( + useCallback(async () => { + setRoleToDisassociate(null); + await RolesAPI.disassociateTeamRole( + roleToDisassociate.id, + parseInt(id, 10) + ); + }, [roleToDisassociate, id]), + { qsConfig: QS_CONFIG, fetchItems: fetchRoles } + ); const canAdd = options && Object.prototype.hasOwnProperty.call(options, 'POST'); @@ -80,11 +104,26 @@ function TeamAccessList({ i18n }) { return `/${resource_type}s/${resource_id}/details`; }; + const isSysAdmin = roles.some(role => role.name === 'System Administrator'); + if (isSysAdmin) { + return ( + + + + {i18n._(t`System Administrator`)} + + + {i18n._(t`System administrators have access to all permissions.`)} + + + ); + } + return ( <> {}} + onSelect={item => { + setRoleToDisassociate(item); + }} /> )} /> @@ -141,6 +182,53 @@ function TeamAccessList({ i18n }) { title={i18n._(t`Add team permissions`)} /> )} + + {roleToDisassociate && ( + setRoleToDisassociate(null)} + actions={[ + , + , + ]} + > +
+ {i18n._( + t`This action will disassociate the following role from ${roleToDisassociate.summary_fields.resource_name}:` + )} +
+ {roleToDisassociate.name} +
+
+ )} + {disassociationError && ( + + {i18n._(t`Failed to delete role.`)} + + + )} ); } diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx index 88311c864353..5828d162dfba 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx @@ -1,8 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { createMemoryHistory } from 'history'; -import { Route } from 'react-router-dom'; -import { TeamsAPI } from '../../../api'; +import { TeamsAPI, RolesAPI } from '../../../api'; import { mountWithContexts, waitForElement, @@ -10,119 +8,114 @@ import { import TeamAccessList from './TeamAccessList'; jest.mock('../../../api/models/Teams'); -describe('', () => { - let wrapper; - let history; - beforeEach(async () => { - TeamsAPI.readRoles.mockResolvedValue({ - data: { - results: [ - { - id: 2, - name: 'Admin', - type: 'role', - url: '/api/v2/roles/257/', - summary_fields: { - resource_name: 'template delete project', - resource_id: 15, - resource_type: 'job_template', - resource_type_display_name: 'Job Template', - user_capabilities: { unattach: true }, - }, - }, - { - id: 3, - name: 'Admin', - type: 'role', - url: '/api/v2/roles/257/', - summary_fields: { - resource_name: 'template delete project', - resource_id: 16, - resource_type: 'workflow_job_template', - resource_type_display_name: 'Job Template', - user_capabilities: { unattach: true }, - }, - }, - { - id: 4, - name: 'Execute', - type: 'role', - url: '/api/v2/roles/258/', - summary_fields: { - resource_name: 'Credential Bar', - resource_id: 75, - resource_type: 'credential', - resource_type_display_name: 'Credential', - user_capabilities: { unattach: true }, - }, - }, - { - id: 5, - name: 'Update', - type: 'role', - url: '/api/v2/roles/259/', - summary_fields: { - resource_name: 'Inventory Foo', - resource_id: 76, - resource_type: 'inventory', - resource_type_display_name: 'Inventory', - user_capabilities: { unattach: true }, - }, - }, - { - id: 6, - name: 'Admin', - type: 'role', - url: '/api/v2/roles/260/', - summary_fields: { - resource_name: 'Smart Inventory Foo', - resource_id: 77, - resource_type: 'smart_inventory', - resource_type_display_name: 'Inventory', - user_capabilities: { unattach: true }, - }, - }, - ], - count: 4, - }, - }); +jest.mock('../../../api/models/Roles'); - TeamsAPI.readRoleOptions.mockResolvedValue({ - data: { actions: { POST: { id: 1, disassociate: true } } }, - }); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 18, + }), +})); - history = createMemoryHistory({ - initialEntries: ['/teams/18/access'], - }); +const roles = { + data: { + results: [ + { + id: 2, + name: 'Admin', + type: 'role', + url: '/api/v2/roles/257/', + summary_fields: { + resource_name: 'template delete project', + resource_id: 15, + resource_type: 'job_template', + resource_type_display_name: 'Job Template', + user_capabilities: { unattach: true }, + }, + }, + { + id: 3, + name: 'Admin Read Only', + type: 'role', + url: '/api/v2/roles/257/', + summary_fields: { + resource_name: 'template delete project', + resource_id: 16, + resource_type: 'workflow_job_template', + resource_type_display_name: 'Job Template', + user_capabilities: { unattach: true }, + }, + }, + { + id: 4, + name: 'Execute', + type: 'role', + url: '/api/v2/roles/258/', + summary_fields: { + resource_name: 'Credential Bar', + resource_id: 75, + resource_type: 'credential', + resource_type_display_name: 'Credential', + user_capabilities: { unattach: true }, + }, + }, + { + id: 5, + name: 'Update', + type: 'role', + url: '/api/v2/roles/259/', + summary_fields: { + resource_name: 'Inventory Foo', + resource_id: 76, + resource_type: 'inventory', + resource_type_display_name: 'Inventory', + user_capabilities: { unattach: true }, + }, + }, + { + id: 6, + name: 'Admin', + type: 'role', + url: '/api/v2/roles/260/', + summary_fields: { + resource_name: 'Smart Inventory Foo', + resource_id: 77, + resource_type: 'smart_inventory', + resource_type_display_name: 'Inventory', + user_capabilities: { unattach: true }, + }, + }, + ], + count: 5, + }, +}; +const options = { + data: { actions: { POST: { id: 1, disassociate: true } } }, +}; +describe('', () => { + let wrapper; - await act(async () => { - wrapper = mountWithContexts( - - - , - { - context: { - router: { - history, - route: { - location: history.location, - match: { params: { id: 18 } }, - }, - }, - }, - } - ); - }); - }); afterEach(() => { jest.clearAllMocks(); wrapper.unmount(); }); test('should render properly', async () => { + TeamsAPI.readRoles.mockResolvedValue(roles); + TeamsAPI.readRoleOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); expect(wrapper.find('TeamAccessList').length).toBe(1); }); test('should create proper detailUrl', async () => { + TeamsAPI.readRoles.mockResolvedValue(roles); + TeamsAPI.readRoleOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); expect(wrapper.find(`Link#teamRole-2`).prop('to')).toBe( @@ -168,22 +161,7 @@ describe('', () => { }, }); await act(async () => { - wrapper = mountWithContexts( - - - , - { - context: { - router: { - history, - route: { - location: history.location, - match: { params: { id: 18 } }, - }, - }, - }, - } - ); + wrapper = mountWithContexts(); }); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); @@ -191,17 +169,129 @@ describe('', () => { 0 ); }); - test('should open and close wizard', async () => { + + test('should render disassociate modal', async () => { + TeamsAPI.readRoles.mockResolvedValue(roles); + TeamsAPI.readRoleOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); + + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + + await act(async () => + wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({ + id: 4, + name: 'Execute', + type: 'role', + url: '/api/v2/roles/258/', + summary_fields: { + resource_name: 'Credential Bar', + resource_id: 75, + resource_type: 'credential', + resource_type_display_name: 'Credential', + user_capabilities: { unattach: true }, + }, + }) + ); + wrapper.update(); + expect( + wrapper.find('AlertModal[aria-label="Disassociate role"]').length + ).toBe(1); + await act(async () => + wrapper + .find('button[aria-label="confirm disassociate"]') + .prop('onClick')() + ); + expect(RolesAPI.disassociateTeamRole).toBeCalledWith(4, 18); + wrapper.update(); + expect( + wrapper.find('AlertModal[aria-label="Disassociate role"]').length + ).toBe(0); + }); + + test('should throw disassociation error', async () => { + TeamsAPI.readRoles.mockResolvedValue(roles); + RolesAPI.disassociateTeamRole.mockRejectedValue( + new Error({ + response: { + config: { + method: 'post', + url: '/api/v2/roles/18/roles', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + TeamsAPI.readRoleOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + await act(async () => - wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')() + wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({ + id: 4, + name: 'Execute', + type: 'role', + url: '/api/v2/roles/258/', + summary_fields: { + resource_name: 'Credential Bar', + resource_id: 75, + resource_type: 'credential', + resource_type_display_name: 'Credential', + user_capabilities: { unattach: true }, + }, + }) ); wrapper.update(); - expect(wrapper.find('PFWizard').length).toBe(1); + expect( + wrapper.find('AlertModal[aria-label="Disassociate role"]').length + ).toBe(1); await act(async () => - wrapper.find("Button[aria-label='Close']").prop('onClick')() + wrapper + .find('button[aria-label="confirm disassociate"]') + .prop('onClick')() ); wrapper.update(); - expect(wrapper.find('PFWizard').length).toBe(0); + expect(wrapper.find('AlertModal[title="Error!"]').length).toBe(1); + }); + + test('user with sys admin privilege should show empty state', async () => { + TeamsAPI.readRoles.mockResolvedValue({ + data: { + results: [ + { + id: 2, + name: 'System Administrator', + type: 'role', + url: '/api/v2/roles/257/', + summary_fields: { + resource_name: 'template delete project', + resource_id: 15, + resource_type: 'job_template', + resource_type_display_name: 'Job Template', + user_capabilities: { unattach: true }, + }, + }, + ], + count: 1, + }, + }); + TeamsAPI.readRoleOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); + + waitForElement( + wrapper, + 'EmptyState[title="System Administrator"]', + el => el.length === 1 + ); }); }); diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.jsx index 117ce861ab2f..12495c80035d 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.jsx +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.jsx @@ -5,11 +5,13 @@ import { DataListItem, DataListItemCells, DataListItemRow, + Chip, } from '@patternfly/react-core'; import { Link } from 'react-router-dom'; +import { DetailList, Detail } from '../../../components/DetailList'; import DataListCell from '../../../components/DataListCell'; -function TeamAccessListItem({ role, i18n, detailUrl }) { +function TeamAccessListItem({ role, i18n, detailUrl, onSelect }) { const labelId = `teamRole-${role.id}`; return ( @@ -23,18 +25,33 @@ function TeamAccessListItem({ role, i18n, detailUrl }) { , {role.summary_fields && ( - <> - {i18n._(t`Type`)} - {role.summary_fields.resource_type_display_name} - + + + )} , {role.name && ( - <> - {i18n._(t`Role`)} - {role.name} - + + onSelect(role)} + > + {role.name} + + } + /> + )} , ]} diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.test.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.test.jsx index 543f789f0fb5..094ba21bf8b5 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.test.jsx @@ -18,20 +18,25 @@ describe('', () => { }, }; - beforeEach(() => { + test('should mount properly', () => { wrapper = mountWithContexts( ); - }); - test('should mount properly', () => { expect(wrapper.length).toBe(1); }); test('should render proper list item data', () => { + wrapper = mountWithContexts( + + ); + expect( wrapper.find('PFDataListCell[aria-label="resource name"]').text() ).toBe('template delete project'); @@ -42,4 +47,23 @@ describe('', () => { wrapper.find('PFDataListCell[aria-label="resource role"]').text() ).toContain('Admin'); }); + test('should render deletable chip', () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('Chip').prop('isReadOnly')).toBe(false); + }); + test('should render read only chip', () => { + role.summary_fields.user_capabilities.unattach = false; + wrapper = mountWithContexts( + + ); + expect(wrapper.find('Chip').prop('isReadOnly')).toBe(true); + }); }); diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx index e48fa6f70d2a..1e1fa4946650 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx @@ -2,12 +2,21 @@ import React, { useCallback, useEffect, useState } from 'react'; import { useParams, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Button } from '@patternfly/react-core'; - +import { + Button, + EmptyState, + EmptyStateBody, + EmptyStateIcon, + Title, +} from '@patternfly/react-core'; +import { CubesIcon } from '@patternfly/react-icons'; import { getQSConfig, parseQueryString } from '../../../util/qs'; -import { UsersAPI } from '../../../api'; -import useRequest from '../../../util/useRequest'; +import { UsersAPI, RolesAPI } from '../../../api'; +import useRequest, { useDeleteItems } from '../../../util/useRequest'; import PaginatedDataList from '../../../components/PaginatedDataList'; +import ErrorDetail from '../../../components/ErrorDetail'; +import AlertModal from '../../../components/AlertModal'; + import DatalistToolbar from '../../../components/DataListToolbar'; import UserAccessListItem from './UserAccessListItem'; import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd'; @@ -25,6 +34,7 @@ function UserAccessList({ i18n }) { const { search } = useLocation(); const [isWizardOpen, setIsWizardOpen] = useState(false); + const [roleToDisassociate, setRoleToDisassociate] = useState(null); const { isLoading, request: fetchRoles, @@ -54,6 +64,23 @@ function UserAccessList({ i18n }) { useEffect(() => { fetchRoles(); }, [fetchRoles]); + + const { + isLoading: isDisassociateLoading, + deleteItems: disassociateRole, + deletionError: disassociationError, + clearDeletionError: clearDisassociationError, + } = useDeleteItems( + useCallback(async () => { + setRoleToDisassociate(null); + await RolesAPI.disassociateUserRole( + roleToDisassociate.id, + parseInt(id, 10) + ); + }, [roleToDisassociate, id]), + { qsConfig: QS_CONFIG, fetchItems: fetchRoles } + ); + const canAdd = options && Object.prototype.hasOwnProperty.call(options, 'POST'); @@ -77,12 +104,25 @@ function UserAccessList({ i18n }) { } return `/${resource_type}s/${resource_id}/details`; }; - + const isSysAdmin = roles.some(role => role.name === 'System Administrator'); + if (isSysAdmin) { + return ( + + + + {i18n._(t`System Administrator`)} + + + {i18n._(t`System administrators have access to all permissions.`)} + + + ); + } return ( <> {}} isSelected={false} + onSelect={item => { + setRoleToDisassociate(item); + }} /> ); }} @@ -143,6 +185,52 @@ function UserAccessList({ i18n }) { title={i18n._(t`Add user permissions`)} /> )} + {roleToDisassociate && ( + setRoleToDisassociate(null)} + actions={[ + , + , + ]} + > +
+ {i18n._( + t`This action will disassociate the following role from ${roleToDisassociate.summary_fields.resource_name}:` + )} +
+ {roleToDisassociate.name} +
+
+ )} + {disassociationError && ( + + {i18n._(t`Failed to delete role.`)} + + + )} ); } diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx index 8a59012a30ba..7a539e8c2a85 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx @@ -1,8 +1,6 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { createMemoryHistory } from 'history'; -import { Route } from 'react-router-dom'; -import { UsersAPI } from '../../../api'; +import { UsersAPI, RolesAPI } from '../../../api'; import { mountWithContexts, waitForElement, @@ -10,124 +8,114 @@ import { import UserAccessList from './UserAccessList'; jest.mock('../../../api/models/Users'); +jest.mock('../../../api/models/Roles'); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 18, + }), +})); +const roles = { + data: { + results: [ + { + id: 2, + name: 'Admin', + type: 'role', + url: '/api/v2/roles/257/', + summary_fields: { + resource_name: 'template delete project', + resource_id: 15, + resource_type: 'job_template', + resource_type_display_name: 'Job Template', + user_capabilities: { unattach: true }, + }, + }, + { + id: 3, + name: 'Admin', + type: 'role', + url: '/api/v2/roles/257/', + summary_fields: { + resource_name: 'template delete project', + resource_id: 16, + resource_type: 'workflow_job_template', + resource_type_display_name: 'Job Template', + user_capabilities: { unattach: true }, + }, + }, + { + id: 4, + name: 'Execute', + type: 'role', + url: '/api/v2/roles/258/', + summary_fields: { + resource_name: 'Credential Bar', + resource_id: 75, + resource_type: 'credential', + resource_type_display_name: 'Credential', + user_capabilities: { unattach: true }, + }, + }, + { + id: 5, + name: 'Update', + type: 'role', + url: '/api/v2/roles/259/', + summary_fields: { + resource_name: 'Inventory Foo', + resource_id: 76, + resource_type: 'inventory', + resource_type_display_name: 'Inventory', + user_capabilities: { unattach: true }, + }, + }, + { + id: 6, + name: 'Admin', + type: 'role', + url: '/api/v2/roles/260/', + summary_fields: { + resource_name: 'Smart Inventory Foo', + resource_id: 77, + resource_type: 'smart_inventory', + resource_type_display_name: 'Inventory', + user_capabilities: { unattach: true }, + }, + }, + ], + count: 5, + }, +}; +const options = { + data: { actions: { POST: { id: 1, disassociate: true } } }, +}; describe('', () => { let wrapper; - let history; - beforeEach(async () => { - UsersAPI.readRoles.mockResolvedValue({ - data: { - results: [ - { - id: 2, - name: 'Admin', - type: 'role', - url: '/api/v2/roles/257/', - summary_fields: { - resource_name: 'template delete project', - resource_id: 15, - resource_type: 'job_template', - resource_type_display_name: 'Job Template', - user_capabilities: { unattach: true }, - }, - description: 'Can manage all aspects of the job template', - }, - { - id: 3, - name: 'Admin', - type: 'role', - url: '/api/v2/roles/257/', - summary_fields: { - resource_name: 'template delete project', - resource_id: 16, - resource_type: 'workflow_job_template', - resource_type_display_name: 'Job Template', - user_capabilities: { unattach: true }, - }, - description: 'Can manage all aspects of the job template', - }, - { - id: 4, - name: 'Execute', - type: 'role', - url: '/api/v2/roles/258/', - summary_fields: { - resource_name: 'Credential Bar', - resource_id: 75, - resource_type: 'credential', - resource_type_display_name: 'Credential', - user_capabilities: { unattach: true }, - }, - description: 'May run the job template', - }, - { - id: 5, - name: 'Read', - type: 'role', - url: '/api/v2/roles/259/', - summary_fields: { - resource_name: 'Inventory Foo', - resource_id: 76, - resource_type: 'inventory', - resource_type_display_name: 'Inventory', - user_capabilities: { unattach: true }, - }, - description: 'May view settings for the job template', - }, - { - id: 6, - name: 'Admin', - type: 'role', - url: '/api/v2/roles/260/', - summary_fields: { - resource_name: 'Project Foo', - resource_id: 77, - resource_type: 'project', - resource_type_display_name: 'Project', - user_capabilities: { unattach: true }, - }, - description: 'Can manage all aspects of the job template', - }, - ], - count: 5, - }, - }); - - UsersAPI.readRoleOptions.mockResolvedValue({ - data: { actions: { POST: { id: 1, disassociate: true } } }, - }); - - history = createMemoryHistory({ - initialEntries: ['/users/18/access'], - }); - - await act(async () => { - wrapper = mountWithContexts( - - - , - { - context: { - router: { - history, - route: { - location: history.location, - match: { params: { id: 18 } }, - }, - }, - }, - } - ); - }); - }); afterEach(() => { jest.clearAllMocks(); - wrapper.unmount(); + // wrapper.unmount(); }); test('should render properly', async () => { + UsersAPI.readRoles.mockResolvedValue(roles); + UsersAPI.readRoleOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); + expect(wrapper.find('UserAccessList').length).toBe(1); }); test('should create proper detailUrl', async () => { + UsersAPI.readRoles.mockResolvedValue(roles); + UsersAPI.readRoleOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); expect(wrapper.find(`Link#userRole-2`).prop('to')).toBe( @@ -143,7 +131,7 @@ describe('', () => { '/inventories/inventory/76/details' ); expect(wrapper.find('Link#userRole-6').prop('to')).toBe( - '/projects/77/details' + '/inventories/smart_inventory/77/details' ); }); test('should not render add button', async () => { @@ -189,22 +177,7 @@ describe('', () => { }, }); await act(async () => { - wrapper = mountWithContexts( - - - , - { - context: { - router: { - history, - route: { - location: history.location, - match: { params: { id: 18 } }, - }, - }, - }, - } - ); + wrapper = mountWithContexts(); }); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); @@ -213,6 +186,11 @@ describe('', () => { ); }); test('should open and close wizard', async () => { + UsersAPI.readRoles.mockResolvedValue(roles); + UsersAPI.readRoleOptions.mockResolvedValue(options); + await act(async () => { + wrapper = mountWithContexts(); + }); waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); await act(async () => wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')() @@ -225,4 +203,126 @@ describe('', () => { wrapper.update(); expect(wrapper.find('PFWizard').length).toBe(0); }); + test('should render disassociate modal', async () => { + UsersAPI.readRoles.mockResolvedValue(roles); + UsersAPI.readRoleOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); + + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + + await act(async () => + wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({ + id: 4, + name: 'Execute', + type: 'role', + url: '/api/v2/roles/258/', + summary_fields: { + resource_name: 'Credential Bar', + resource_id: 75, + resource_type: 'credential', + resource_type_display_name: 'Credential', + user_capabilities: { unattach: true }, + }, + }) + ); + wrapper.update(); + expect( + wrapper.find('AlertModal[aria-label="Disassociate role"]').length + ).toBe(1); + await act(async () => + wrapper + .find('button[aria-label="confirm disassociate"]') + .prop('onClick')() + ); + expect(RolesAPI.disassociateUserRole).toBeCalledWith(4, 18); + wrapper.update(); + expect( + wrapper.find('AlertModal[aria-label="Disassociate role"]').length + ).toBe(0); + }); + test('should throw disassociation error', async () => { + UsersAPI.readRoles.mockResolvedValue(roles); + RolesAPI.disassociateUserRole.mockRejectedValue( + new Error({ + response: { + config: { + method: 'post', + url: '/api/v2/roles/18/roles', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + UsersAPI.readRoleOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); + + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + + await act(async () => + wrapper.find('Chip[aria-label="Execute"]').prop('onClick')({ + id: 4, + name: 'Execute', + type: 'role', + url: '/api/v2/roles/258/', + summary_fields: { + resource_name: 'Credential Bar', + resource_id: 75, + resource_type: 'credential', + resource_type_display_name: 'Credential', + user_capabilities: { unattach: true }, + }, + }) + ); + wrapper.update(); + expect( + wrapper.find('AlertModal[aria-label="Disassociate role"]').length + ).toBe(1); + await act(async () => + wrapper + .find('button[aria-label="confirm disassociate"]') + .prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('AlertModal[title="Error!"]').length).toBe(1); + }); + test('user with sys admin privilege should show empty state', async () => { + UsersAPI.readRoles.mockResolvedValue({ + data: { + results: [ + { + id: 2, + name: 'System Administrator', + type: 'role', + url: '/api/v2/roles/257/', + summary_fields: { + resource_name: 'template delete project', + resource_id: 15, + resource_type: 'job_template', + resource_type_display_name: 'Job Template', + user_capabilities: { unattach: true }, + }, + }, + ], + count: 1, + }, + }); + UsersAPI.readRoleOptions.mockResolvedValue(options); + + await act(async () => { + wrapper = mountWithContexts(); + }); + + waitForElement( + wrapper, + 'EmptyState[title="System Administrator"]', + el => el.length === 1 + ); + }); }); diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx index 834b76da47f3..c06e4cca8db9 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx @@ -5,11 +5,13 @@ import { DataListItem, DataListItemCells, DataListItemRow, + Chip, } from '@patternfly/react-core'; import { Link } from 'react-router-dom'; +import { DetailList, Detail } from '../../../components/DetailList'; import DataListCell from '../../../components/DataListCell'; -function UserAccessListItem({ role, i18n, detailUrl }) { +function UserAccessListItem({ role, i18n, detailUrl, onSelect }) { const labelId = `userRole-${role.id}`; return ( @@ -23,18 +25,33 @@ function UserAccessListItem({ role, i18n, detailUrl }) { , {role.summary_fields && ( - <> - {i18n._(t`Type`)} - {role.summary_fields.resource_type_display_name} - + + + )} , {role.name && ( - <> - {i18n._(t`Role`)} - {role.name} - + + onSelect(role)} + > + {role.name} + + } + /> + )} , ]} diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx index 31b53ca47798..f0ce74377ecb 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx @@ -17,21 +17,23 @@ describe('', () => { user_capabilities: { unattach: true }, }, }; - - beforeEach(() => { + test('should mount properly', () => { wrapper = mountWithContexts( ); - }); - - test('should mount properly', () => { expect(wrapper.length).toBe(1); }); test('should render proper list item data', () => { + wrapper = mountWithContexts( + + ); expect( wrapper.find('PFDataListCell[aria-label="resource name"]').text() ).toBe('template delete project'); @@ -42,4 +44,23 @@ describe('', () => { wrapper.find('PFDataListCell[aria-label="resource role"]').text() ).toContain('Admin'); }); + test('should render deletable chip', () => { + wrapper = mountWithContexts( + + ); + expect(wrapper.find('Chip').prop('isReadOnly')).toBe(false); + }); + test('should render read only chip', () => { + role.summary_fields.user_capabilities.unattach = false; + wrapper = mountWithContexts( + + ); + expect(wrapper.find('Chip').prop('isReadOnly')).toBe(true); + }); }); From af70e3bb494bd1966f8fc9c0eddfbdc32ddebe70 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 2 Jun 2020 14:36:17 -0400 Subject: [PATCH 2/2] updates sys admin message --- awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx | 4 +++- awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx index 106cac80ffef..e29b7d5e5f70 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx @@ -113,7 +113,9 @@ function TeamAccessList({ i18n }) { {i18n._(t`System Administrator`)} - {i18n._(t`System administrators have access to all permissions.`)} + {i18n._( + t`System administrators have unrestricted access to all resources.` + )} ); diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx index 1e1fa4946650..4bc108ff8373 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx @@ -113,7 +113,9 @@ function UserAccessList({ i18n }) { {i18n._(t`System Administrator`)} - {i18n._(t`System administrators have access to all permissions.`)} + {i18n._( + t`System administrators have unrestricted access to all resources.` + )} );