From 1bb29ec5f78bba609a97183cfb74e5592d5935c5 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Thu, 21 May 2020 16:24:11 -0700 Subject: [PATCH 01/49] add user teams list --- awx/ui_next/src/api/models/Users.js | 6 ++ .../UserOrganizationListItem.jsx | 14 ++-- .../src/screens/User/UserTeams/UserTeams.jsx | 12 ++-- .../screens/User/UserTeams/UserTeamsList.jsx | 70 +++++++++++++++++++ .../User/UserTeams/UserTeamsListItem.jsx | 43 ++++++++++++ 5 files changed, 130 insertions(+), 15 deletions(-) create mode 100644 awx/ui_next/src/screens/User/UserTeams/UserTeamsList.jsx create mode 100644 awx/ui_next/src/screens/User/UserTeams/UserTeamsListItem.jsx diff --git a/awx/ui_next/src/api/models/Users.js b/awx/ui_next/src/api/models/Users.js index b98cf45cae..3f5a177390 100644 --- a/awx/ui_next/src/api/models/Users.js +++ b/awx/ui_next/src/api/models/Users.js @@ -34,6 +34,12 @@ class Users extends Base { readRoleOptions(userId) { return this.http.options(`${this.baseUrl}${userId}/roles/`); } + + readTeams(userId, params) { + return this.http.get(`${this.baseUrl}${userId}/teams/`, { + params, + }); + } } export default Users; diff --git a/awx/ui_next/src/screens/User/UserOrganizations/UserOrganizationListItem.jsx b/awx/ui_next/src/screens/User/UserOrganizations/UserOrganizationListItem.jsx index f45c9a5b93..bc01af942d 100644 --- a/awx/ui_next/src/screens/User/UserOrganizations/UserOrganizationListItem.jsx +++ b/awx/ui_next/src/screens/User/UserOrganizations/UserOrganizationListItem.jsx @@ -1,7 +1,5 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; import { DataListItemCells, DataListItemRow, @@ -9,14 +7,18 @@ import { } from '@patternfly/react-core'; import DataListCell from '../../../components/DataListCell'; -function UserOrganizationListItem({ organization, i18n }) { +export default function UserOrganizationListItem({ organization }) { + const labelId = `organization-${organization.id}`; return ( - + - + {organization.name} , @@ -29,5 +31,3 @@ function UserOrganizationListItem({ organization, i18n }) { ); } - -export default withI18n()(UserOrganizationListItem); diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeams.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeams.jsx index 5d342e00f2..1cfc018236 100644 --- a/awx/ui_next/src/screens/User/UserTeams/UserTeams.jsx +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeams.jsx @@ -1,10 +1,6 @@ -import React, { Component } from 'react'; -import { CardBody } from '../../../components/Card'; +import React from 'react'; +import UserTeamsList from './UserTeamsList'; -class UserAdd extends Component { - render() { - return Coming soon :); - } +export default function UserTeams() { + return ; } - -export default UserAdd; diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamsList.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamsList.jsx new file mode 100644 index 0000000000..6bc06dba3e --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamsList.jsx @@ -0,0 +1,70 @@ +import React, { useCallback, useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { useLocation, useParams } from 'react-router-dom'; +import { t } from '@lingui/macro'; + +import PaginatedDataList from '../../../components/PaginatedDataList'; +import useRequest from '../../../util/useRequest'; +import { UsersAPI } from '../../../api'; +import { getQSConfig, parseQueryString } from '../../../util/qs'; +import UserTeamListItem from './UserTeamsListItem'; + +const QS_CONFIG = getQSConfig('teams', { + page: 1, + page_size: 20, + order_by: 'name', +}); + +function UserTeamsList({ i18n }) { + const location = useLocation(); + const { id: userId } = useParams(); + + const { + result: { teams, count }, + error: contentError, + isLoading, + request: fetchOrgs, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, location.search); + const { + data: { results, count: teamCount }, + } = await UsersAPI.readTeams(userId, params); + return { + teams: results, + count: teamCount, + }; + }, [userId, location.search]), + { + teams: [], + count: 0, + } + ); + + useEffect(() => { + fetchOrgs(); + }, [fetchOrgs]); + + return ( + ( + {}} + isSelected={false} + /> + )} + /> + ); +} + +export default withI18n()(UserTeamsList); diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamsListItem.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamsListItem.jsx new file mode 100644 index 0000000000..44995f004c --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamsListItem.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { + DataListItemCells, + DataListItemRow, + DataListItem, +} from '@patternfly/react-core'; +import DataListCell from '../../../components/DataListCell'; + +function UserTeamsListItem({ team, i18n }) { + return ( + + + + + {team.name} + + , + + {team.summary_fields.organization && ( + <> + {i18n._(t`Organization`)} + + {team.summary_fields.organization.name} + + + )} + , + {team.description}, + ]} + /> + + + ); +} + +export default withI18n()(UserTeamsListItem); From 75fd703530112c61ddb4058bb872b80a6e96e56a Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Fri, 22 May 2020 11:05:32 -0700 Subject: [PATCH 02/49] add UserTeamList tests --- awx/ui_next/src/api/models/Users.js | 4 + .../screens/Team/TeamList/TeamListItem.jsx | 2 +- awx/ui_next/src/screens/User/User.jsx | 2 +- .../{UserTeamsList.jsx => UserTeamList.jsx} | 6 +- .../User/UserTeams/UserTeamList.test.jsx | 82 +++++++++++++++++++ ...TeamsListItem.jsx => UserTeamListItem.jsx} | 6 +- .../User/UserTeams/UserTeamListItem.test.jsx | 38 +++++++++ .../src/screens/User/UserTeams/UserTeams.jsx | 4 +- 8 files changed, 134 insertions(+), 10 deletions(-) rename awx/ui_next/src/screens/User/UserTeams/{UserTeamsList.jsx => UserTeamList.jsx} (92%) create mode 100644 awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx rename awx/ui_next/src/screens/User/UserTeams/{UserTeamsListItem.jsx => UserTeamListItem.jsx} (87%) create mode 100644 awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.test.jsx diff --git a/awx/ui_next/src/api/models/Users.js b/awx/ui_next/src/api/models/Users.js index 3f5a177390..12eb74c4a6 100644 --- a/awx/ui_next/src/api/models/Users.js +++ b/awx/ui_next/src/api/models/Users.js @@ -40,6 +40,10 @@ class Users extends Base { params, }); } + + readTeamsOptions(userId) { + return this.http.options(`${this.baseUrl}${userId}/teams/`); + } } export default Users; diff --git a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx index f088dede27..47b2b4011c 100644 --- a/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx +++ b/awx/ui_next/src/screens/Team/TeamList/TeamListItem.jsx @@ -58,7 +58,7 @@ class TeamListItem extends React.Component { {team.summary_fields.organization && ( - {i18n._(t`Organization`)} + {i18n._(t`Organization`)}{' '} diff --git a/awx/ui_next/src/screens/User/User.jsx b/awx/ui_next/src/screens/User/User.jsx index 982ae0de5e..5e195da2f3 100644 --- a/awx/ui_next/src/screens/User/User.jsx +++ b/awx/ui_next/src/screens/User/User.jsx @@ -108,7 +108,7 @@ function User({ i18n, setBreadcrumb }) { - + {user && ( diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamsList.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx similarity index 92% rename from awx/ui_next/src/screens/User/UserTeams/UserTeamsList.jsx rename to awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx index 6bc06dba3e..c9d0c051e8 100644 --- a/awx/ui_next/src/screens/User/UserTeams/UserTeamsList.jsx +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.jsx @@ -7,7 +7,7 @@ import PaginatedDataList from '../../../components/PaginatedDataList'; import useRequest from '../../../util/useRequest'; import { UsersAPI } from '../../../api'; import { getQSConfig, parseQueryString } from '../../../util/qs'; -import UserTeamListItem from './UserTeamsListItem'; +import UserTeamListItem from './UserTeamListItem'; const QS_CONFIG = getQSConfig('teams', { page: 1, @@ -15,7 +15,7 @@ const QS_CONFIG = getQSConfig('teams', { order_by: 'name', }); -function UserTeamsList({ i18n }) { +function UserTeamList({ i18n }) { const location = useLocation(); const { id: userId } = useParams(); @@ -67,4 +67,4 @@ function UserTeamsList({ i18n }) { ); } -export default withI18n()(UserTeamsList); +export default withI18n()(UserTeamList); diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx new file mode 100644 index 0000000000..caac6b0c5f --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamList.test.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { UsersAPI } from '../../../api'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; + +import UserTeamList from './UserTeamList'; + +jest.mock('../../../api'); + +const mockAPIUserTeamList = { + data: { + count: 3, + results: [ + { + name: 'Team 0', + id: 1, + url: '/teams/1', + summary_fields: { + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + { + name: 'Team 1', + id: 2, + url: '/teams/2', + summary_fields: { + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + { + name: 'Team 2', + id: 3, + url: '/teams/3', + summary_fields: { + user_capabilities: { + delete: true, + edit: true, + }, + }, + }, + ], + }, + isModalOpen: false, + warningTitle: 'title', + warningMsg: 'message', +}; + +describe('', () => { + beforeEach(() => { + UsersAPI.readTeams = jest.fn(() => + Promise.resolve({ + data: mockAPIUserTeamList.data, + }) + ); + UsersAPI.readOptions = jest.fn(() => + Promise.resolve({ + data: { + actions: { + GET: {}, + POST: {}, + }, + }, + }) + ); + }); + + test('should load and render teams', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts(); + }); + wrapper.update(); + + expect(wrapper.find('UserTeamListItem')).toHaveLength(3); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamsListItem.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.jsx similarity index 87% rename from awx/ui_next/src/screens/User/UserTeams/UserTeamsListItem.jsx rename to awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.jsx index 44995f004c..41f4429879 100644 --- a/awx/ui_next/src/screens/User/UserTeams/UserTeamsListItem.jsx +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.jsx @@ -9,7 +9,7 @@ import { } from '@patternfly/react-core'; import DataListCell from '../../../components/DataListCell'; -function UserTeamsListItem({ team, i18n }) { +function UserTeamListItem({ team, i18n }) { return ( @@ -23,7 +23,7 @@ function UserTeamsListItem({ team, i18n }) { {team.summary_fields.organization && ( <> - {i18n._(t`Organization`)} + {i18n._(t`Organization`)}{' '} @@ -40,4 +40,4 @@ function UserTeamsListItem({ team, i18n }) { ); } -export default withI18n()(UserTeamsListItem); +export default withI18n()(UserTeamListItem); diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.test.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.test.jsx new file mode 100644 index 0000000000..5816622eb2 --- /dev/null +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeamListItem.test.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@lingui/react'; +import { mountWithContexts } from '../../../../testUtils/enzymeHelpers'; +import UserTeamListItem from './UserTeamListItem'; + +describe('', () => { + test('should render item', () => { + const wrapper = mountWithContexts( + + + {}} + /> + + + ); + + const cells = wrapper.find('DataListCell'); + expect(cells).toHaveLength(3); + expect(cells.at(0).text()).toEqual('Team 1'); + expect(cells.at(1).text()).toEqual('Organization The Org'); + expect(cells.at(2).text()).toEqual('something something team'); + }); +}); diff --git a/awx/ui_next/src/screens/User/UserTeams/UserTeams.jsx b/awx/ui_next/src/screens/User/UserTeams/UserTeams.jsx index 1cfc018236..65ddf670e5 100644 --- a/awx/ui_next/src/screens/User/UserTeams/UserTeams.jsx +++ b/awx/ui_next/src/screens/User/UserTeams/UserTeams.jsx @@ -1,6 +1,6 @@ import React from 'react'; -import UserTeamsList from './UserTeamsList'; +import UserTeamList from './UserTeamList'; export default function UserTeams() { - return ; + return ; } From 5eeb8b0337cf74f5d6ce9d29de2bbe184da7f329 Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 27 May 2020 11:15:41 -0700 Subject: [PATCH 03/49] fix extra_vars when not prompted during launch --- .../components/LaunchPrompt/LaunchPrompt.jsx | 5 ++- .../LaunchPrompt/steps/PreviewStep.jsx | 35 +++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx index 71d441e572..94f2f8b582 100644 --- a/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/LaunchPrompt.jsx @@ -44,7 +44,10 @@ function LaunchPrompt({ config, resource, onLaunch, onCancel, i18n }) { setValue('limit', values.limit); setValue('job_tags', values.job_tags); setValue('skip_tags', values.skip_tags); - setValue('extra_vars', mergeExtraVars(values.extra_vars, surveyValues)); + const extraVars = config.ask_variables_on_launch + ? values.extra_vars || '---' + : resource.extra_vars; + setValue('extra_vars', mergeExtraVars(extraVars, surveyValues)); setValue('scm_branch', values.scm_branch); onLaunch(postValues); }; diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx index f7e8ed1c36..835fbba18d 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.jsx @@ -8,27 +8,32 @@ import getSurveyValues from '../getSurveyValues'; function PreviewStep({ resource, config, survey, formErrors }) { const { values } = useFormikContext(); const surveyValues = getSurveyValues(values); - let extraVars; - if (survey && survey.spec) { - const passwordFields = survey.spec - .filter(q => q.type === 'password') - .map(q => q.variable); - const masked = maskPasswords(surveyValues, passwordFields); - extraVars = yaml.safeDump( - mergeExtraVars(values.extra_vars || '---', masked) - ); - } else { - extraVars = values.extra_vars || '---'; + + const overrides = { ...values }; + + if (config.ask_variables_on_launch || config.survey_enabled) { + const initialExtraVars = config.ask_variables_on_launch + ? values.extra_vars || '---' + : resource.extra_vars; + if (survey && survey.spec) { + const passwordFields = survey.spec + .filter(q => q.type === 'password') + .map(q => q.variable); + const masked = maskPasswords(surveyValues, passwordFields); + overrides.extra_vars = yaml.safeDump( + mergeExtraVars(initialExtraVars, masked) + ); + } else { + overrides.extra_vars = initialExtraVars; + } } + return ( <> {formErrors && (
    From fe7df910e2d84a9d4eb4756ee94fa4b15c93dedc Mon Sep 17 00:00:00 2001 From: Keith Grant Date: Wed, 27 May 2020 11:29:44 -0700 Subject: [PATCH 04/49] add tests for preview step extra vars --- .../components/LaunchPrompt/mergeExtraVars.js | 2 +- .../LaunchPrompt/mergeExtraVars.test.js | 4 ++++ .../LaunchPrompt/steps/PreviewStep.test.jsx | 24 ++++++++++++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js index 261c02a875..5cb60ac2ac 100644 --- a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js +++ b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.js @@ -1,6 +1,6 @@ import yaml from 'js-yaml'; -export default function mergeExtraVars(extraVars, survey = {}) { +export default function mergeExtraVars(extraVars = '', survey = {}) { const vars = yaml.safeLoad(extraVars) || {}; return { ...vars, diff --git a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js index bd696ab9e5..bd3d04cae9 100644 --- a/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js +++ b/awx/ui_next/src/components/LaunchPrompt/mergeExtraVars.test.js @@ -32,6 +32,10 @@ describe('mergeExtraVars', () => { }); }); + test('should handle undefined', () => { + expect(mergeExtraVars(undefined, undefined)).toEqual({}); + }); + describe('maskPasswords', () => { test('should mask password fields', () => { const vars = { diff --git a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx index 47aba96bb1..71a33b2fec 100644 --- a/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx +++ b/awx/ui_next/src/components/LaunchPrompt/steps/PreviewStep.test.jsx @@ -71,8 +71,30 @@ describe('PreviewStep', () => { expect(detail).toHaveLength(1); expect(detail.prop('resource')).toEqual(resource); expect(detail.prop('overrides')).toEqual({ - extra_vars: '---', limit: '4', }); }); + + test('should handle extra vars without survey', async () => { + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + + + + ); + }); + + const detail = wrapper.find('PromptDetail'); + expect(detail).toHaveLength(1); + expect(detail.prop('resource')).toEqual(resource); + expect(detail.prop('overrides')).toEqual({ + extra_vars: 'one: 1', + }); + }); }); From 8d6d5eeed8ff849ce18107d5729d985bc2a5b4e8 Mon Sep 17 00:00:00 2001 From: mstrent Date: Thu, 28 May 2020 09:17:45 -0700 Subject: [PATCH 05/49] Add subnet configuratin to Docker Compose to avoid conflicts. The out of the box subnet Docker Compose selects may conflict with your existing LAN subnets. This makes it configurable. --- installer/inventory | 3 +++ .../local_docker/templates/docker-compose.yml.j2 | 11 +++++++++++ 2 files changed, 14 insertions(+) diff --git a/installer/inventory b/installer/inventory index 4b644ba21d..e581001cbc 100644 --- a/installer/inventory +++ b/installer/inventory @@ -154,3 +154,6 @@ secret_key=awxsecret # which makes include "optional" - i.e. not fail # if file is absent #extra_nginx_include="/etc/nginx/awx_extra[.]conf" + +# Docker compose explicit subnet. Set to avoid overlapping your existing LAN networks. +#docker_compose_subnet="172.17.0.1/16" diff --git a/installer/roles/local_docker/templates/docker-compose.yml.j2 b/installer/roles/local_docker/templates/docker-compose.yml.j2 index 66ada06aba..7ecdcf2dad 100644 --- a/installer/roles/local_docker/templates/docker-compose.yml.j2 +++ b/installer/roles/local_docker/templates/docker-compose.yml.j2 @@ -171,6 +171,17 @@ services: https_proxy: {{ https_proxy | default('') }} no_proxy: {{ no_proxy | default('') }} {% endif %} + +{% if docker_compose_subnet is defined %} +networks: + default: + driver: bridge + ipam: + driver: default + config: + - subnet: {{ docker_compose_subnet }} +{% endif %} + volumes: supervisor-socket: rsyslog-socket: From 131f5ff0186545adec19e0cfe0d342e2d2cbdec6 Mon Sep 17 00:00:00 2001 From: Bill Nottingham Date: Thu, 28 May 2020 12:17:46 -0400 Subject: [PATCH 06/49] Remove dev env futzing of supervisord.conf permissions If we just link it into the dev env, we don't need to copy it at startup, and we don't need write permissions on it. --- installer/roles/image_build/templates/Dockerfile.j2 | 1 - tools/docker-compose-cluster.yml | 3 +++ tools/docker-compose.yml | 1 + tools/docker-compose/bootstrap_development.sh | 1 - 4 files changed, 4 insertions(+), 2 deletions(-) diff --git a/installer/roles/image_build/templates/Dockerfile.j2 b/installer/roles/image_build/templates/Dockerfile.j2 index 7e8ffe0e05..8acc2c2e7b 100644 --- a/installer/roles/image_build/templates/Dockerfile.j2 +++ b/installer/roles/image_build/templates/Dockerfile.j2 @@ -220,7 +220,6 @@ RUN for dir in \ /vendor ; \ do mkdir -m 0775 -p $dir ; chmod g+rw $dir ; chgrp root $dir ; done && \ for file in \ - /etc/supervisord.conf \ /var/run/nginx.pid \ /venv/awx/lib/python3.6/site-packages/awx.egg-link ; \ do touch $file ; chmod g+rw $file ; done diff --git a/tools/docker-compose-cluster.yml b/tools/docker-compose-cluster.yml index 4dc7ef49ce..95f7f5aaa8 100644 --- a/tools/docker-compose-cluster.yml +++ b/tools/docker-compose-cluster.yml @@ -31,6 +31,7 @@ services: - "../:/awx_devel" - "./redis/redis_socket_ha_1:/var/run/redis/" - "./memcached/:/var/run/memcached" + - "./docker-compose/supervisor.conf:/etc/supervisord.conf" ports: - "5899-5999:5899-5999" awx-2: @@ -50,6 +51,7 @@ services: - "../:/awx_devel" - "./redis/redis_socket_ha_2:/var/run/redis/" - "./memcached/:/var/run/memcached" + - "./docker-compose/supervisor.conf:/etc/supervisord.conf" ports: - "7899-7999:7899-7999" awx-3: @@ -69,6 +71,7 @@ services: - "../:/awx_devel" - "./redis/redis_socket_ha_3:/var/run/redis/" - "./memcached/:/var/run/memcached" + - "./docker-compose/supervisor.conf:/etc/supervisord.conf" ports: - "8899-8999:8899-8999" redis_1: diff --git a/tools/docker-compose.yml b/tools/docker-compose.yml index a96c835c02..d4dee7f101 100644 --- a/tools/docker-compose.yml +++ b/tools/docker-compose.yml @@ -35,6 +35,7 @@ services: - "./redis/redis_socket_standalone:/var/run/redis/" - "./memcached/:/var/run/memcached" - "./rsyslog/:/var/lib/awx/rsyslog" + - "./docker-compose/supervisor.conf:/etc/supervisord.conf" privileged: true tty: true # A useful container that simply passes through log messages to the console diff --git a/tools/docker-compose/bootstrap_development.sh b/tools/docker-compose/bootstrap_development.sh index 00642d5528..095d3e0d04 100755 --- a/tools/docker-compose/bootstrap_development.sh +++ b/tools/docker-compose/bootstrap_development.sh @@ -20,7 +20,6 @@ else fi make awx-link -yes | cp -rf /awx_devel/tools/docker-compose/supervisor.conf /etc/supervisord.conf # AWX bootstrapping make version_file From d3086206b46b9579289c42cf9d3694c766a376a6 Mon Sep 17 00:00:00 2001 From: Christian Adams Date: Tue, 26 May 2020 11:38:25 -0400 Subject: [PATCH 07/49] allow org admins to remove labels --- awx/main/access.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/awx/main/access.py b/awx/main/access.py index f1bbc42683..4705fb2cfc 100644 --- a/awx/main/access.py +++ b/awx/main/access.py @@ -495,7 +495,7 @@ class NotificationAttachMixin(BaseAccess): # due to this special case, we use symmetrical logic with attach permission return self._can_attach(notification_template=sub_obj, resource_obj=obj) return super(NotificationAttachMixin, self).can_unattach( - obj, sub_obj, relationship, relationship, data=data + obj, sub_obj, relationship, data=data ) From 8527991cb255efc271eb98f46c43fce7c2f932d3 Mon Sep 17 00:00:00 2001 From: nixocio Date: Wed, 27 May 2020 09:47:26 -0400 Subject: [PATCH 08/49] Add section about issues to be translated Add steps related to issues to be translated. --- awx/ui_next/CONTRIBUTING.md | 83 ++++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 34 deletions(-) diff --git a/awx/ui_next/CONTRIBUTING.md b/awx/ui_next/CONTRIBUTING.md index f2b41cee68..85c3ce18e3 100644 --- a/awx/ui_next/CONTRIBUTING.md +++ b/awx/ui_next/CONTRIBUTING.md @@ -6,24 +6,33 @@ Have questions about this document or anything not covered here? Feel free to re ## Table of contents -* [Things to know prior to submitting code](#things-to-know-prior-to-submitting-code) -* [Setting up your development environment](#setting-up-your-development-environment) - * [Prerequisites](#prerequisites) - * [Node and npm](#node-and-npm) -* [Build the user interface](#build-the-user-interface) -* [Accessing the AWX web interface](#accessing-the-awx-web-interface) -* [AWX REST API Interaction](#awx-rest-api-interaction) -* [Handling API Errors](#handling-api-errors) -* [Forms](#forms) -* [Working with React](#working-with-react) - * [App structure](#app-structure) - * [Naming files](#naming-files) - * [Class constructors vs Class properties](#class-constructors-vs-class-properties) - * [Binding](#binding) - * [Typechecking with PropTypes](#typechecking-with-proptypes) - * [Naming Functions](#naming-functions) - * [Default State Initialization](#default-state-initialization) -* [Internationalization](#internationalization) +- [Ansible AWX UI With PatternFly](#ansible-awx-ui-with-patternfly) + - [Table of contents](#table-of-contents) + - [Things to know prior to submitting code](#things-to-know-prior-to-submitting-code) + - [Setting up your development environment](#setting-up-your-development-environment) + - [Prerequisites](#prerequisites) + - [Node and npm](#node-and-npm) + - [Build the User Interface](#build-the-user-interface) + - [Accessing the AWX web interface](#accessing-the-awx-web-interface) + - [AWX REST API Interaction](#awx-rest-api-interaction) + - [Handling API Errors](#handling-api-errors) + - [Forms](#forms) + - [Working with React](#working-with-react) + - [App structure](#app-structure) + - [Patterns](#patterns) + - [Bootstrapping the application (root src/ files)](#bootstrapping-the-application-root-src-files) + - [Naming files](#naming-files) + - [Naming components that use the context api](#naming-components-that-use-the-context-api) + - [Class constructors vs Class properties](#class-constructors-vs-class-properties) + - [Binding](#binding) + - [Typechecking with PropTypes](#typechecking-with-proptypes) + - [Naming Functions](#naming-functions) + - [Default State Initialization](#default-state-initialization) + - [Testing components that use contexts](#testing-components-that-use-contexts) + - [Internationalization](#internationalization) + - [Marking strings for translation and replacement in the UI](#marking-strings-for-translation-and-replacement-in-the-ui) + - [Setting up .po files to give to translation team](#setting-up-po-files-to-give-to-translation-team) + - [Marking an issue to be translated](#marking-an-issue-to-be-translated) ## Things to know prior to submitting code @@ -35,7 +44,7 @@ Have questions about this document or anything not covered here? Feel free to re - functions should adopt camelCase - constructors/classes should adopt PascalCase - constants to be exported should adopt UPPERCASE -- For strings, we adopt the `sentence capitalization` since it is a (patternfly style guide)[https://www.patternfly.org/v4/design-guidelines/content/grammar-and-terminology#capitalization]. +- For strings, we adopt the `sentence capitalization` since it is a [Patternfly style guide](https://www.patternfly.org/v4/design-guidelines/content/grammar-and-terminology#capitalization). ## Setting up your development environment @@ -237,21 +246,21 @@ About.defaultProps = { ### Naming Functions Here are the guidelines for how to name functions. -| Naming Convention | Description | -|----------|-------------| -|`handle`| Use for methods that process events | -|`on`| Use for component prop names | -|`toggle`| Use for methods that flip one value to the opposite value | -|`show`| Use for methods that always set a value to show or add an element | -|`hide`| Use for methods that always set a value to hide or remove an element | -|`create`| Use for methods that make API `POST` requests | -|`read`| Use for methods that make API `GET` requests | -|`update`| Use for methods that make API `PATCH` requests | -|`destroy`| Use for methods that make API `DESTROY` requests | -|`replace`| Use for methods that make API `PUT` requests | -|`disassociate`| Use for methods that pass `{ disassociate: true }` as a data param to an endpoint | -|`associate`| Use for methods that pass a resource id as a data param to an endpoint | -|`can`| Use for props dealing with RBAC to denote whether a user has access to something | +| Naming Convention | Description | +| ----------------- | --------------------------------------------------------------------------------- | +| `handle` | Use for methods that process events | +| `on` | Use for component prop names | +| `toggle` | Use for methods that flip one value to the opposite value | +| `show` | Use for methods that always set a value to show or add an element | +| `hide` | Use for methods that always set a value to hide or remove an element | +| `create` | Use for methods that make API `POST` requests | +| `read` | Use for methods that make API `GET` requests | +| `update` | Use for methods that make API `PATCH` requests | +| `destroy` | Use for methods that make API `DESTROY` requests | +| `replace` | Use for methods that make API `PUT` requests | +| `disassociate` | Use for methods that pass `{ disassociate: true }` as a data param to an endpoint | +| `associate` | Use for methods that pass a resource id as a data param to an endpoint | +| `can` | Use for props dealing with RBAC to denote whether a user has access to something | ### Default State Initialization When declaring empty initial states, prefer the following instead of leaving them undefined: @@ -320,3 +329,9 @@ You can learn more about the ways lingui and its React helpers at [this link](ht 3) Open up the .po file for the language you want to test and add some translations. In production we would pass this .po file off to the translation team. 4) Once you've edited your .po file (or we've gotten a .po file back from the translation team) run `npm run compile-strings`. This command takes the .po files and turns them into a minified JSON object and can be seen in the `messages.js` file in each locale directory. These files get loaded at the App root level (see: App.jsx). 5) Change the language in your browser and reload the page. You should see your specified translations in place of English strings. + +### Marking an issue to be translated + +1) Issues marked with `component:I10n` should not be closed after the issue was fixed. +2) Remove the label `state:needs_devel`. +3) Add the label `state:pending_translations`. At this point, the translations will be batch translated by a maintainer, creating relevant entries in the PO files. Then after those translations have been merged, the issue can be closed. From 4f6d7e56eb57d8257dc4a6c699df00dcbb22b75d Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Wed, 20 May 2020 12:40:55 -0400 Subject: [PATCH 09/49] Adds support to user and team access add --- .../UserAccessAdd/UserAndTeamAccessAdd.jsx | 155 ++++++++++++ .../UserAndTeamAccessAdd.test.jsx | 225 ++++++++++++++++++ .../src/components/UserAccessAdd/index.js | 1 + .../UserAccessAdd/resources.data.jsx | 208 ++++++++++++++++ .../Team/TeamAccess/TeamAccessList.jsx | 48 ++-- .../Team/TeamAccess/TeamAccessList.test.jsx | 63 +++++ .../User/UserAccess/UserAccessList.jsx | 116 +++++---- .../User/UserAccess/UserAccessList.test.jsx | 96 +++++++- 8 files changed, 848 insertions(+), 64 deletions(-) create mode 100644 awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.jsx create mode 100644 awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.test.jsx create mode 100644 awx/ui_next/src/components/UserAccessAdd/index.js create mode 100644 awx/ui_next/src/components/UserAccessAdd/resources.data.jsx diff --git a/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.jsx b/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.jsx new file mode 100644 index 0000000000..ad2c254f06 --- /dev/null +++ b/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.jsx @@ -0,0 +1,155 @@ +import React, { useState, useCallback } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useParams } from 'react-router-dom'; +import styled from 'styled-components'; +import useRequest, { useDismissableError } from '../../util/useRequest'; +import SelectableCard from '../SelectableCard'; +import AlertModal from '../AlertModal'; +import ErrorDetail from '../ErrorDetail'; +import Wizard from '../Wizard/Wizard'; +import useSelected from '../../util/useSelected'; +import SelectResourceStep from '../AddRole/SelectResourceStep'; +import SelectRoleStep from '../AddRole/SelectRoleStep'; +import getResourceTypes from './resources.data'; + +const Grid = styled.div` + display: grid; + grid-gap: 20px; + grid-template-columns: 33% 33% 33%; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); +`; + +function UserAndTeamAccessAdd({ + i18n, + isOpen, + title, + onSave, + apiModel, + onClose, +}) { + const [selectedResourceType, setSelectedResourceType] = useState(); + const [stepIdReached, setStepIdReached] = useState(1); + const { id: userId } = useParams(); + const { + selected: resourcesSelected, + handleSelect: handleResourceSelect, + } = useSelected([]); + + const { + selected: rolesSelected, + handleSelect: handleRoleSelect, + } = useSelected([]); + + const { request: handleWizardSave, error: saveError } = useRequest( + useCallback(async () => { + const roleRequests = []; + const resourceRolesTypes = resourcesSelected.flatMap(resource => + Object.values(resource.summary_fields.object_roles) + ); + + rolesSelected.map(role => + resourceRolesTypes.forEach(rolename => { + if (rolename.name === role.name) { + roleRequests.push(apiModel.associateRole(userId, rolename.id)); + } + }) + ); + + await Promise.all(roleRequests); + onSave(); + }, [onSave, rolesSelected, apiModel, userId, resourcesSelected]), + {} + ); + + const { error, dismissError } = useDismissableError(saveError); + + const steps = [ + { + id: 1, + name: i18n._(t`Add resource type`), + component: ( + + {getResourceTypes(i18n).map(resource => ( + setSelectedResourceType(resource)} + /> + ))} + + ), + }, + { + id: 2, + name: i18n._(t`Select items from list`), + component: selectedResourceType && ( + + ), + enableNext: resourcesSelected.length > 0, + canJumpTo: stepIdReached >= 2, + }, + { + id: 3, + name: i18n._(t`Select roles to apply`), + component: resourcesSelected?.length > 0 && ( + + ), + nextButtonText: i18n._(t`Save`), + canJumpTo: stepIdReached >= 3, + }, + ]; + + if (error) { + return ( + + {i18n._(t`Failed to associate role`)} + + + ); + } + + return ( + + setStepIdReached(stepIdReached < id ? id : stepIdReached) + } + onSave={handleWizardSave} + /> + ); +} + +export default withI18n()(UserAndTeamAccessAdd); diff --git a/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.test.jsx b/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.test.jsx new file mode 100644 index 0000000000..0a703a7024 --- /dev/null +++ b/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.test.jsx @@ -0,0 +1,225 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { UsersAPI, JobTemplatesAPI } from '../../api'; +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; +import UserAndTeamAccessAdd from './UserAndTeamAccessAdd'; + +jest.mock('../../api/models/Teams'); +jest.mock('../../api/models/Users'); +jest.mock('../../api/models/JobTemplates'); + +describe('', () => { + const resources = { + data: { + results: [ + { + id: 1, + name: 'Job Template Foo Bar', + url: '/api/v2/job_template/1/', + summary_fields: { + object_roles: { + admin_role: { + description: 'Can manage all aspects of the job template', + name: 'Admin', + id: 164, + }, + execute_role: { + description: 'May run the job template', + name: 'Execute', + id: 165, + }, + read_role: { + description: 'May view settings for the job template', + name: 'Read', + id: 166, + }, + }, + }, + }, + ], + count: 1, + }, + }; + let wrapper; + beforeEach(async () => { + await act(async () => { + wrapper = mountWithContexts( + {}} + onClose={() => {}} + title="Add user permissions" + /> + ); + }); + }); + afterEach(() => { + wrapper.unmount(); + jest.clearAllMocks(); + }); + test('should mount properly', async () => { + expect(wrapper.find('PFWizard').length).toBe(1); + }); + test('should disable steps', async () => { + expect( + wrapper + .find('WizardNavItem[text="Select ttems from list"]') + .prop('isDisabled') + ).toBe(true); + expect( + wrapper + .find('WizardNavItem[text="Select roles to apply"]') + .prop('isDisabled') + ).toBe(true); + await act(async () => + wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({ + fetchItems: JobTemplatesAPI.read, + label: 'Job template', + selectedResource: 'jobTemplate', + searchColumns: [{ name: 'Name', key: 'name', isDefault: true }], + sortColumns: [{ name: 'Name', key: 'name' }], + }) + ); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + wrapper.update(); + expect( + wrapper.find('WizardNavItem[text="Add resource type"]').prop('isDisabled') + ).toBe(false); + expect( + wrapper + .find('WizardNavItem[text="Select ttems from list"]') + .prop('isDisabled') + ).toBe(false); + expect( + wrapper + .find('WizardNavItem[text="Select roles to apply"]') + .prop('isDisabled') + ).toBe(true); + }); + + test('should call api to associate role', async () => { + JobTemplatesAPI.read.mockResolvedValue(resources); + UsersAPI.associateRole.mockResolvedValue({}); + + await act(async () => + wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({ + fetchItems: JobTemplatesAPI.read, + label: 'Job template', + selectedResource: 'jobTemplate', + searchColumns: [{ name: 'Name', key: 'name', isDefault: true }], + sortColumns: [{ name: 'Name', key: 'name' }], + }) + ); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + await waitForElement(wrapper, 'SelectResourceStep', el => el.length > 0); + expect(JobTemplatesAPI.read).toHaveBeenCalled(); + await act(async () => + wrapper + .find('CheckboxListItem') + .first() + .find('input[type="checkbox"]') + .simulate('change', { target: { checked: true } }) + ); + + wrapper.update(); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + + wrapper.update(); + + expect(wrapper.find('RolesStep').length).toBe(1); + + await act(async () => + wrapper + .find('CheckboxCard') + .first() + .prop('onSelect')() + ); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + + await expect(UsersAPI.associateRole).toHaveBeenCalled(); + }); + + test('should throw error', async () => { + JobTemplatesAPI.read.mockResolvedValue(resources); + UsersAPI.associateRole.mockRejectedValue( + new Error({ + response: { + config: { + method: 'post', + url: '/api/v2/users/a/roles', + }, + data: 'An error occurred', + status: 403, + }, + }) + ); + + jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: () => ({ + id: 'a', + }), + })); + + await act(async () => + wrapper.find('SelectableCard[label="Job templates"]').prop('onClick')({ + fetchItems: JobTemplatesAPI.read, + label: 'Job template', + selectedResource: 'jobTemplate', + searchColumns: [{ name: 'Name', key: 'name', isDefault: true }], + sortColumns: [{ name: 'Name', key: 'name' }], + }) + ); + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + await waitForElement(wrapper, 'SelectResourceStep', el => el.length > 0); + expect(JobTemplatesAPI.read).toHaveBeenCalled(); + await act(async () => + wrapper + .find('CheckboxListItem') + .first() + .find('input[type="checkbox"]') + .simulate('change', { target: { checked: true } }) + ); + + wrapper.update(); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + + wrapper.update(); + + expect(wrapper.find('RolesStep').length).toBe(1); + + await act(async () => + wrapper + .find('CheckboxCard') + .first() + .prop('onSelect')() + ); + + await act(async () => + wrapper.find('Button[type="submit"]').prop('onClick')() + ); + + await expect(UsersAPI.associateRole).toHaveBeenCalled(); + wrapper.update(); + expect(wrapper.find('AlertModal').length).toBe(1); + }); +}); diff --git a/awx/ui_next/src/components/UserAccessAdd/index.js b/awx/ui_next/src/components/UserAccessAdd/index.js new file mode 100644 index 0000000000..c445f018ba --- /dev/null +++ b/awx/ui_next/src/components/UserAccessAdd/index.js @@ -0,0 +1 @@ +export { default } from './UserAndTeamAccessAdd'; diff --git a/awx/ui_next/src/components/UserAccessAdd/resources.data.jsx b/awx/ui_next/src/components/UserAccessAdd/resources.data.jsx new file mode 100644 index 0000000000..0000036c59 --- /dev/null +++ b/awx/ui_next/src/components/UserAccessAdd/resources.data.jsx @@ -0,0 +1,208 @@ +import { t } from '@lingui/macro'; +import { + JobTemplatesAPI, + WorkflowJobTemplatesAPI, + CredentialsAPI, + InventoriesAPI, + ProjectsAPI, + OrganizationsAPI, +} from '../../api'; + +export default function getResourceTypes(i18n) { + return [ + { + selectedResource: 'jobTemplate', + label: i18n._(t`Job templates`), + searchColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Playbook name`), + key: 'playbook', + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ], + sortColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ], + fetchItems: () => JobTemplatesAPI.read(), + }, + { + selectedResource: 'workflowJobTemplate', + label: i18n._(t`Workflow job templates`), + searchColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Playbook name`), + key: 'playbook', + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ], + sortColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ], + fetchItems: () => WorkflowJobTemplatesAPI.read(), + }, + { + selectedResource: 'credential', + label: i18n._(t`Credentials`), + searchColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Type`), + key: 'scm_type', + options: [ + [``, i18n._(t`Manual`)], + [`git`, i18n._(t`Git`)], + [`hg`, i18n._(t`Mercurial`)], + [`svn`, i18n._(t`Subversion`)], + [`insights`, i18n._(t`Red Hat Insights`)], + ], + }, + { + name: i18n._(t`Source Control URL`), + key: 'scm_url', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + ], + sortColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ], + fetchItems: () => CredentialsAPI.read(), + }, + { + selectedResource: 'inventory', + label: i18n._(t`Inventories`), + searchColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ], + sortColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ], + fetchItems: () => InventoriesAPI.read(), + }, + { + selectedResource: 'project', + label: i18n._(t`Projects`), + searchColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Type`), + key: 'scm_type', + options: [ + [``, i18n._(t`Manual`)], + [`git`, i18n._(t`Git`)], + [`hg`, i18n._(t`Mercurial`)], + [`svn`, i18n._(t`Subversion`)], + [`insights`, i18n._(t`Red Hat Insights`)], + ], + }, + { + name: i18n._(t`Source Control URL`), + key: 'scm_url', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + ], + sortColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ], + fetchItems: () => ProjectsAPI.read(), + }, + { + selectedResource: 'organization', + label: i18n._(t`Organizations`), + searchColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ], + sortColumns: [ + { + name: i18n._(t`Name`), + key: 'name', + }, + ], + fetchItems: () => OrganizationsAPI.read(), + }, + ]; +} diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx index 79531fb561..6c6f2c2910 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx @@ -1,19 +1,18 @@ -import React, { useCallback, useEffect } from 'react'; -import { useLocation, useRouteMatch, useParams } from 'react-router-dom'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { Card } from '@patternfly/react-core'; +import { Button } from '@patternfly/react-core'; import { TeamsAPI } from '../../../api'; import useRequest from '../../../util/useRequest'; import DataListToolbar from '../../../components/DataListToolbar'; -import PaginatedDataList, { - ToolbarAddButton, -} from '../../../components/PaginatedDataList'; +import PaginatedDataList from '../../../components/PaginatedDataList'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import TeamAccessListItem from './TeamAccessListItem'; +import UserAndTeamAccessAdd from '../../../components/UserAccessAdd/UserAndTeamAccessAdd'; const QS_CONFIG = getQSConfig('team', { page: 1, @@ -22,8 +21,8 @@ const QS_CONFIG = getQSConfig('team', { }); function TeamAccessList({ i18n }) { + const [isWizardOpen, setIsWizardOpen] = useState(false); const { search } = useLocation(); - const match = useRouteMatch(); const { id } = useParams(); const { @@ -57,6 +56,11 @@ function TeamAccessList({ i18n }) { fetchRoles(); }, [fetchRoles]); + const saveRoles = () => { + setIsWizardOpen(false); + fetchRoles(); + }; + const canAdd = options && Object.prototype.hasOwnProperty.call(options, 'POST'); @@ -77,7 +81,7 @@ function TeamAccessList({ i18n }) { }; return ( - + <> ] + ? [ + , + ] : []), ]} /> @@ -117,13 +131,17 @@ function TeamAccessList({ i18n }) { onSelect={() => {}} /> )} - emptyStateControls={ - canAdd ? ( - - ) : null - } /> - + {isWizardOpen && ( + setIsWizardOpen(false)} + title={i18n._(t`Add team permissions`)} + /> + )} + ); } 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 index b7b5e26025..88311c8643 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.test.jsx @@ -141,4 +141,67 @@ describe('', () => { '/inventories/smart_inventory/77/details' ); }); + test('should not render add button', async () => { + TeamsAPI.readRoleOptions.mockResolvedValueOnce({ + data: {}, + }); + + 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 }, + }, + description: 'Can manage all aspects of the job template', + }, + ], + count: 1, + }, + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 18 } }, + }, + }, + }, + } + ); + }); + + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe( + 0 + ); + }); + test('should open and close wizard', async () => { + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + await act(async () => + wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('PFWizard').length).toBe(1); + await act(async () => + wrapper.find("Button[aria-label='Close']").prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('PFWizard').length).toBe(0); + }); }); diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx index db62a14bd6..b44a83f303 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx @@ -1,15 +1,16 @@ -import React, { useCallback, useEffect } from 'react'; +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 { getQSConfig, parseQueryString } from '../../../util/qs'; import { UsersAPI } from '../../../api'; import useRequest from '../../../util/useRequest'; -import PaginatedDataList, { - ToolbarAddButton, -} from '../../../components/PaginatedDataList'; +import PaginatedDataList from '../../../components/PaginatedDataList'; import DatalistToolbar from '../../../components/DataListToolbar'; import UserAccessListItem from './UserAccessListItem'; +import UserAndTeamAccessAdd from '../../../components/UserAccessAdd/UserAndTeamAccessAdd'; const QS_CONFIG = getQSConfig('roles', { page: 1, @@ -22,6 +23,7 @@ const QS_CONFIG = getQSConfig('roles', { function UserAccessList({ i18n }) { const { id } = useParams(); const { search } = useLocation(); + const [isWizardOpen, setIsWizardOpen] = useState(false); const { isLoading, @@ -55,6 +57,11 @@ function UserAccessList({ i18n }) { const canAdd = options && Object.prototype.hasOwnProperty.call(options, 'POST'); + const saveRoles = () => { + setIsWizardOpen(false); + fetchRoles(); + }; + const detailUrl = role => { const { resource_id, resource_type } = role.summary_fields; @@ -72,48 +79,71 @@ function UserAccessList({ i18n }) { }; return ( - { - return ( - {}} - isSelected={false} + <> + { + return ( + {}} + isSelected={false} + /> + ); + }} + renderToolbar={props => ( + { + setIsWizardOpen(true); + }} + > + Add + , + ] + : []), + ]} /> - ); - }} - renderToolbar={props => ( - ] : []), - ]} + )} + /> + {isWizardOpen && ( + setIsWizardOpen(false)} + title={i18n._(t`Add user permissions`)} /> )} - /> + ); } 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 index acb1400608..8a59012a30 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.test.jsx @@ -29,6 +29,7 @@ describe('', () => { resource_type_display_name: 'Job Template', user_capabilities: { unattach: true }, }, + description: 'Can manage all aspects of the job template', }, { id: 3, @@ -42,6 +43,7 @@ describe('', () => { resource_type_display_name: 'Job Template', user_capabilities: { unattach: true }, }, + description: 'Can manage all aspects of the job template', }, { id: 4, @@ -55,10 +57,11 @@ describe('', () => { resource_type_display_name: 'Credential', user_capabilities: { unattach: true }, }, + description: 'May run the job template', }, { id: 5, - name: 'Update', + name: 'Read', type: 'role', url: '/api/v2/roles/259/', summary_fields: { @@ -68,6 +71,7 @@ describe('', () => { resource_type_display_name: 'Inventory', user_capabilities: { unattach: true }, }, + description: 'May view settings for the job template', }, { id: 6, @@ -75,15 +79,16 @@ describe('', () => { type: 'role', url: '/api/v2/roles/260/', summary_fields: { - resource_name: 'Smart Inventory Foo', + resource_name: 'Project Foo', resource_id: 77, - resource_type: 'smart_inventory', - resource_type_display_name: 'Inventory', + resource_type: 'project', + resource_type_display_name: 'Project', user_capabilities: { unattach: true }, }, + description: 'Can manage all aspects of the job template', }, ], - count: 4, + count: 5, }, }); @@ -138,7 +143,86 @@ describe('', () => { '/inventories/inventory/76/details' ); expect(wrapper.find('Link#userRole-6').prop('to')).toBe( - '/inventories/smart_inventory/77/details' + '/projects/77/details' ); }); + test('should not render add button', async () => { + UsersAPI.readRoleOptions.mockResolvedValueOnce({ + data: {}, + }); + + 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 }, + object_roles: { + admin_role: { + description: 'Can manage all aspects of the job template', + name: 'Admin', + id: 164, + }, + execute_role: { + description: 'May run the job template', + name: 'Execute', + id: 165, + }, + read_role: { + description: 'May view settings for the job template', + name: 'Read', + id: 166, + }, + }, + }, + }, + ], + count: 1, + }, + }); + await act(async () => { + wrapper = mountWithContexts( + + + , + { + context: { + router: { + history, + route: { + location: history.location, + match: { params: { id: 18 } }, + }, + }, + }, + } + ); + }); + + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + expect(wrapper.find('Button[aria-label="Add resource roles"]').length).toBe( + 0 + ); + }); + test('should open and close wizard', async () => { + waitForElement(wrapper, 'ContentEmpty', el => el.length === 0); + await act(async () => + wrapper.find('Button[aria-label="Add resource roles"]').prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('PFWizard').length).toBe(1); + await act(async () => + wrapper.find("Button[aria-label='Close']").prop('onClick')() + ); + wrapper.update(); + expect(wrapper.find('PFWizard').length).toBe(0); + }); }); From 585ca082e330af99ae7bd26b3a77366ad3138375 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 28 May 2020 10:20:15 -0400 Subject: [PATCH 10/49] Improves naming and updates resource list and adds search functionality --- .../components/AddRole/AddResourceRole.jsx | 4 +- .../components/AddRole/SelectResourceStep.jsx | 202 ++++++++---------- .../AddRole/SelectResourceStep.test.jsx | 99 +++------ .../UserAndTeamAccessAdd.jsx | 9 +- .../UserAndTeamAccessAdd.test.jsx | 11 +- .../getResourceAccessConfig.js} | 14 +- .../index.js | 0 .../Team/TeamAccess/TeamAccessList.jsx | 2 +- .../User/UserAccess/UserAccessList.jsx | 2 +- 9 files changed, 147 insertions(+), 196 deletions(-) rename awx/ui_next/src/components/{UserAccessAdd => UserAndTeamAccessAdd}/UserAndTeamAccessAdd.jsx (94%) rename awx/ui_next/src/components/{UserAccessAdd => UserAndTeamAccessAdd}/UserAndTeamAccessAdd.test.jsx (94%) rename awx/ui_next/src/components/{UserAccessAdd/resources.data.jsx => UserAndTeamAccessAdd/getResourceAccessConfig.js} (90%) rename awx/ui_next/src/components/{UserAccessAdd => UserAndTeamAccessAdd}/index.js (100%) diff --git a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx index 639a6e8128..5727a78c74 100644 --- a/awx/ui_next/src/components/AddRole/AddResourceRole.jsx +++ b/awx/ui_next/src/components/AddRole/AddResourceRole.jsx @@ -258,7 +258,7 @@ class AddResourceRole extends React.Component { sortColumns={userSortColumns} displayKey="username" onRowClick={this.handleResourceCheckboxClick} - onSearch={readUsers} + fetchItems={readUsers} selectedLabel={i18n._(t`Selected`)} selectedResourceRows={selectedResourceRows} sortedColumnKey="username" @@ -269,7 +269,7 @@ class AddResourceRole extends React.Component { searchColumns={teamSearchColumns} sortColumns={teamSortColumns} onRowClick={this.handleResourceCheckboxClick} - onSearch={readTeams} + fetchItems={readTeams} selectedLabel={i18n._(t`Selected`)} selectedResourceRows={selectedResourceRows} /> diff --git a/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx b/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx index 9427bca17b..461327587e 100644 --- a/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx +++ b/awx/ui_next/src/components/AddRole/SelectResourceStep.jsx @@ -1,8 +1,10 @@ -import React, { Fragment } from 'react'; +import React, { Fragment, useCallback, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { withRouter } from 'react-router-dom'; +import { withRouter, useLocation } from 'react-router-dom'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; +import useRequest from '../../util/useRequest'; + import { SearchColumns, SortColumns } from '../../types'; import PaginatedDataList from '../PaginatedDataList'; import DataListToolbar from '../DataListToolbar'; @@ -10,124 +12,94 @@ import CheckboxListItem from '../CheckboxListItem'; import SelectedList from '../SelectedList'; import { getQSConfig, parseQueryString } from '../../util/qs'; -class SelectResourceStep extends React.Component { - constructor(props) { - super(props); +const QS_Config = sortColumns => { + return getQSConfig('resource', { + page: 1, + page_size: 5, + order_by: `${ + sortColumns.filter(col => col.key === 'name').length ? 'name' : 'username' + }`, + }); +}; +function SelectResourceStep({ + searchColumns, + sortColumns, + displayKey, + onRowClick, + selectedLabel, + selectedResourceRows, + fetchItems, + i18n, +}) { + const location = useLocation(); - this.state = { - isInitialized: false, - count: null, - error: false, + const { + isLoading, + error, + request: readResourceList, + result: { resources, itemCount }, + } = useRequest( + useCallback(async () => { + const queryParams = parseQueryString( + QS_Config(sortColumns), + location.search + ); + + const { + data: { count, results }, + } = await fetchItems(queryParams); + return { resources: results, itemCount: count }; + }, [location, fetchItems, sortColumns]), + { resources: [], - }; - - this.qsConfig = getQSConfig('resource', { - page: 1, - page_size: 5, - order_by: `${ - props.sortColumns.filter(col => col.key === 'name').length - ? 'name' - : 'username' - }`, - }); - } - - componentDidMount() { - this.readResourceList(); - } - - componentDidUpdate(prevProps) { - const { location } = this.props; - if (location !== prevProps.location) { - this.readResourceList(); + itemCount: 0, } - } + ); - async readResourceList() { - const { onSearch, location } = this.props; - const queryParams = parseQueryString(this.qsConfig, location.search); + useEffect(() => { + readResourceList(); + }, [readResourceList]); - this.setState({ - isLoading: true, - error: false, - }); - try { - const { data } = await onSearch(queryParams); - const { count, results } = data; - - this.setState({ - resources: results, - count, - isInitialized: true, - isLoading: false, - error: false, - }); - } catch (err) { - this.setState({ - isLoading: false, - error: true, - }); - } - } - - render() { - const { isInitialized, isLoading, count, error, resources } = this.state; - - const { - searchColumns, - sortColumns, - displayKey, - onRowClick, - selectedLabel, - selectedResourceRows, - i18n, - } = this.props; - - return ( - - {isInitialized && ( - -
    - {i18n._( - t`Choose the resources that will be receiving new roles. You'll be able to select the roles to apply in the next step. Note that the resources chosen here will receive all roles chosen in the next step.` - )} -
    - {selectedResourceRows.length > 0 && ( - - )} - ( - i.id === item.id)} - itemId={item.id} - key={item.id} - name={item[displayKey]} - label={item[displayKey]} - onSelect={() => onRowClick(item)} - onDeselect={() => onRowClick(item)} - /> - )} - renderToolbar={props => } - showPageSizeOptions={false} - /> -
    + return ( + +
    + {i18n._( + t`Choose the resources that will be receiving new roles. You'll be able to select the roles to apply in the next step. Note that the resources chosen here will receive all roles chosen in the next step.` )} - {error ?
    error
    : ''} - - ); - } +
    + {selectedResourceRows.length > 0 && ( + + )} + ( + i.id === item.id)} + itemId={item.id} + key={item.id} + name={item[displayKey]} + label={item[displayKey]} + onSelect={() => onRowClick(item)} + onDeselect={() => onRowClick(item)} + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + /> +
    + ); } SelectResourceStep.propTypes = { @@ -135,7 +107,7 @@ SelectResourceStep.propTypes = { sortColumns: SortColumns, displayKey: PropTypes.string, onRowClick: PropTypes.func, - onSearch: PropTypes.func.isRequired, + fetchItems: PropTypes.func.isRequired, selectedLabel: PropTypes.string, selectedResourceRows: PropTypes.arrayOf(PropTypes.object), }; diff --git a/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx b/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx index e925044ed5..d309ea706f 100644 --- a/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx +++ b/awx/ui_next/src/components/AddRole/SelectResourceStep.test.jsx @@ -1,7 +1,11 @@ import React from 'react'; -import { createMemoryHistory } from 'history'; +import { act } from 'react-dom/test-utils'; + import { shallow } from 'enzyme'; -import { mountWithContexts } from '../../../testUtils/enzymeHelpers'; +import { + mountWithContexts, + waitForElement, +} from '../../../testUtils/enzymeHelpers'; import { sleep } from '../../../testUtils/testUtils'; import SelectResourceStep from './SelectResourceStep'; @@ -30,12 +34,12 @@ describe('', () => { sortColumns={sortColumns} displayKey="username" onRowClick={() => {}} - onSearch={() => {}} + fetchItems={() => {}} /> ); }); - test('fetches resources on mount', async () => { + test('fetches resources on mount and adds items to list', async () => { const handleSearch = jest.fn().mockResolvedValue({ data: { count: 2, @@ -45,61 +49,24 @@ describe('', () => { ], }, }); - mountWithContexts( - {}} - onSearch={handleSearch} - /> - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + {}} + fetchItems={handleSearch} + /> + ); + }); expect(handleSearch).toHaveBeenCalledWith({ order_by: 'username', page: 1, page_size: 5, }); - }); - - test('readResourceList properly adds rows to state', async () => { - const selectedResourceRows = [{ id: 1, username: 'foo', url: 'item/1' }]; - const handleSearch = jest.fn().mockResolvedValue({ - data: { - count: 2, - results: [ - { id: 1, username: 'foo', url: 'item/1' }, - { id: 2, username: 'bar', url: 'item/2' }, - ], - }, - }); - const history = createMemoryHistory({ - initialEntries: [ - '/organizations/1/access?resource.page=1&resource.order_by=-username', - ], - }); - const wrapper = mountWithContexts( - {}} - onSearch={handleSearch} - selectedResourceRows={selectedResourceRows} - />, - { - context: { router: { history, route: { location: history.location } } }, - } - ).find('SelectResourceStep'); - await wrapper.instance().readResourceList(); - expect(handleSearch).toHaveBeenCalledWith({ - order_by: '-username', - page: 1, - page_size: 5, - }); - expect(wrapper.state('resources')).toEqual([ - { id: 1, username: 'foo', url: 'item/1' }, - { id: 2, username: 'bar', url: 'item/2' }, - ]); + waitForElement(wrapper, 'CheckBoxListItem', el => el.length === 2); }); test('clicking on row fires callback with correct params', async () => { @@ -111,20 +78,24 @@ describe('', () => { { id: 2, username: 'bar', url: 'item/2' }, ], }; - const wrapper = mountWithContexts( - ({ data })} - selectedResourceRows={[]} - /> - ); + let wrapper; + await act(async () => { + wrapper = mountWithContexts( + ({ data })} + selectedResourceRows={[]} + /> + ); + }); await sleep(0); wrapper.update(); const checkboxListItemWrapper = wrapper.find('CheckboxListItem'); expect(checkboxListItemWrapper.length).toBe(2); + checkboxListItemWrapper .first() .find('input[type="checkbox"]') diff --git a/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx similarity index 94% rename from awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.jsx rename to awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx index ad2c254f06..ceac1a32f5 100644 --- a/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.jsx +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.jsx @@ -11,7 +11,7 @@ import Wizard from '../Wizard/Wizard'; import useSelected from '../../util/useSelected'; import SelectResourceStep from '../AddRole/SelectResourceStep'; import SelectRoleStep from '../AddRole/SelectRoleStep'; -import getResourceTypes from './resources.data'; +import getResourceAccessConfig from './getResourceAccessConfig'; const Grid = styled.div` display: grid; @@ -28,7 +28,7 @@ function UserAndTeamAccessAdd({ apiModel, onClose, }) { - const [selectedResourceType, setSelectedResourceType] = useState(); + const [selectedResourceType, setSelectedResourceType] = useState(null); const [stepIdReached, setStepIdReached] = useState(1); const { id: userId } = useParams(); const { @@ -70,7 +70,7 @@ function UserAndTeamAccessAdd({ name: i18n._(t`Add resource type`), component: ( - {getResourceTypes(i18n).map(resource => ( + {getResourceAccessConfig(i18n).map(resource => ( ), + enableNext: selectedResourceType !== null, }, { id: 2, @@ -94,7 +95,7 @@ function UserAndTeamAccessAdd({ sortColumns={selectedResourceType.sortColumns} displayKey="name" onRowClick={handleResourceSelect} - onSearch={selectedResourceType.fetchItems} + fetchItems={selectedResourceType.fetchItems} selectedLabel={i18n._(t`Selected`)} selectedResourceRows={resourcesSelected} sortedColumnKey="username" diff --git a/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.test.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx similarity index 94% rename from awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.test.jsx rename to awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx index 0a703a7024..b80c24a493 100644 --- a/awx/ui_next/src/components/UserAccessAdd/UserAndTeamAccessAdd.test.jsx +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/UserAndTeamAccessAdd.test.jsx @@ -65,9 +65,10 @@ describe('', () => { expect(wrapper.find('PFWizard').length).toBe(1); }); test('should disable steps', async () => { + expect(wrapper.find('Button[type="submit"]').prop('isDisabled')).toBe(true); expect( wrapper - .find('WizardNavItem[text="Select ttems from list"]') + .find('WizardNavItem[text="Select items from list"]') .prop('isDisabled') ).toBe(true); expect( @@ -93,7 +94,7 @@ describe('', () => { ).toBe(false); expect( wrapper - .find('WizardNavItem[text="Select ttems from list"]') + .find('WizardNavItem[text="Select items from list"]') .prop('isDisabled') ).toBe(false); expect( @@ -119,6 +120,12 @@ describe('', () => { await act(async () => wrapper.find('Button[type="submit"]').prop('onClick')() ); + expect(JobTemplatesAPI.read).toHaveBeenCalledWith({ + order_by: 'name', + page: 1, + page_size: 5, + }); + await waitForElement(wrapper, 'SelectResourceStep', el => el.length > 0); expect(JobTemplatesAPI.read).toHaveBeenCalled(); await act(async () => diff --git a/awx/ui_next/src/components/UserAccessAdd/resources.data.jsx b/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js similarity index 90% rename from awx/ui_next/src/components/UserAccessAdd/resources.data.jsx rename to awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js index 0000036c59..718476e70e 100644 --- a/awx/ui_next/src/components/UserAccessAdd/resources.data.jsx +++ b/awx/ui_next/src/components/UserAndTeamAccessAdd/getResourceAccessConfig.js @@ -8,7 +8,7 @@ import { OrganizationsAPI, } from '../../api'; -export default function getResourceTypes(i18n) { +export default function getResourceAccessConfig(i18n) { return [ { selectedResource: 'jobTemplate', @@ -38,7 +38,7 @@ export default function getResourceTypes(i18n) { key: 'name', }, ], - fetchItems: () => JobTemplatesAPI.read(), + fetchItems: queryParams => JobTemplatesAPI.read(queryParams), }, { selectedResource: 'workflowJobTemplate', @@ -68,7 +68,7 @@ export default function getResourceTypes(i18n) { key: 'name', }, ], - fetchItems: () => WorkflowJobTemplatesAPI.read(), + fetchItems: queryParams => WorkflowJobTemplatesAPI.read(queryParams), }, { selectedResource: 'credential', @@ -109,7 +109,7 @@ export default function getResourceTypes(i18n) { key: 'name', }, ], - fetchItems: () => CredentialsAPI.read(), + fetchItems: queryParams => CredentialsAPI.read(queryParams), }, { selectedResource: 'inventory', @@ -135,7 +135,7 @@ export default function getResourceTypes(i18n) { key: 'name', }, ], - fetchItems: () => InventoriesAPI.read(), + fetchItems: queryParams => InventoriesAPI.read(queryParams), }, { selectedResource: 'project', @@ -176,7 +176,7 @@ export default function getResourceTypes(i18n) { key: 'name', }, ], - fetchItems: () => ProjectsAPI.read(), + fetchItems: queryParams => ProjectsAPI.read(queryParams), }, { selectedResource: 'organization', @@ -202,7 +202,7 @@ export default function getResourceTypes(i18n) { key: 'name', }, ], - fetchItems: () => OrganizationsAPI.read(), + fetchItems: queryParams => OrganizationsAPI.read(queryParams), }, ]; } diff --git a/awx/ui_next/src/components/UserAccessAdd/index.js b/awx/ui_next/src/components/UserAndTeamAccessAdd/index.js similarity index 100% rename from awx/ui_next/src/components/UserAccessAdd/index.js rename to awx/ui_next/src/components/UserAndTeamAccessAdd/index.js diff --git a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx index 6c6f2c2910..516c090cdd 100644 --- a/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx +++ b/awx/ui_next/src/screens/Team/TeamAccess/TeamAccessList.jsx @@ -12,7 +12,7 @@ import DataListToolbar from '../../../components/DataListToolbar'; import PaginatedDataList from '../../../components/PaginatedDataList'; import { getQSConfig, parseQueryString } from '../../../util/qs'; import TeamAccessListItem from './TeamAccessListItem'; -import UserAndTeamAccessAdd from '../../../components/UserAccessAdd/UserAndTeamAccessAdd'; +import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd'; const QS_CONFIG = getQSConfig('team', { page: 1, diff --git a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx index b44a83f303..e48fa6f70d 100644 --- a/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx +++ b/awx/ui_next/src/screens/User/UserAccess/UserAccessList.jsx @@ -10,7 +10,7 @@ import useRequest from '../../../util/useRequest'; import PaginatedDataList from '../../../components/PaginatedDataList'; import DatalistToolbar from '../../../components/DataListToolbar'; import UserAccessListItem from './UserAccessListItem'; -import UserAndTeamAccessAdd from '../../../components/UserAccessAdd/UserAndTeamAccessAdd'; +import UserAndTeamAccessAdd from '../../../components/UserAndTeamAccessAdd/UserAndTeamAccessAdd'; const QS_CONFIG = getQSConfig('roles', { page: 1, From ca6ae24032c816ba8c7557e240f4dc631c2a0394 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Fri, 29 May 2020 09:19:14 -0400 Subject: [PATCH 11/49] makes whole card selectable --- .../src/components/AddRole/CheckboxCard.jsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/awx/ui_next/src/components/AddRole/CheckboxCard.jsx b/awx/ui_next/src/components/AddRole/CheckboxCard.jsx index a1b56939d0..361bf1a60d 100644 --- a/awx/ui_next/src/components/AddRole/CheckboxCard.jsx +++ b/awx/ui_next/src/components/AddRole/CheckboxCard.jsx @@ -1,19 +1,27 @@ import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; -import { Checkbox } from '@patternfly/react-core'; +import { Checkbox as PFCheckbox } from '@patternfly/react-core'; +import styled from 'styled-components'; + +const CheckboxWrapper = styled.div` + display: flex; + border: 1px solid var(--pf-global--BorderColor--200); + border-radius: var(--pf-global--BorderRadius--sm); + padding: 10px; +`; + +const Checkbox = styled(PFCheckbox)` + width: 100%; + & label { + width: 100%; + } +`; class CheckboxCard extends Component { render() { const { name, description, isSelected, onSelect, itemId } = this.props; return ( -
    + -
    + ); } } From 0f0e401c98ae8ba89a380374ea8a4112f1fff1cf Mon Sep 17 00:00:00 2001 From: Bill Nottingham Date: Fri, 29 May 2020 13:41:49 -0400 Subject: [PATCH 12/49] Hardcode --kubeconfig and therefore only support OpenShift 3.11+ Avoid trying to parse inconsitent oc --version output --- installer/roles/kubernetes/tasks/openshift_auth.yml | 8 -------- installer/roles/kubernetes/vars/openshift.yml | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/installer/roles/kubernetes/tasks/openshift_auth.yml b/installer/roles/kubernetes/tasks/openshift_auth.yml index 084e20c482..1b53cda59a 100644 --- a/installer/roles/kubernetes/tasks/openshift_auth.yml +++ b/installer/roles/kubernetes/tasks/openshift_auth.yml @@ -1,12 +1,4 @@ --- -- name: Determine version of oc - shell: oc version | sed -n 's/oc v//p' - register: oc_version - -- name: Use correct cli option for kubeconfig - set_fact: - oc_kubeconfig_option: "{{ '--kubeconfig' if oc_version.stdout is version('3.11', '>=') else '--config' }}" - - include_vars: openshift.yml - name: Set kubernetes_namespace diff --git a/installer/roles/kubernetes/vars/openshift.yml b/installer/roles/kubernetes/vars/openshift.yml index 1104914fb8..6e1fd30a3c 100644 --- a/installer/roles/kubernetes/vars/openshift.yml +++ b/installer/roles/kubernetes/vars/openshift.yml @@ -1,3 +1,3 @@ --- openshift_oc_config_file: "{{ kubernetes_base_path }}/.kube/config" -openshift_oc_bin: "oc {{ oc_kubeconfig_option }}={{ openshift_oc_config_file }}" +openshift_oc_bin: "oc --kubeconfig={{ openshift_oc_config_file }}" From 747fdf38d80e55255eb55508780dd86e0cbdd3e5 Mon Sep 17 00:00:00 2001 From: Shane McDonald Date: Fri, 29 May 2020 13:47:33 -0400 Subject: [PATCH 13/49] Stop bouncing k8s deployment post-install We shouldnt need to do this now that RabbitMQ autoclustering is gone. --- installer/roles/kubernetes/tasks/main.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/installer/roles/kubernetes/tasks/main.yml b/installer/roles/kubernetes/tasks/main.yml index 3ff39968b4..2b518b3f9a 100644 --- a/installer/roles/kubernetes/tasks/main.yml +++ b/installer/roles/kubernetes/tasks/main.yml @@ -292,7 +292,5 @@ - name: Scale up deployment shell: | - {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ - scale {{ deployment_object }} {{ kubernetes_deployment_name }} --replicas=0 {{ kubectl_or_oc }} -n {{ kubernetes_namespace }} \ scale {{ deployment_object }} {{ kubernetes_deployment_name }} --replicas={{ replicas | default(kubernetes_deployment_replica_size) }} From e373ae1e272df838866e387fd36a8fd17b34b341 Mon Sep 17 00:00:00 2001 From: Christian Adams Date: Mon, 1 Jun 2020 00:42:38 -0400 Subject: [PATCH 14/49] Changelog entry for label removal issue --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd60d6ba69..34870e1f5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ This is a list of high-level changes for each release of AWX. A full list of com - Fixed a bug that broke local pip installs of awxkit (https://github.com/ansible/awx/issues/7107) - Fixed a bug that prevented PagerDuty notifications from sending for workflow job template approvals (https://github.com/ansible/awx/issues/7094) - Fixed a bug that broke external log aggregation support for URL paths that include the = character (such as the tokens for SumoLogic) (https://github.com/ansible/awx/issues/7139) +- Fixed a bug that prevented organization admins from removing labels from workflow job templates (https://github.com/ansible/awx/pull/7143) ## 11.2.0 (Apr 29, 2020) From cfe8a1722c1aeb70c5b26a939672d6975e880de2 Mon Sep 17 00:00:00 2001 From: Ryan Petrello Date: Mon, 1 Jun 2020 09:47:33 -0400 Subject: [PATCH 15/49] properly quote conjur URLs that contain spaces see: https://github.com/ansible/awx/issues/7191 --- awx/main/credential_plugins/conjur.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/awx/main/credential_plugins/conjur.py b/awx/main/credential_plugins/conjur.py index a82b91893b..313b766bdc 100644 --- a/awx/main/credential_plugins/conjur.py +++ b/awx/main/credential_plugins/conjur.py @@ -1,7 +1,7 @@ from .plugin import CredentialPlugin, CertFiles import base64 -from urllib.parse import urljoin, quote_plus +from urllib.parse import urljoin, quote from django.utils.translation import ugettext_lazy as _ import requests @@ -50,9 +50,9 @@ conjur_inputs = { def conjur_backend(**kwargs): url = kwargs['url'] api_key = kwargs['api_key'] - account = quote_plus(kwargs['account']) - username = quote_plus(kwargs['username']) - secret_path = quote_plus(kwargs['secret_path']) + account = quote(kwargs['account']) + username = quote(kwargs['username']) + secret_path = quote(kwargs['secret_path']) version = kwargs.get('secret_version') cacert = kwargs.get('cacert', None) From 58737a64e19c35f2855975d7d97282309f5f8d58 Mon Sep 17 00:00:00 2001 From: Christian Adams Date: Mon, 1 Jun 2020 14:18:54 -0400 Subject: [PATCH 16/49] Add basic functional tests for labels --- awx/main/tests/functional/test_labels.py | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 awx/main/tests/functional/test_labels.py diff --git a/awx/main/tests/functional/test_labels.py b/awx/main/tests/functional/test_labels.py new file mode 100644 index 0000000000..fad1869d0e --- /dev/null +++ b/awx/main/tests/functional/test_labels.py @@ -0,0 +1,37 @@ +import pytest + +# awx +from awx.main.models import WorkflowJobTemplate +from awx.api.versioning import reverse + + +@pytest.mark.django_db +def test_workflow_can_add_label(org_admin,organization, post): + # create workflow + wfjt = WorkflowJobTemplate.objects.create(name='test-wfjt') + wfjt.organization = organization + # create label + wfjt.admin_role.members.add(org_admin) + url = reverse('api:workflow_job_template_label_list', kwargs={'pk': wfjt.pk}) + data = {'name': 'dev-label', 'organization': organization.id} + label = post(url, user=org_admin, data=data, expect=201) + assert label.data['name'] == 'dev-label' + + +@pytest.mark.django_db +def test_workflow_can_remove_label(org_admin, organization, post, get): + # create workflow + wfjt = WorkflowJobTemplate.objects.create(name='test-wfjt') + wfjt.organization = organization + # create label + wfjt.admin_role.members.add(org_admin) + label = wfjt.labels.create(name='dev-label', organization=organization) + # delete label + url = reverse('api:workflow_job_template_label_list', kwargs={'pk': wfjt.pk}) + data = { + "id": label.pk, + "disassociate": True + } + post(url, data, org_admin, expect=204) + results = get(url, org_admin, expect=200) + assert results.data['count'] == 0 From 068d9660b303e15868a1848dfc1ccbfd1c846eb8 Mon Sep 17 00:00:00 2001 From: mabashian Date: Mon, 1 Jun 2020 14:40:33 -0400 Subject: [PATCH 17/49] Addresses npm audit security report vulnerabilities with http-proxy which is a dependency of some of our dependencies. --- awx/ui_next/package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/awx/ui_next/package-lock.json b/awx/ui_next/package-lock.json index f9a6b9cf15..57577d86d9 100644 --- a/awx/ui_next/package-lock.json +++ b/awx/ui_next/package-lock.json @@ -6690,9 +6690,9 @@ "integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=" }, "eventemitter3": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.0.tgz", - "integrity": "sha512-qerSRB0p+UDEssxTtm6EDKcE7W4OaoisfIMl4CngyEhjpYglocpNg6UEqCvemdGhosAsg4sO2dXJOdyBifPGCg==" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.4.tgz", + "integrity": "sha512-rlaVLnVxtxvoyLsQQFBx53YmXHDxRIzzTLbdfxqi4yocpSjAxXwkU0cScM5JgSKMqEhrZpnvQ2D9gjylR0AimQ==" }, "events": { "version": "3.1.0", @@ -7996,9 +7996,9 @@ "integrity": "sha1-ksnBN0w1CF912zWexWzCV8u5P6Q=" }, "http-proxy": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.0.tgz", - "integrity": "sha512-84I2iJM/n1d4Hdgc6y2+qY5mDaz2PUVjlg9znE9byl+q0uC3DeByqBGReQu5tpLK0TAqTIXScRUV+dg7+bUPpQ==", + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", "requires": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", From 4b95297bd4af478cd0d4d1e3d483ab45e10ce538 Mon Sep 17 00:00:00 2001 From: mabashian Date: Thu, 21 May 2020 14:43:19 -0400 Subject: [PATCH 18/49] Adds basic credential plugin support to relevant fields in the static credential forms. --- awx/ui_next/src/api/index.js | 19 ++- .../src/api/models/CredentialInputSources.js | 10 ++ awx/ui_next/src/api/models/Credentials.js | 7 + .../components/FormField/PasswordField.jsx | 54 +----- .../components/FormField/PasswordInput.jsx | 71 ++++++++ awx/ui_next/src/components/FormField/index.js | 1 + .../CredentialAdd/CredentialAdd.jsx | 33 +++- .../CredentialEdit/CredentialEdit.jsx | 93 ++++++++-- .../CredentialEdit/CredentialEdit.test.jsx | 1 + .../Credential/shared/CredentialForm.jsx | 12 +- .../CredentialPluginField.jsx | 103 ++++++++++++ .../CredentialPluginPrompt.jsx | 60 +++++++ .../CredentialsStep.jsx | 101 +++++++++++ .../CredentialPluginPrompt/MetadataStep.jsx | 159 ++++++++++++++++++ .../CredentialPluginPrompt/index.js | 3 + .../CredentialPluginSelected.jsx | 54 ++++++ .../shared/CredentialPlugins/index.js | 2 + .../GoogleComputeEngineSubForm.jsx | 33 ++-- .../CredentialSubForms/SharedFields.jsx | 37 ++-- 19 files changed, 756 insertions(+), 97 deletions(-) create mode 100644 awx/ui_next/src/api/models/CredentialInputSources.js create mode 100644 awx/ui_next/src/components/FormField/PasswordInput.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx create mode 100644 awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js diff --git a/awx/ui_next/src/api/index.js b/awx/ui_next/src/api/index.js index e5f1f34557..6a48a6ce21 100644 --- a/awx/ui_next/src/api/index.js +++ b/awx/ui_next/src/api/index.js @@ -1,5 +1,6 @@ import AdHocCommands from './models/AdHocCommands'; import Config from './models/Config'; +import CredentialInputSources from './models/CredentialInputSources'; import CredentialTypes from './models/CredentialTypes'; import Credentials from './models/Credentials'; import Groups from './models/Groups'; @@ -14,8 +15,8 @@ import Labels from './models/Labels'; import Me from './models/Me'; import NotificationTemplates from './models/NotificationTemplates'; import Organizations from './models/Organizations'; -import Projects from './models/Projects'; import ProjectUpdates from './models/ProjectUpdates'; +import Projects from './models/Projects'; import Root from './models/Root'; import Schedules from './models/Schedules'; import SystemJobs from './models/SystemJobs'; @@ -24,14 +25,15 @@ import UnifiedJobTemplates from './models/UnifiedJobTemplates'; import UnifiedJobs from './models/UnifiedJobs'; import Users from './models/Users'; import WorkflowApprovalTemplates from './models/WorkflowApprovalTemplates'; -import WorkflowJobs from './models/WorkflowJobs'; import WorkflowJobTemplateNodes from './models/WorkflowJobTemplateNodes'; import WorkflowJobTemplates from './models/WorkflowJobTemplates'; +import WorkflowJobs from './models/WorkflowJobs'; const AdHocCommandsAPI = new AdHocCommands(); const ConfigAPI = new Config(); -const CredentialsAPI = new Credentials(); +const CredentialInputSourcesAPI = new CredentialInputSources(); const CredentialTypesAPI = new CredentialTypes(); +const CredentialsAPI = new Credentials(); const GroupsAPI = new Groups(); const HostsAPI = new Hosts(); const InstanceGroupsAPI = new InstanceGroups(); @@ -44,8 +46,8 @@ const LabelsAPI = new Labels(); const MeAPI = new Me(); const NotificationTemplatesAPI = new NotificationTemplates(); const OrganizationsAPI = new Organizations(); -const ProjectsAPI = new Projects(); const ProjectUpdatesAPI = new ProjectUpdates(); +const ProjectsAPI = new Projects(); const RootAPI = new Root(); const SchedulesAPI = new Schedules(); const SystemJobsAPI = new SystemJobs(); @@ -54,15 +56,16 @@ const UnifiedJobTemplatesAPI = new UnifiedJobTemplates(); const UnifiedJobsAPI = new UnifiedJobs(); const UsersAPI = new Users(); const WorkflowApprovalTemplatesAPI = new WorkflowApprovalTemplates(); -const WorkflowJobsAPI = new WorkflowJobs(); const WorkflowJobTemplateNodesAPI = new WorkflowJobTemplateNodes(); const WorkflowJobTemplatesAPI = new WorkflowJobTemplates(); +const WorkflowJobsAPI = new WorkflowJobs(); export { AdHocCommandsAPI, ConfigAPI, - CredentialsAPI, + CredentialInputSourcesAPI, CredentialTypesAPI, + CredentialsAPI, GroupsAPI, HostsAPI, InstanceGroupsAPI, @@ -75,8 +78,8 @@ export { MeAPI, NotificationTemplatesAPI, OrganizationsAPI, - ProjectsAPI, ProjectUpdatesAPI, + ProjectsAPI, RootAPI, SchedulesAPI, SystemJobsAPI, @@ -85,7 +88,7 @@ export { UnifiedJobsAPI, UsersAPI, WorkflowApprovalTemplatesAPI, - WorkflowJobsAPI, WorkflowJobTemplateNodesAPI, WorkflowJobTemplatesAPI, + WorkflowJobsAPI, }; diff --git a/awx/ui_next/src/api/models/CredentialInputSources.js b/awx/ui_next/src/api/models/CredentialInputSources.js new file mode 100644 index 0000000000..ec09cba267 --- /dev/null +++ b/awx/ui_next/src/api/models/CredentialInputSources.js @@ -0,0 +1,10 @@ +import Base from '../Base'; + +class CredentialInputSources extends Base { + constructor(http) { + super(http); + this.baseUrl = '/api/v2/credential_input_sources/'; + } +} + +export default CredentialInputSources; diff --git a/awx/ui_next/src/api/models/Credentials.js b/awx/ui_next/src/api/models/Credentials.js index 9b31506956..ec7f97812d 100644 --- a/awx/ui_next/src/api/models/Credentials.js +++ b/awx/ui_next/src/api/models/Credentials.js @@ -6,6 +6,7 @@ class Credentials extends Base { this.baseUrl = '/api/v2/credentials/'; this.readAccessList = this.readAccessList.bind(this); + this.readInputSources = this.readInputSources.bind(this); } readAccessList(id, params) { @@ -13,6 +14,12 @@ class Credentials extends Base { params, }); } + + readInputSources(id, params) { + return this.http.get(`${this.baseUrl}${id}/input_sources/`, { + params, + }); + } } export default Credentials; diff --git a/awx/ui_next/src/components/FormField/PasswordField.jsx b/awx/ui_next/src/components/FormField/PasswordField.jsx index d865a8b70c..c813ca29c1 100644 --- a/awx/ui_next/src/components/FormField/PasswordField.jsx +++ b/awx/ui_next/src/components/FormField/PasswordField.jsx @@ -1,29 +1,14 @@ -import React, { useState } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; -import { withI18n } from '@lingui/react'; -import { t } from '@lingui/macro'; import { useField } from 'formik'; -import { - Button, - ButtonVariant, - FormGroup, - InputGroup, - TextInput, - Tooltip, -} from '@patternfly/react-core'; -import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons'; +import { FormGroup, InputGroup } from '@patternfly/react-core'; +import { PasswordInput } from '.'; function PasswordField(props) { - const { id, name, label, validate, isRequired, isDisabled, i18n } = props; - const [inputType, setInputType] = useState('password'); - const [field, meta] = useField({ name, validate }); - + const { id, name, label, validate, isRequired } = props; + const [, meta] = useField({ name, validate }); const isValid = !(meta.touched && meta.error); - const handlePasswordToggle = () => { - setInputType(inputType === 'text' ? 'password' : 'text'); - }; - return ( - - - - { - field.onChange(event); - }} - /> + ); @@ -79,4 +39,4 @@ PasswordField.defaultProps = { isDisabled: false, }; -export default withI18n()(PasswordField); +export default PasswordField; diff --git a/awx/ui_next/src/components/FormField/PasswordInput.jsx b/awx/ui_next/src/components/FormField/PasswordInput.jsx new file mode 100644 index 0000000000..993ee9a523 --- /dev/null +++ b/awx/ui_next/src/components/FormField/PasswordInput.jsx @@ -0,0 +1,71 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { + Button, + ButtonVariant, + TextInput, + Tooltip, +} from '@patternfly/react-core'; +import { EyeIcon, EyeSlashIcon } from '@patternfly/react-icons'; + +function PasswordInput(props) { + const { id, name, validate, isRequired, isDisabled, i18n } = props; + const [inputType, setInputType] = useState('password'); + const [field, meta] = useField({ name, validate }); + + const isValid = !(meta.touched && meta.error); + + const handlePasswordToggle = () => { + setInputType(inputType === 'text' ? 'password' : 'text'); + }; + + return ( + <> + + + + { + field.onChange(event); + }} + /> + + ); +} + +PasswordInput.propTypes = { + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + validate: PropTypes.func, + isRequired: PropTypes.bool, + isDisabled: PropTypes.bool, +}; + +PasswordInput.defaultProps = { + validate: () => {}, + isRequired: false, + isDisabled: false, +}; + +export default withI18n()(PasswordInput); diff --git a/awx/ui_next/src/components/FormField/index.js b/awx/ui_next/src/components/FormField/index.js index 563f8519eb..fd0c95dafd 100644 --- a/awx/ui_next/src/components/FormField/index.js +++ b/awx/ui_next/src/components/FormField/index.js @@ -2,4 +2,5 @@ export { default } from './FormField'; export { default as CheckboxField } from './CheckboxField'; export { default as FieldTooltip } from './FieldTooltip'; export { default as PasswordField } from './PasswordField'; +export { default as PasswordInput } from './PasswordInput'; export { default as FormSubmitError } from './FormSubmitError'; diff --git a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx index e42b3faec7..e75581e463 100644 --- a/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialAdd/CredentialAdd.jsx @@ -5,7 +5,11 @@ import { CardBody } from '../../../components/Card'; import ContentError from '../../../components/ContentError'; import ContentLoading from '../../../components/ContentLoading'; -import { CredentialTypesAPI, CredentialsAPI } from '../../../api'; +import { + CredentialInputSourcesAPI, + CredentialTypesAPI, + CredentialsAPI, +} from '../../../api'; import CredentialForm from '../shared/CredentialForm'; function CredentialAdd({ me }) { @@ -38,16 +42,41 @@ function CredentialAdd({ me }) { }; const handleSubmit = async values => { - const { organization, ...remainingValues } = values; + const { inputs, organization, ...remainingValues } = values; + let pluginInputs = []; + const inputEntries = Object.entries(inputs); + for (const [key, value] of inputEntries) { + if (value.credential && value.inputs) { + pluginInputs.push([key, value]); + delete inputs[key]; + } + } + setFormSubmitError(null); + try { const { data: { id: credentialId }, } = await CredentialsAPI.create({ user: (me && me.id) || null, organization: (organization && organization.id) || null, + inputs: inputs || {}, ...remainingValues, }); + const inputSourceRequests = []; + for (const [key, value] of pluginInputs) { + if (value.credential && value.inputs) { + inputSourceRequests.push( + CredentialInputSourcesAPI.create({ + input_field_name: key, + metadata: value.inputs, + source_credential: value.credential.id, + target_credential: credentialId, + }) + ); + } + } + await Promise.all(inputSourceRequests); const url = `/credentials/${credentialId}/details`; history.push(`${url}`); } catch (err) { diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx index 71409638f6..05980f4ec6 100644 --- a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.jsx @@ -3,7 +3,11 @@ import { useHistory } from 'react-router-dom'; import { object } from 'prop-types'; import { CardBody } from '../../../components/Card'; -import { CredentialsAPI, CredentialTypesAPI } from '../../../api'; +import { + CredentialsAPI, + CredentialInputSourcesAPI, + CredentialTypesAPI, +} from '../../../api'; import ContentError from '../../../components/ContentError'; import ContentLoading from '../../../components/ContentLoading'; import CredentialForm from '../shared/CredentialForm'; @@ -12,18 +16,32 @@ function CredentialEdit({ credential, me }) { const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); const [credentialTypes, setCredentialTypes] = useState(null); + const [inputSources, setInputSources] = useState(null); const [formSubmitError, setFormSubmitError] = useState(null); const history = useHistory(); useEffect(() => { const loadData = async () => { try { - const { - data: { results: loadedCredentialTypes }, - } = await CredentialTypesAPI.read({ - or__namespace: ['gce', 'scm', 'ssh'], - }); + const [ + { + data: { results: loadedCredentialTypes }, + }, + { + data: { results: loadedInputSources }, + }, + ] = await Promise.all([ + CredentialTypesAPI.read({ + or__namespace: ['gce', 'scm', 'ssh'], + }), + CredentialsAPI.readInputSources(credential.id, { page_size: 200 }), + ]); setCredentialTypes(loadedCredentialTypes); + const inputSourcesMap = {}; + loadedInputSources.forEach(inputSource => { + inputSourcesMap[inputSource.input_field_name] = inputSource; + }); + setInputSources(inputSourcesMap); } catch (err) { setError(err); } finally { @@ -31,7 +49,7 @@ function CredentialEdit({ credential, me }) { } }; loadData(); - }, []); + }, [credential.id]); const handleCancel = () => { const url = `/credentials/${credential.id}/details`; @@ -39,20 +57,62 @@ function CredentialEdit({ credential, me }) { history.push(`${url}`); }; + const createAndUpdateInputSources = pluginInputs => + Object.entries(pluginInputs).map(([fieldName, fieldValue]) => { + if (!inputSources[fieldName]) { + return CredentialInputSourcesAPI.create({ + input_field_name: fieldName, + metadata: fieldValue.inputs, + source_credential: fieldValue.credential.id, + target_credential: credential.id, + }); + } else if (fieldValue.touched) { + return CredentialInputSourcesAPI.update(inputSources[fieldName].id, { + metadata: fieldValue.inputs, + source_credential: fieldValue.credential.id, + }); + } + + return null; + }); + + const destroyInputSources = inputs => { + const destroyRequests = []; + Object.values(inputSources).forEach(inputSource => { + const { id, input_field_name } = inputSource; + if (!inputs[input_field_name]?.credential) { + destroyRequests.push(CredentialInputSourcesAPI.destroy(id)); + } + }); + return destroyRequests; + }; + const handleSubmit = async values => { - const { organization, ...remainingValues } = values; + const { inputs, organization, ...remainingValues } = values; + let pluginInputs = {}; + const inputEntries = Object.entries(inputs); + for (const [key, value] of inputEntries) { + if (value.credential && value.inputs) { + pluginInputs[key] = value; + delete inputs[key]; + } + } setFormSubmitError(null); try { - const { - data: { id: credentialId }, - } = await CredentialsAPI.update(credential.id, { - user: (me && me.id) || null, - organization: (organization && organization.id) || null, - ...remainingValues, - }); - const url = `/credentials/${credentialId}/details`; + await Promise.all([ + CredentialsAPI.update(credential.id, { + user: (me && me.id) || null, + organization: (organization && organization.id) || null, + inputs: inputs || {}, + ...remainingValues, + }), + ...destroyInputSources(pluginInputs), + ]); + await Promise.all(createAndUpdateInputSources(pluginInputs)); + const url = `/credentials/${credential.id}/details`; history.push(`${url}`); } catch (err) { + console.log(err); setFormSubmitError(err); } }; @@ -72,6 +132,7 @@ function CredentialEdit({ credential, me }) { onSubmit={handleSubmit} credential={credential} credentialTypes={credentialTypes} + inputSources={inputSources} submitError={formSubmitError} /> diff --git a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx index 4b0ab68187..3d4ce756cb 100644 --- a/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx +++ b/awx/ui_next/src/screens/Credential/CredentialEdit/CredentialEdit.test.jsx @@ -245,6 +245,7 @@ CredentialTypesAPI.read.mockResolvedValue({ }); CredentialsAPI.update.mockResolvedValue({ data: { id: 3 } }); +CredentialsAPI.readInputSources.mockResolvedValue({ data: { results: [] } }); describe('', () => { let wrapper; diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx index bccfa50583..ddd4ccaa5f 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialForm.jsx @@ -2,7 +2,7 @@ import React from 'react'; import { Formik, useField } from 'formik'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; -import { func, shape } from 'prop-types'; +import { arrayOf, func, object, shape } from 'prop-types'; import { Form, FormGroup, Title } from '@patternfly/react-core'; import FormField, { FormSubmitError } from '../../../components/FormField'; import FormActionGroup from '../../../components/FormActionGroup/FormActionGroup'; @@ -124,6 +124,7 @@ function CredentialFormFields({ function CredentialForm({ credential = {}, credentialTypes, + inputSources, onSubmit, onCancel, submitError, @@ -147,6 +148,13 @@ function CredentialForm({ }, }; + Object.values(inputSources).forEach(inputSource => { + initialValues.inputs[inputSource.input_field_name] = { + credential: inputSource.summary_fields.source_credential, + inputs: inputSource.metadata, + }; + }); + const scmCredentialTypeId = Object.keys(credentialTypes) .filter(key => credentialTypes[key].namespace === 'scm') .map(key => credentialTypes[key].id)[0]; @@ -232,10 +240,12 @@ CredentialForm.proptype = { handleSubmit: func.isRequired, handleCancel: func.isRequired, credential: shape({}), + inputSources: arrayOf(object), }; CredentialForm.defaultProps = { credential: {}, + inputSources: [], }; export default withI18n()(CredentialForm); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx new file mode 100644 index 0000000000..096827fde7 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginField.jsx @@ -0,0 +1,103 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { + Button, + ButtonVariant, + FormGroup, + InputGroup, + Tooltip, +} from '@patternfly/react-core'; +import { KeyIcon } from '@patternfly/react-icons'; +import { CredentialPluginPrompt } from './CredentialPluginPrompt'; +import { CredentialPluginSelected } from '.'; + +function CredentialPluginField(props) { + const { + children, + id, + name, + label, + validate, + isRequired, + isDisabled, + i18n, + } = props; + const [showPluginWizard, setShowPluginWizard] = useState(false); + const [field, meta, helpers] = useField({ name, validate }); + const isValid = !(meta.touched && meta.error); + + return ( + + {field.value.credential ? ( + helpers.setValue('')} + onEditPlugin={() => setShowPluginWizard(true)} + /> + ) : ( + + {React.cloneElement(children, { + ...field, + isRequired, + onChange: (_, event) => { + field.onChange(event); + }, + })} + + + + + )} + {showPluginWizard && ( + setShowPluginWizard(false)} + onSubmit={val => { + val.touched = true; + helpers.setValue(val); + setShowPluginWizard(false); + }} + /> + )} + + ); +} + +CredentialPluginField.propTypes = { + id: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + validate: PropTypes.func, + isRequired: PropTypes.bool, + isDisabled: PropTypes.bool, +}; + +CredentialPluginField.defaultProps = { + validate: () => {}, + isRequired: false, + isDisabled: false, +}; + +export default withI18n()(CredentialPluginField); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx new file mode 100644 index 0000000000..d132c58997 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialPluginPrompt.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { Formik, useField } from 'formik'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { Wizard } from '@patternfly/react-core'; +import { CredentialsStep, MetadataStep } from './'; + +function CredentialPluginWizard({ i18n, handleSubmit, onClose }) { + const [selectedCredential] = useField('credential'); + const steps = [ + { + id: 1, + name: i18n._(t`Credential`), + component: , + }, + { + id: 2, + name: i18n._(t`Metadata`), + component: , + canJumpTo: !!selectedCredential.value, + nextButtonText: i18n._(t`OK`), + }, + ]; + + return ( + + ); +} + +function CredentialPluginPrompt({ i18n, onClose, onSubmit, initialValues }) { + return ( + + {({ handleSubmit }) => ( + + )} + + ); +} + +CredentialPluginPrompt.propTypes = {}; + +CredentialPluginPrompt.defaultProps = {}; + +export default withI18n()(CredentialPluginPrompt); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx new file mode 100644 index 0000000000..a59f894470 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/CredentialsStep.jsx @@ -0,0 +1,101 @@ +import React, { useCallback, useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField } from 'formik'; +import { CredentialsAPI } from '../../../../../api'; +import CheckboxListItem from '../../../../../components/CheckboxListItem'; +import ContentError from '../../../../../components/ContentError'; +import DataListToolbar from '../../../../../components/DataListToolbar'; +import PaginatedDataList from '../../../../../components/PaginatedDataList'; +import { getQSConfig, parseQueryString } from '../../../../../util/qs'; +import useRequest from '../../../../../util/useRequest'; + +const QS_CONFIG = getQSConfig('credential', { + page: 1, + page_size: 5, + order_by: 'name', + credential_type__kind: 'external', +}); + +function CredentialsStep({ i18n }) { + const [selectedCredential, , selectedCredentialHelper] = useField( + 'credential' + ); + const history = useHistory(); + + const { + result: { credentials, count }, + error: credentialsError, + isLoading: isCredentialsLoading, + request: fetchCredentials, + } = useRequest( + useCallback(async () => { + const params = parseQueryString(QS_CONFIG, history.location.search); + const { data } = await CredentialsAPI.read({ + ...params, + }); + return { + credentials: data.results, + count: data.count, + }; + }, [history.location.search]), + { credentials: [], count: 0 } + ); + + useEffect(() => { + fetchCredentials(); + }, [fetchCredentials]); + + if (credentialsError) { + return ; + } + + return ( + selectedCredentialHelper.setValue(row)} + qsConfig={QS_CONFIG} + renderItem={credential => ( + selectedCredentialHelper.setValue(credential)} + onDeselect={() => selectedCredentialHelper.setValue(null)} + isRadio + /> + )} + renderToolbar={props => } + showPageSizeOptions={false} + toolbarSearchColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + isDefault: true, + }, + { + name: i18n._(t`Created By (Username)`), + key: 'created_by__username', + }, + { + name: i18n._(t`Modified By (Username)`), + key: 'modified_by__username', + }, + ]} + toolbarSortColumns={[ + { + name: i18n._(t`Name`), + key: 'name', + }, + ]} + /> + ); +} + +export default withI18n()(CredentialsStep); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx new file mode 100644 index 0000000000..2114904ef2 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/MetadataStep.jsx @@ -0,0 +1,159 @@ +import React, { useCallback, useEffect } from 'react'; +import { withI18n } from '@lingui/react'; +import { t } from '@lingui/macro'; +import { useField, useFormikContext } from 'formik'; +import styled from 'styled-components'; +import { Button, Form, FormGroup, Tooltip } from '@patternfly/react-core'; +import { QuestionCircleIcon as PFQuestionCircleIcon } from '@patternfly/react-icons'; +import { CredentialTypesAPI } from '../../../../../api'; +import AnsibleSelect from '../../../../../components/AnsibleSelect'; +import ContentError from '../../../../../components/ContentError'; +import ContentLoading from '../../../../../components/ContentLoading'; +import FormField from '../../../../../components/FormField'; +import { FormFullWidthLayout } from '../../../../../components/FormLayout'; +import useRequest from '../../../../../util/useRequest'; +import { required } from '../../../../../util/validators'; + +const QuestionCircleIcon = styled(PFQuestionCircleIcon)` + margin-left: 10px; +`; + +const TestButton = styled(Button)` + margin-top: 20px; +`; + +function MetadataStep({ i18n }) { + const form = useFormikContext(); + const [selectedCredential] = useField('credential'); + const [inputValues] = useField('inputs'); + + const { + result: fields, + error, + isLoading, + request: fetchMetadataOptions, + } = useRequest( + useCallback(async () => { + const { + data: { + inputs: { required, metadata }, + }, + } = await CredentialTypesAPI.readDetail( + selectedCredential.value.credential_type || + selectedCredential.value.credential_type_id + ); + metadata.forEach(field => { + if (inputValues.value[field.id]) { + form.initialValues.inputs[field.id] = inputValues.value[field.id]; + } else { + if (field.type === 'string' && field.choices) { + form.initialValues.inputs[field.id] = + field.default || field.choices[0]; + } else { + form.initialValues.inputs[field.id] = ''; + } + } + if (required && required.includes(field.id)) { + field.required = true; + } + }); + return metadata; + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, []), + [] + ); + + useEffect(() => { + fetchMetadataOptions(); + }, [fetchMetadataOptions]); + + const testMetadata = () => { + alert('not implemented'); + }; + + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + return ( + <> + {fields.length > 0 && ( +
    + + {fields.map(field => { + if (field.type === 'string') { + if (field.choices) { + return ( + + {field.help_text && ( + + + + )} + { + return { + value: choice, + key: choice, + label: choice, + }; + })} + onChange={(event, value) => { + form.setFieldValue(`inputs.${field.id}`, value); + }} + validate={field.required ? required(null, i18n) : null} + /> + + ); + } + + return ( + + ); + } + + return null; + })} + +
    + )} + + testMetadata()} + > + {i18n._(t`Test`)} + + + + ); +} + +export default withI18n()(MetadataStep); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js new file mode 100644 index 0000000000..467b3f3936 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginPrompt/index.js @@ -0,0 +1,3 @@ +export { default as CredentialPluginPrompt } from './CredentialPluginPrompt'; +export { default as CredentialsStep } from './CredentialsStep'; +export { default as MetadataStep } from './MetadataStep'; diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx new file mode 100644 index 0000000000..2ea3ca84d0 --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/CredentialPluginSelected.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { withI18n } from '@lingui/react'; +import { t, Trans } from '@lingui/macro'; +import styled from 'styled-components'; +import { Button, ButtonVariant, Tooltip } from '@patternfly/react-core'; +import { KeyIcon } from '@patternfly/react-icons'; +import CredentialChip from '../../../../components/CredentialChip'; + +const SelectedCredential = styled.div` + display: flex; + justify-content: space-between; + margin-top: 10px; + background-color: white; + border-bottom-color: var(--pf-global--BorderColor--200); +`; + +const SpacedCredentialChip = styled(CredentialChip)` + margin: 5px 8px; +`; + +function CredentialPluginSelected({ + i18n, + credential, + onEditPlugin, + onClearPlugin, +}) { + return ( + <> +

    + + This field will be retrieved from an external secret management system + using the following credential: + +

    + + + + + + + + ); +} + +export default withI18n()(CredentialPluginSelected); diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js new file mode 100644 index 0000000000..033586567f --- /dev/null +++ b/awx/ui_next/src/screens/Credential/shared/CredentialPlugins/index.js @@ -0,0 +1,2 @@ +export { default as CredentialPluginSelected } from './CredentialPluginSelected'; +export { default as CredentialPluginField } from './CredentialPluginField'; diff --git a/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx index 89584b956c..2622106afb 100644 --- a/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx +++ b/awx/ui_next/src/screens/Credential/shared/CredentialSubForms/GoogleComputeEngineSubForm.jsx @@ -2,13 +2,18 @@ import React, { useState } from 'react'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; import { useField } from 'formik'; -import { FileUpload, FormGroup } from '@patternfly/react-core'; -import FormField from '../../../../components/FormField'; +import { + FileUpload, + FormGroup, + TextArea, + TextInput, +} from '@patternfly/react-core'; import { FormColumnLayout, FormFullWidthLayout, } from '../../../../components/FormLayout'; import { required } from '../../../../util/validators'; +import { CredentialPluginField } from '../CredentialPlugins'; const GoogleComputeEngineSubForm = ({ i18n }) => { const [fileError, setFileError] = useState(null); @@ -91,30 +96,38 @@ const GoogleComputeEngineSubForm = ({ i18n }) => { }} /> - - + + + + > + + - + > +