From fc4060778bab30c29826f963ef1a885aea856037 Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Mon, 24 Aug 2020 11:18:24 -0400 Subject: [PATCH 1/2] Disables template and workflow template fields for users that do not have permissions for those fields. --- .../src/components/Lookup/InventoryLookup.jsx | 18 ++++++++++-- awx/ui_next/src/components/Lookup/Lookup.jsx | 11 ++++++-- .../src/components/Lookup/ProjectLookup.jsx | 5 +++- .../MultiSelect/useSyncedSelectValue.js | 17 ++++++++--- .../JobTemplateEdit/JobTemplateEdit.jsx | 13 +-------- .../Template/shared/JobTemplateForm.jsx | 28 +++---------------- .../Template/shared/PlaybookSelect.jsx | 24 ++++++++++++---- 7 files changed, 66 insertions(+), 50 deletions(-) diff --git a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx index 3abb85604e..102abc4d60 100644 --- a/awx/ui_next/src/components/Lookup/InventoryLookup.jsx +++ b/awx/ui_next/src/components/Lookup/InventoryLookup.jsx @@ -19,7 +19,13 @@ const QS_CONFIG = getQSConfig('inventory', { function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) { const { - result: { inventories, count, relatedSearchableKeys, searchableKeys }, + result: { + inventories, + count, + relatedSearchableKeys, + searchableKeys, + canEdit, + }, request: fetchInventories, error, isLoading, @@ -39,9 +45,16 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) { searchableKeys: Object.keys( actionsResponse.data.actions?.GET || {} ).filter(key => actionsResponse.data.actions?.GET[key].filterable), + canEdit: Boolean(actionsResponse.data.actions.POST), }; }, [history.location]), - { inventories: [], count: 0, relatedSearchableKeys: [], searchableKeys: [] } + { + inventories: [], + count: 0, + relatedSearchableKeys: [], + searchableKeys: [], + canEdit: false, + } ); useEffect(() => { @@ -58,6 +71,7 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) { onBlur={onBlur} required={required} isLoading={isLoading} + isDisabled={!canEdit} qsConfig={QS_CONFIG} renderOptionsList={({ state, dispatch, canDelete }) => ( + props.isDisabled ? 'var(--pf-global--disabled-color--300)' : null}; `; function Lookup(props) { const { @@ -43,6 +45,7 @@ function Lookup(props) { renderOptionsList, history, i18n, + isDisabled, } = props; const [state, dispatch] = useReducer( @@ -103,11 +106,15 @@ function Lookup(props) { id={id} onClick={() => dispatch({ type: 'TOGGLE_MODAL' })} variant={ButtonVariant.control} - isDisabled={isLoading} + isDisabled={isLoading || isDisabled} > - + {items.map(item => renderItemChip({ diff --git a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx index 5c8ec16dee..3858c6a7fb 100644 --- a/awx/ui_next/src/components/Lookup/ProjectLookup.jsx +++ b/awx/ui_next/src/components/Lookup/ProjectLookup.jsx @@ -32,7 +32,7 @@ function ProjectLookup({ history, }) { const { - result: { projects, count, relatedSearchableKeys, searchableKeys }, + result: { projects, count, relatedSearchableKeys, searchableKeys, canEdit }, request: fetchProjects, error, isLoading, @@ -55,6 +55,7 @@ function ProjectLookup({ searchableKeys: Object.keys( actionsResponse.data.actions?.GET || {} ).filter(key => actionsResponse.data.actions?.GET[key].filterable), + canEdit: Boolean(actionsResponse.data.actions.POST), }; }, [history.location.search, autocomplete]), { @@ -62,6 +63,7 @@ function ProjectLookup({ projects: [], relatedSearchableKeys: [], searchableKeys: [], + canEdit: false, } ); @@ -87,6 +89,7 @@ function ProjectLookup({ onChange={onChange} required={required} isLoading={isLoading} + isDisabled={!canEdit} qsConfig={QS_CONFIG} renderOptionsList={({ state, dispatch, canDelete }) => ( { + const newOptions = []; if (value !== selections && options.length) { - const syncedValue = value.map(item => - options.find(i => i.id === item.id) - ); + const syncedValue = value.map(item => { + const match = options.find(i => { + return i.id === item.id; + }); + if (!match) { + newOptions.push(item); + } + return match || item; + }); setSelections(syncedValue); } + if (newOptions.length > 0) { + setOptions(options.concat(newOptions)); + } /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [value, options]); @@ -27,7 +37,6 @@ export default function useSyncedSelectValue(value, onChange) { onChange(selections.concat(item)); } }; - return { selections: options.length ? addToStringToObjects(selections) : [], onSelect, diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx index 1e48523d85..a51bbb355a 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.jsx @@ -4,13 +4,11 @@ import { withRouter, Redirect } from 'react-router-dom'; import { CardBody } from '../../../components/Card'; import ContentError from '../../../components/ContentError'; import ContentLoading from '../../../components/ContentLoading'; -import { JobTemplatesAPI, ProjectsAPI } from '../../../api'; +import { JobTemplatesAPI } from '../../../api'; import { JobTemplate } from '../../../types'; import { getAddedAndRemoved } from '../../../util/lists'; import JobTemplateForm from '../shared/JobTemplateForm'; -const loadRelatedProjectPlaybooks = async project => - ProjectsAPI.readPlaybooks(project); class JobTemplateEdit extends Component { static propTypes = { template: JobTemplate.isRequired, @@ -43,17 +41,8 @@ class JobTemplateEdit extends Component { } async loadRelated() { - const { - template: { project }, - } = this.props; this.setState({ contentError: null, hasContentLoading: true }); try { - if (project) { - const { data: playbook = [] } = await loadRelatedProjectPlaybooks( - project - ); - this.setState({ relatedProjectPlaybooks: playbook }); - } const [relatedCredentials] = await this.loadRelatedCredentials(); this.setState({ relatedCredentials, diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index a7d540a7a7..c75efc036f 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -39,7 +39,7 @@ import { ProjectLookup, MultiCredentialsLookup, } from '../../../components/Lookup'; -import { JobTemplatesAPI, ProjectsAPI } from '../../../api'; +import { JobTemplatesAPI } from '../../../api'; import LabelSelect from './LabelSelect'; import PlaybookSelect from './PlaybookSelect'; import WebhookSubForm from './WebhookSubForm'; @@ -100,18 +100,6 @@ function JobTemplateForm({ 'webhook_credential' ); - const { - request: fetchProject, - error: projectContentError, - contentLoading: hasProjectLoading, - } = useRequest( - useCallback(async () => { - if (template?.project) { - await ProjectsAPI.readDetail(template?.project); - } - }, [template]) - ); - const { request: loadRelatedInstanceGroups, error: instanceGroupError, @@ -127,10 +115,6 @@ function JobTemplateForm({ }, [setFieldValue, template]) ); - useEffect(() => { - fetchProject(); - }, [fetchProject]); - useEffect(() => { loadRelatedInstanceGroups(); }, [loadRelatedInstanceGroups]); @@ -204,16 +188,12 @@ function JobTemplateForm({ callbackUrl = `${origin}${path}`; } - if (instanceGroupLoading || hasProjectLoading) { + if (instanceGroupLoading) { return ; } - if (contentError || instanceGroupError || projectContentError) { - return ( - - ); + if (contentError || instanceGroupError) { + return ; } return ( diff --git a/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx b/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx index 78fd6e28f9..83dfc590af 100644 --- a/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx +++ b/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; import { number, string, oneOfType } from 'prop-types'; import { withI18n } from '@lingui/react'; import { t } from '@lingui/macro'; @@ -7,6 +7,7 @@ import { ProjectsAPI } from '../../../api'; import useRequest from '../../../util/useRequest'; function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) { + const [isDisabled, setIsDisabled] = useState(false); const { result: options, request: fetchOptions, @@ -18,6 +19,7 @@ function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) { return []; } const { data } = await ProjectsAPI.readPlaybooks(projectId); + const opts = (data || []).map(playbook => ({ value: playbook, key: playbook, @@ -33,7 +35,7 @@ function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) { }); return opts; }, [projectId, i18n]), - [] + [field.value] ); useEffect(() => { @@ -42,18 +44,30 @@ function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) { useEffect(() => { if (error) { - onError(error); + if (error.response.status === 403) { + setIsDisabled(true); + } else { + onError(error); + } } }, [error, onError]); + const isDisabledData = [ + { + value: field.value || '', + label: field.value || '', + key: 1, + isDisabled: true, + }, + ]; return ( ); } From b51f013880d187d5da96b6daadc54db68eb91a4e Mon Sep 17 00:00:00 2001 From: Alex Corey Date: Thu, 3 Sep 2020 11:14:15 -0400 Subject: [PATCH 2/2] disables prompt on launch checkbox --- .../FieldWithPrompt/FieldWithPrompt.jsx | 2 + .../components/FormField/CheckboxField.jsx | 11 ++- .../components/Lookup/CredentialLookup.jsx | 2 + .../src/components/Lookup/InventoryLookup.jsx | 79 ++++++++++++++++++- awx/ui_next/src/components/Lookup/Lookup.jsx | 9 +-- .../JobTemplateEdit/JobTemplateEdit.test.jsx | 7 ++ .../Template/shared/JobTemplateForm.jsx | 18 ++--- .../Template/shared/JobTemplateForm.test.jsx | 14 +++- .../Template/shared/PlaybookSelect.jsx | 2 +- .../shared/WorkflowJobTemplateForm.jsx | 23 +++--- .../shared/WorkflowJobTemplateForm.test.jsx | 13 +++ 11 files changed, 145 insertions(+), 35 deletions(-) diff --git a/awx/ui_next/src/components/FieldWithPrompt/FieldWithPrompt.jsx b/awx/ui_next/src/components/FieldWithPrompt/FieldWithPrompt.jsx index b0d27ccc6e..d505049cf0 100644 --- a/awx/ui_next/src/components/FieldWithPrompt/FieldWithPrompt.jsx +++ b/awx/ui_next/src/components/FieldWithPrompt/FieldWithPrompt.jsx @@ -23,6 +23,7 @@ function FieldWithPrompt({ promptId, promptName, tooltip, + isDisabled, }) { return (
@@ -39,6 +40,7 @@ function FieldWithPrompt({ {tooltip && }
diff --git a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx index c67e0087c6..a31fd8fd7d 100644 --- a/awx/ui_next/src/components/Lookup/CredentialLookup.jsx +++ b/awx/ui_next/src/components/Lookup/CredentialLookup.jsx @@ -33,6 +33,7 @@ function CredentialLookup({ history, i18n, tooltip, + isDisabled, }) { const { result: { count, credentials, relatedSearchableKeys, searchableKeys }, @@ -108,6 +109,7 @@ function CredentialLookup({ onChange={onChange} required={required} qsConfig={QS_CONFIG} + isDisabled={isDisabled} renderOptionsList={({ state, dispatch, canDelete }) => ( + + ( + dispatch({ type: 'SELECT_ITEM', item })} + deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })} + /> + )} + /> + + + + ) : ( <> 1); + const canDelete = + (!required || (multiple && value.length > 1)) && !isDisabled; let items = []; if (multiple) { items = value; @@ -110,11 +111,7 @@ function Lookup(props) { > - + {items.map(item => renderItemChip({ diff --git a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx index 61b1467bcf..7790989aa9 100644 --- a/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx +++ b/awx/ui_next/src/screens/Template/JobTemplateEdit/JobTemplateEdit.test.jsx @@ -12,6 +12,7 @@ import { JobTemplatesAPI, LabelsAPI, ProjectsAPI, + InventoriesAPI, } from '../../../api'; import JobTemplateEdit from './JobTemplateEdit'; @@ -181,6 +182,12 @@ JobTemplatesAPI.readCredentials.mockResolvedValue({ ProjectsAPI.readPlaybooks.mockResolvedValue({ data: mockRelatedProjectPlaybooks, }); +InventoriesAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {}, POST: {} } }, +}); +ProjectsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {}, POST: {} } }, +}); LabelsAPI.read.mockResolvedValue({ data: { results: [] } }); CredentialsAPI.read.mockResolvedValue({ data: { diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx index c75efc036f..ae68594205 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.jsx @@ -234,17 +234,15 @@ function JobTemplateForm({ }} /> - + <> inventoryHelpers.setTouched()} onChange={value => { inventoryHelpers.setValue(value ? value.id : null); @@ -263,7 +261,7 @@ function JobTemplateForm({ {inventoryMeta.error} )} - + projectHelpers.setTouched()} diff --git a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx index a373fefe37..f6c6c3e486 100644 --- a/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx +++ b/awx/ui_next/src/screens/Template/shared/JobTemplateForm.test.jsx @@ -14,6 +14,7 @@ import { ProjectsAPI, CredentialsAPI, CredentialTypesAPI, + InventoriesAPI, } from '../../../api'; jest.mock('../../../api'); @@ -111,14 +112,23 @@ describe('', () => { JobTemplatesAPI.updateWebhookKey.mockReturnValue({ data: { webhook_key: 'webhook key' }, }); - ProjectsAPI.readPlaybooks.mockReturnValue({ - data: ['debug.yml'], + JobTemplatesAPI.updateWebhookKey.mockReturnValue({ + data: { webhook_key: 'webhook key' }, }); ProjectsAPI.readDetail.mockReturnValue({ name: 'foo', id: 1, allow_override: true, }); + ProjectsAPI.readPlaybooks.mockReturnValue({ + data: ['debug.yml'], + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {}, POST: {} } }, + }); + ProjectsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {}, POST: {} } }, + }); }); afterEach(() => { diff --git a/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx b/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx index 83dfc590af..5a66cc0a78 100644 --- a/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx +++ b/awx/ui_next/src/screens/Template/shared/PlaybookSelect.jsx @@ -35,7 +35,7 @@ function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) { }); return opts; }, [projectId, i18n]), - [field.value] + [] ); useEffect(() => { diff --git a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx index 4ea77786f9..bb13418aac 100644 --- a/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx +++ b/awx/ui_next/src/screens/Template/shared/WorkflowJobTemplateForm.jsx @@ -110,23 +110,21 @@ function WorkflowJobTemplateForm({ value={organizationField.value} isValid={!organizationMeta.error} /> - - + <> inventoryHelpers.setTouched()} onChange={value => { inventoryHelpers.setValue(value); }} - required={askInventoryOnLaunchField.value} + required={!askInventoryOnLaunchField.value} touched={inventoryMeta.touched} error={inventoryMeta.error} /> @@ -139,8 +137,7 @@ function WorkflowJobTemplateForm({ {inventoryMeta.error} )} - - + ', () => { let wrapper; @@ -71,6 +75,15 @@ describe('', () => { { id: 2, name: 'Bar' }, ], }); + CredentialTypesAPI.read.mockResolvedValue({ + data: { results: [{ id: 1 }] }, + }); + InventoriesAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {}, POST: {} } }, + }); + ProjectsAPI.readOptions.mockResolvedValue({ + data: { actions: { GET: {}, POST: {} } }, + }); history = createMemoryHistory({ initialEntries: ['/templates/workflow_job_template/6/edit'],