From 09e72bc0ae96c8b9c96863c3ecb58d5274a4e521 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Tue, 12 May 2020 11:37:49 -0400 Subject: [PATCH] Adds Teams Access List and tests --- awx/api/serializers.py | 1 + awx/ui_next/src/api/models/Teams.js | 14 +- awx/ui_next/src/screens/Team/Team.jsx | 3 +- .../Team/TeamAccess/TeamAccessList.jsx | 129 ++++++++++++++++ .../Team/TeamAccess/TeamAccessList.test.jsx | 141 ++++++++++++++++++ .../Team/TeamAccess/TeamAccessListItem.jsx | 47 ++++++ .../TeamAccess/TeamAccessListItem.test.jsx | 45 ++++++ .../src/screens/Team/TeamAccess/index.js | 1 + 8 files changed, 379 insertions(+), 2 deletions(-) create mode 100644 awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.test.jsx create mode 100644 awx/ui_next/src/screens/Team/TeamAccess/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/Teams.js b/awx/ui_next/src/api/models/Teams.js index 585eb1086d..de2d3db077 100644 --- a/awx/ui_next/src/api/models/Teams.js +++ b/awx/ui_next/src/api/models/Teams.js @@ -7,7 +7,9 @@ class Teams extends Base { } associateRole(teamId, roleId) { - return this.http.post(`${this.baseUrl}${teamId}/roles/`, { id: roleId }); + return this.http.post(`${this.baseUrl}${teamId}/roles/`, { + id: roleId, + }); } disassociateRole(teamId, roleId) { @@ -16,6 +18,16 @@ class Teams extends Base { disassociate: true, }); } + + readRoles(teamId, params) { + return this.http.get(`${this.baseUrl}${teamId}/roles/`, { + params, + }); + } + + readRoleOptions(teamId) { + return this.http.options(`${this.baseUrl}${teamId}/roles/`); + } } export default Teams; diff --git a/awx/ui_next/src/screens/Team/Team.jsx b/awx/ui_next/src/screens/Team/Team.jsx index a702970c39..48cc13c02c 100644 --- a/awx/ui_next/src/screens/Team/Team.jsx +++ b/awx/ui_next/src/screens/Team/Team.jsx @@ -17,6 +17,7 @@ import ContentError from '@components/ContentError'; import TeamDetail from './TeamDetail'; import TeamEdit from './TeamEdit'; import { TeamsAPI } from '@api'; +import TeamAccessList from './TeamAccess'; function Team({ i18n, setBreadcrumb }) { const [team, setTeam] = useState(null); @@ -98,7 +99,7 @@ function Team({ i18n, setBreadcrumb }) { )} {team && ( - Coming soon :) + )} diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx new file mode 100644 index 0000000000..7d2cea1a72 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx @@ -0,0 +1,129 @@ +import React, { useCallback, useEffect } from 'react'; +import { useLocation, useRouteMatch, useParams } from 'react-router-dom'; + +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; + +import { TeamsAPI } from '@api'; +import { Card } from '@patternfly/react-core'; + +import useRequest from '@util/useRequest'; +import DataListToolbar from '@components/DataListToolbar'; +import PaginatedDataList, { + ToolbarAddButton, +} from '@components/PaginatedDataList'; +import { getQSConfig, parseQueryString } from '@util/qs'; +import TeamAccessListItem from './TeamAccessListItem'; + +const QS_CONFIG = getQSConfig('team', { + page: 1, + page_size: 20, + order_by: 'id', +}); + +function TeamAccessList({ i18n }) { + const { search } = useLocation(); + const match = useRouteMatch(); + const { id } = useParams(); + + const { + isLoading, + request: fetchRoles, + contentError, + result: { roleCount, roles, options }, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, search); + const [ + { + data: { results, count }, + }, + { + data: { actions }, + }, + ] = await Promise.all([ + TeamsAPI.readRoles(id, params), + TeamsAPI.readRoleOptions(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 ( + + ( + ] + : []), + ]} + /> + )} + renderItem={role => ( + {}} + /> + )} + emptyStateControls={ + canAdd ? ( + + ) : null + } + /> + + ); +} +export default withI18n()(TeamAccessList); diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx new file mode 100644 index 0000000000..12d8e9cf39 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx @@ -0,0 +1,141 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { createMemoryHistory } from 'history'; +import { TeamsAPI } from '@api'; +import { Route } from 'react-router-dom'; +import { mountWithContexts, waitForElement } from '@testUtils/enzymeHelpers'; +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, + }, + }); + + TeamsAPI.readRoleOptions.mockResolvedValue({ + data: { actions: { POST: { id: 1, disassociate: true } } }, + }); + + history = createMemoryHistory({ + initialEntries: ['/teams/18/access'], + }); + + 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 () => { + expect(wrapper.find('TeamAccessList').length).toBe(1); + }); + + test('should create proper detailUrl', async () => { + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + + expect(wrapper.find(`Link#teamRole-2`).prop('to')).toBe( + '/templates/job_template/15/details' + ); + expect(wrapper.find(`Link#teamRole-3`).prop('to')).toBe( + '/templates/workflow_job_template/16/details' + ); + expect(wrapper.find('Link#teamRole-4').prop('to')).toBe( + '/credentials/75/details' + ); + expect(wrapper.find('Link#teamRole-5').prop('to')).toBe( + '/inventories/inventory/76/details' + ); + expect(wrapper.find('Link#teamRole-6').prop('to')).toBe( + '/inventories/smart_inventory/77/details' + ); + }); +}); diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.jsx new file mode 100644 index 0000000000..994c175ecd --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.jsx @@ -0,0 +1,47 @@ +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 TeamAccessListItem({ role, i18n, detailUrl }) { + const labelId = `teamRole-${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()(TeamAccessListItem); diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.test.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.test.jsx new file mode 100644 index 0000000000..45d40e08a1 --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessListItem.test.jsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { mountWithContexts } from '@testUtils/enzymeHelpers'; +import TeamAccessListItem from './TeamAccessListItem'; + +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'); + 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/Team/TeamAccess/index.js b/awx/ui_next/src/screens/Team/TeamAccess/index.js new file mode 100644 index 0000000000..d249ad2afa --- /dev/null +++ b/awx/ui_next/src/screens/Team/TeamAccess/index.js @@ -0,0 +1 @@ +export { default } from './TeamAccessList';