From 68d56d5616dcf0c0b01f57167b84c25042466bbc Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 7 May 2020 15:55:57 -0400 Subject: [PATCH 1/2] Adds User Access List --- awx/api/serializers.py | 1 + awx/ui_next/src/api/models/Users.js | 10 ++ awx/ui_next/src/screens/User/User.jsx | 6 +- .../User/UserAccess/UserAccessList.jsx | 114 ++++++++++++++++++ .../User/UserAccess/UserAccessList.test.jsx | 76 ++++++++++++ .../User/UserAccess/UserAccessListItem.jsx | 48 ++++++++ .../UserAccess/UserAccessListItem.test.jsx | 46 +++++++ .../src/screens/User/UserAccess/index.js | 8 ++ 8 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx create mode 100644 awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx create mode 100644 awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx create mode 100644 awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx create mode 100644 awx/ui_next/src/screens/User/UserAccess/index.js diff --git a/awx/api/serializers.py b/awx/api/serializers.py index 571edf57a9..631d8afeca 100644 --- a/awx/api/serializers.py +++ b/awx/api/serializers.py @@ -2306,6 +2306,7 @@ class RoleSerializer(BaseSerializer): content_model = obj.content_type.model_class() ret['summary_fields']['resource_type'] = get_type_for_model(content_model) ret['summary_fields']['resource_type_display_name'] = content_model._meta.verbose_name.title() + ret['summary_fields']['resource_id'] = obj.object_id return ret diff --git a/awx/ui_next/src/api/models/Users.js b/awx/ui_next/src/api/models/Users.js index e15908270b..d486e3ea81 100644 --- a/awx/ui_next/src/api/models/Users.js +++ b/awx/ui_next/src/api/models/Users.js @@ -24,6 +24,16 @@ class Users extends Base { params, }); } + + readRoles(userId, params) { + return this.http.get(`${this.baseUrl}${userId}/roles/`, { + params + }); + } + + roleOptions(userId) { + return this.http.options(`${this.baseUrl}${userId}/roles/`) + } } export default Users; diff --git a/awx/ui_next/src/screens/User/User.jsx b/awx/ui_next/src/screens/User/User.jsx index f7a15c48f9..cee493bc77 100644 --- a/awx/ui_next/src/screens/User/User.jsx +++ b/awx/ui_next/src/screens/User/User.jsx @@ -22,6 +22,7 @@ import UserEdit from './UserEdit'; import UserOrganizations from './UserOrganizations'; import UserTeams from './UserTeams'; import UserTokens from './UserTokens'; +import UserAccessList from './UserAccess/UserAccessList'; function User({ i18n, setBreadcrumb }) { const location = useLocation(); @@ -111,10 +112,7 @@ function User({ i18n, setBreadcrumb }) { {user && ( - - this needs a different access list from regular resources like - proj, inv, jt - + )} diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx new file mode 100644 index 0000000000..27082dfd44 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx @@ -0,0 +1,114 @@ +import React, { useCallback, useEffect } from 'react'; +import { useParams, useLocation } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import { UsersAPI } from '@api'; +import useRequest from '@util/useRequest'; +import PaginatedDataList, { + ToolbarAddButton, +} from '@components/PaginatedDataList'; +import DatalistToolbar from '@components/DataListToolbar'; +import UserAccessListItem from './UserAccessListItem'; + +const QS_CONFIG = getQSConfig('roles', { + page: 1, + page_size: 20, + order_by: 'id', +}); +// TODO Figure out how to best conduct a search of this list. +// Since we only have a role ID in the top level of each role object +// we can't really search using the normal search parameters. +function UserAccessList({ i18n }) { + const { id } = useParams(); + const { search } = useLocation(); + + const { + isLoading, + request: fetchRoles, + error, + result: { roleCount, roles, options }, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, search); + const { + data: { results, count }, + } = await UsersAPI.readRoles(id, params); + const { + data: { actions }, + } = await UsersAPI.roleOptions(id); + return { roleCount: count, roles: results, options: actions }; + }, [id, search]), + { + roles: [], + roleCount: 0, + } + ); + useEffect(() => { + fetchRoles(); + }, [fetchRoles]); + const canAdd = + options && Object.prototype.hasOwnProperty.call(options, 'POST'); + + const detailUrl = role => { + const { resource_id, resource_type } = role.summary_fields; + + if (!role || !resource_type) { + return null; + } + + if (resource_type?.includes('template')) { + return `/templates/${resource_type}/${resource_id}/details`; + } + if (resource_type?.includes('inventory')) { + return `/inventories/${resource_type}/${resource_id}/details`; + } + return `/${resource_type}s/${resource_id}/details`; + }; + + return ( + { + return ( + {}} + isSelected={false} + /> + ); + }} + renderToolbar={props => ( + ] : []), + ]} + /> + )} + /> + ); +} +export default withI18n()(UserAccessList); diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx new file mode 100644 index 0000000000..366d263926 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { UsersAPI } from '@api'; +import { Route } from 'react-router-dom'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import UserAccessList from './UserAccessList'; + +jest.mock('@api/models/Users'); +describe('', () => { + test('should render properly', 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 }, + }, + }, + { + id: 3, + name: 'Update', + type: 'role', + url: '/api/v2/roles/258/', + summary_fields: { + resource_name: 'Foo Bar', + resource_id: 75, + resource_type: 'credential', + resource_type_display_name: 'Credential', + user_capabilities: { unattach: true }, + }, + }, + ], + count: 2, + }, + }); + + UsersAPI.roleOptions.mockResolvedValue({ + data: { actions: { POST: { id: 1, disassociate: true } } }, + }); + + let wrapper; + const history = createMemoryHistory({ + initialEntries: ['/users/18/access'], + }); + + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 18 } }, + }, + }, + }, + } + ); + }); + + expect(wrapper.find('UserAccessList').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx new file mode 100644 index 0000000000..6d9bd1fd15 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + DataListItem, + DataListItemCells, + DataListItemRow, +} from '@patternfly/react-core'; +import DataListCell from '@components/DataListCell'; + +import { Link } from 'react-router-dom'; + +function UserAccessListItem({ role, i18n, detailUrl }) { + const labelId = `check-action-${role.id}`; + return ( + + + + + {role.summary_fields.resource_name} + + , + + {role.summary_fields && ( + <> + {i18n._(t`Type`)} + {role.summary_fields.resource_type_display_name} + + )} + , + + {role.name && ( + <> + {i18n._(t`Role`)} + {role.name} + + )} + , + ]} + /> + + + ); +} + +export default withI18n()(UserAccessListItem); diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx new file mode 100644 index 0000000000..4837906f35 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import UserAccessListItem from './UserAccessListItem'; + +describe('', () => { + let wrapper; + const role = { + id: 1, + 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 }, + }, + }; + + beforeEach(() => { + wrapper = mountWithContexts( + + ); + }); + + test('should mount properly', () => { + expect(wrapper.length).toBe(1); + }); + + test('should render proper list item data', () => { + expect( + wrapper.find('PFDataListCell[aria-label="resource name"]').text() + ).toBe('template delete project'); + console.log(wrapper.debug()); + expect( + wrapper.find('PFDataListCell[aria-label="resource type"]').text() + ).toContain('Job Template'); + expect( + wrapper.find('PFDataListCell[aria-label="resource role"]').text() + ).toContain('Admin'); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserAccess/index.js b/awx/ui_next/src/screens/User/UserAccess/index.js new file mode 100644 index 0000000000..df470abaa6 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserAccess/index.js @@ -0,0 +1,8 @@ +export { + default as UserAccessListList +} +from './UserAccessList'; +export { + default as UserAccessListItem +} +from './UserAccessListItem'; From 2cbcbddc52bd325b5c5abe789dcead74cdb79492 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Fri, 8 May 2020 12:03:06 -0400 Subject: [PATCH 2/2] Adds more testing for Urls --- awx/ui_next/src/api/models/Users.js | 6 +- .../User/UserAccess/UserAccessList.jsx | 21 +++-- .../User/UserAccess/UserAccessList.test.jsx | 83 +++++++++++++++++-- .../User/UserAccess/UserAccessListItem.jsx | 2 +- .../UserAccess/UserAccessListItem.test.jsx | 1 - .../src/screens/User/UserAccess/index.js | 10 +-- 6 files changed, 93 insertions(+), 30 deletions(-) diff --git a/awx/ui_next/src/api/models/Users.js b/awx/ui_next/src/api/models/Users.js index d486e3ea81..b98cf45cae 100644 --- a/awx/ui_next/src/api/models/Users.js +++ b/awx/ui_next/src/api/models/Users.js @@ -27,12 +27,12 @@ class Users extends Base { readRoles(userId, params) { return this.http.get(`${this.baseUrl}${userId}/roles/`, { - params + params, }); } - roleOptions(userId) { - return this.http.options(`${this.baseUrl}${userId}/roles/`) + readRoleOptions(userId) { + return this.http.options(`${this.baseUrl}${userId}/roles/`); } } diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx index 27082dfd44..808ec3530a 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx @@ -31,12 +31,17 @@ function UserAccessList({ i18n }) { } = useRequest( useCallback(async () => { const params = parseQueryString(QS_CONFIG, search); - const { - data: { results, count }, - } = await UsersAPI.readRoles(id, params); - const { - data: { actions }, - } = await UsersAPI.roleOptions(id); + const [ + { + data: { results, count }, + }, + { + data: { actions }, + }, + ] = await Promise.all([ + UsersAPI.readRoles(id, params), + UsersAPI.readRoleOptions(id), + ]); return { roleCount: count, roles: results, options: actions }; }, [id, search]), { @@ -76,8 +81,8 @@ function UserAccessList({ i18n }) { qsConfig={QS_CONFIG} toolbarSearchColumns={[ { - name: i18n._(t`Type`), - key: 'content_type__search', + name: i18n._(t`Role`), + key: 'role_field', isDefault: true, }, ]} 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 366d263926..85cdf25f4f 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx @@ -3,12 +3,14 @@ import { act } from 'react-dom/test-utils'; import { createMemoryHistory } from 'history'; import { UsersAPI } from '@api'; import { Route } from 'react-router-dom'; -import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; import UserAccessList from './UserAccessList'; jest.mock('@api/models/Users'); describe('', () => { - test('should render properly', async () => { + let wrapper; + let history; + beforeEach(async () => { UsersAPI.readRoles.mockResolvedValue({ data: { results: [ @@ -27,28 +29,66 @@ describe('', () => { }, { id: 3, - name: 'Update', + 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: 'Foo Bar', + 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: 2, + count: 4, }, }); - UsersAPI.roleOptions.mockResolvedValue({ + UsersAPI.readRoleOptions.mockResolvedValue({ data: { actions: { POST: { id: 1, disassociate: true } } }, }); - let wrapper; - const history = createMemoryHistory({ + history = createMemoryHistory({ initialEntries: ['/users/18/access'], }); @@ -70,7 +110,32 @@ describe('', () => { } ); }); - + }); + afterEach(() => { + jest.clearAllMocks(); + wrapper.unmount(); + }); + test('should render properly', async () => { expect(wrapper.find('UserAccessList').length).toBe(1); }); + + test('should create proper detailUrl', async () => { + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + + expect(wrapper.find(`Link#userRole-2`).prop('to')).toBe( + '/templates/job_template/15/details' + ); + expect(wrapper.find(`Link#userRole-3`).prop('to')).toBe( + '/templates/workflow_job_template/16/details' + ); + expect(wrapper.find('Link#userRole-4').prop('to')).toBe( + '/credentials/75/details' + ); + expect(wrapper.find('Link#userRole-5').prop('to')).toBe( + '/inventories/inventory/76/details' + ); + expect(wrapper.find('Link#userRole-6').prop('to')).toBe( + '/inventories/smart_inventory/77/details' + ); + }); }); diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx index 6d9bd1fd15..032c981da6 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.jsx @@ -11,7 +11,7 @@ import DataListCell from '@components/DataListCell'; import { Link } from 'react-router-dom'; function UserAccessListItem({ role, i18n, detailUrl }) { - const labelId = `check-action-${role.id}`; + const labelId = `userRole-${role.id}`; return ( 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 4837906f35..3b6df96c73 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessListItem.test.jsx @@ -35,7 +35,6 @@ describe('', () => { expect( wrapper.find('PFDataListCell[aria-label="resource name"]').text() ).toBe('template delete project'); - console.log(wrapper.debug()); expect( wrapper.find('PFDataListCell[aria-label="resource type"]').text() ).toContain('Job Template'); diff --git a/awx/ui_next/src/screens/User/UserAccess/index.js b/awx/ui_next/src/screens/User/UserAccess/index.js index df470abaa6..754ba1ae99 100644 --- a/awx/ui_next/src/screens/User/UserAccess/index.js +++ b/awx/ui_next/src/screens/User/UserAccess/index.js @@ -1,8 +1,2 @@ -export { - default as UserAccessListList -} -from './UserAccessList'; -export { - default as UserAccessListItem -} -from './UserAccessListItem'; +export { default as UserAccessListList } from './UserAccessList'; +export { default as UserAccessListItem } from './UserAccessListItem';