Merge pull request #7975 from AlexSCorey/7973-DisableTemplateFormFields

Disables template and workflow template fields.

Reviewed-by: https://github.com/apps/softwarefactory-project-zuul
This commit is contained in:
softwarefactory-project-zuul[bot]
2020-09-06 21:26:23 +00:00
committed by GitHub
14 changed files with 204 additions and 78 deletions

View File

@@ -23,6 +23,7 @@ function FieldWithPrompt({
promptId, promptId,
promptName, promptName,
tooltip, tooltip,
isDisabled,
}) { }) {
return ( return (
<div className="pf-c-form__group"> <div className="pf-c-form__group">
@@ -39,6 +40,7 @@ function FieldWithPrompt({
{tooltip && <FieldTooltip content={tooltip} />} {tooltip && <FieldTooltip content={tooltip} />}
</div> </div>
<StyledCheckboxField <StyledCheckboxField
isDisabled={isDisabled}
id={promptId} id={promptId}
label={i18n._(t`Prompt on launch`)} label={i18n._(t`Prompt on launch`)}
name={promptName} name={promptName}

View File

@@ -9,10 +9,19 @@ const QuestionCircleIcon = styled(PFQuestionCircleIcon)`
margin-left: 10px; margin-left: 10px;
`; `;
function CheckboxField({ id, name, label, tooltip, validate, ...rest }) { function CheckboxField({
id,
name,
label,
tooltip,
validate,
isDisabled,
...rest
}) {
const [field] = useField({ name, validate }); const [field] = useField({ name, validate });
return ( return (
<Checkbox <Checkbox
isDisabled={isDisabled}
aria-label={label} aria-label={label}
label={ label={
<span> <span>

View File

@@ -33,6 +33,7 @@ function CredentialLookup({
history, history,
i18n, i18n,
tooltip, tooltip,
isDisabled,
}) { }) {
const { const {
result: { count, credentials, relatedSearchableKeys, searchableKeys }, result: { count, credentials, relatedSearchableKeys, searchableKeys },
@@ -108,6 +109,7 @@ function CredentialLookup({
onChange={onChange} onChange={onChange}
required={required} required={required}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
isDisabled={isDisabled}
renderOptionsList={({ state, dispatch, canDelete }) => ( renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList <OptionsList
value={state.selectedItems} value={state.selectedItems}

View File

@@ -10,6 +10,7 @@ import OptionsList from '../OptionsList';
import useRequest from '../../util/useRequest'; import useRequest from '../../util/useRequest';
import { getQSConfig, parseQueryString } from '../../util/qs'; import { getQSConfig, parseQueryString } from '../../util/qs';
import LookupErrorMessage from './shared/LookupErrorMessage'; import LookupErrorMessage from './shared/LookupErrorMessage';
import FieldWithPrompt from '../FieldWithPrompt';
const QS_CONFIG = getQSConfig('inventory', { const QS_CONFIG = getQSConfig('inventory', {
page: 1, page: 1,
@@ -17,9 +18,26 @@ const QS_CONFIG = getQSConfig('inventory', {
order_by: 'name', order_by: 'name',
}); });
function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) { function InventoryLookup({
value,
onChange,
onBlur,
i18n,
history,
required,
isPromptableField,
fieldId,
promptId,
promptName,
}) {
const { const {
result: { inventories, count, relatedSearchableKeys, searchableKeys }, result: {
inventories,
count,
relatedSearchableKeys,
searchableKeys,
canEdit,
},
request: fetchInventories, request: fetchInventories,
error, error,
isLoading, isLoading,
@@ -39,16 +57,86 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
searchableKeys: Object.keys( searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {} actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable), ).filter(key => actionsResponse.data.actions?.GET[key].filterable),
canEdit: Boolean(actionsResponse.data.actions.POST),
}; };
}, [history.location]), }, [history.location]),
{ inventories: [], count: 0, relatedSearchableKeys: [], searchableKeys: [] } {
inventories: [],
count: 0,
relatedSearchableKeys: [],
searchableKeys: [],
canEdit: false,
}
); );
useEffect(() => { useEffect(() => {
fetchInventories(); fetchInventories();
}, [fetchInventories]); }, [fetchInventories]);
return ( return isPromptableField ? (
<>
<FieldWithPrompt
fieldId={fieldId}
isRequired={required}
label={i18n._(t`Inventory`)}
promptId={promptId}
promptName={promptName}
isDisabled={!canEdit}
tooltip={i18n._(t`Select the inventory containing the hosts
you want this job to manage.`)}
>
<Lookup
id="inventory-lookup"
header={i18n._(t`Inventory`)}
value={value}
onChange={onChange}
onBlur={onBlur}
required={required}
isLoading={isLoading}
isDisabled={!canEdit}
qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList
value={state.selectedItems}
options={inventories}
optionCount={count}
searchColumns={[
{
name: i18n._(t`Name`),
key: 'name__icontains',
isDefault: true,
},
{
name: i18n._(t`Created By (Username)`),
key: 'created_by__username__icontains',
},
{
name: i18n._(t`Modified By (Username)`),
key: 'modified_by__username__icontains',
},
]}
sortColumns={[
{
name: i18n._(t`Name`),
key: 'name',
},
]}
searchableKeys={searchableKeys}
relatedSearchableKeys={relatedSearchableKeys}
multiple={state.multiple}
header={i18n._(t`Inventory`)}
name="inventory"
qsConfig={QS_CONFIG}
readOnly={!canDelete}
selectItem={item => dispatch({ type: 'SELECT_ITEM', item })}
deselectItem={item => dispatch({ type: 'DESELECT_ITEM', item })}
/>
)}
/>
<LookupErrorMessage error={error} />
</FieldWithPrompt>
</>
) : (
<> <>
<Lookup <Lookup
id="inventory-lookup" id="inventory-lookup"
@@ -58,6 +146,7 @@ function InventoryLookup({ value, onChange, onBlur, required, i18n, history }) {
onBlur={onBlur} onBlur={onBlur}
required={required} required={required}
isLoading={isLoading} isLoading={isLoading}
isDisabled={!canEdit}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => ( renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList <OptionsList

View File

@@ -27,6 +27,8 @@ import { QSConfig } from '../../types';
const ChipHolder = styled.div` const ChipHolder = styled.div`
--pf-c-form-control--Height: auto; --pf-c-form-control--Height: auto;
background-color: ${props =>
props.isDisabled ? 'var(--pf-global--disabled-color--300)' : null};
`; `;
function Lookup(props) { function Lookup(props) {
const { const {
@@ -43,6 +45,7 @@ function Lookup(props) {
renderOptionsList, renderOptionsList,
history, history,
i18n, i18n,
isDisabled,
} = props; } = props;
const [state, dispatch] = useReducer( const [state, dispatch] = useReducer(
@@ -88,7 +91,8 @@ function Lookup(props) {
}; };
const { isModalOpen, selectedItems } = state; const { isModalOpen, selectedItems } = state;
const canDelete = !required || (multiple && value.length > 1); const canDelete =
(!required || (multiple && value.length > 1)) && !isDisabled;
let items = []; let items = [];
if (multiple) { if (multiple) {
items = value; items = value;
@@ -103,11 +107,11 @@ function Lookup(props) {
id={id} id={id}
onClick={() => dispatch({ type: 'TOGGLE_MODAL' })} onClick={() => dispatch({ type: 'TOGGLE_MODAL' })}
variant={ButtonVariant.control} variant={ButtonVariant.control}
isDisabled={isLoading} isDisabled={isLoading || isDisabled}
> >
<SearchIcon /> <SearchIcon />
</Button> </Button>
<ChipHolder className="pf-c-form-control"> <ChipHolder isDisabled={isDisabled} className="pf-c-form-control">
<ChipGroup numChips={5} totalChips={items.length}> <ChipGroup numChips={5} totalChips={items.length}>
{items.map(item => {items.map(item =>
renderItemChip({ renderItemChip({

View File

@@ -32,7 +32,7 @@ function ProjectLookup({
history, history,
}) { }) {
const { const {
result: { projects, count, relatedSearchableKeys, searchableKeys }, result: { projects, count, relatedSearchableKeys, searchableKeys, canEdit },
request: fetchProjects, request: fetchProjects,
error, error,
isLoading, isLoading,
@@ -55,6 +55,7 @@ function ProjectLookup({
searchableKeys: Object.keys( searchableKeys: Object.keys(
actionsResponse.data.actions?.GET || {} actionsResponse.data.actions?.GET || {}
).filter(key => actionsResponse.data.actions?.GET[key].filterable), ).filter(key => actionsResponse.data.actions?.GET[key].filterable),
canEdit: Boolean(actionsResponse.data.actions.POST),
}; };
}, [history.location.search, autocomplete]), }, [history.location.search, autocomplete]),
{ {
@@ -62,6 +63,7 @@ function ProjectLookup({
projects: [], projects: [],
relatedSearchableKeys: [], relatedSearchableKeys: [],
searchableKeys: [], searchableKeys: [],
canEdit: false,
} }
); );
@@ -87,6 +89,7 @@ function ProjectLookup({
onChange={onChange} onChange={onChange}
required={required} required={required}
isLoading={isLoading} isLoading={isLoading}
isDisabled={!canEdit}
qsConfig={QS_CONFIG} qsConfig={QS_CONFIG}
renderOptionsList={({ state, dispatch, canDelete }) => ( renderOptionsList={({ state, dispatch, canDelete }) => (
<OptionsList <OptionsList

View File

@@ -11,12 +11,22 @@ export default function useSyncedSelectValue(value, onChange) {
const [selections, setSelections] = useState([]); const [selections, setSelections] = useState([]);
useEffect(() => { useEffect(() => {
const newOptions = [];
if (value !== selections && options.length) { if (value !== selections && options.length) {
const syncedValue = value.map(item => const syncedValue = value.map(item => {
options.find(i => i.id === item.id) const match = options.find(i => {
); return i.id === item.id;
});
if (!match) {
newOptions.push(item);
}
return match || item;
});
setSelections(syncedValue); setSelections(syncedValue);
} }
if (newOptions.length > 0) {
setOptions(options.concat(newOptions));
}
/* eslint-disable-next-line react-hooks/exhaustive-deps */ /* eslint-disable-next-line react-hooks/exhaustive-deps */
}, [value, options]); }, [value, options]);
@@ -27,7 +37,6 @@ export default function useSyncedSelectValue(value, onChange) {
onChange(selections.concat(item)); onChange(selections.concat(item));
} }
}; };
return { return {
selections: options.length ? addToStringToObjects(selections) : [], selections: options.length ? addToStringToObjects(selections) : [],
onSelect, onSelect,

View File

@@ -4,13 +4,11 @@ import { withRouter, Redirect } from 'react-router-dom';
import { CardBody } from '../../../components/Card'; import { CardBody } from '../../../components/Card';
import ContentError from '../../../components/ContentError'; import ContentError from '../../../components/ContentError';
import ContentLoading from '../../../components/ContentLoading'; import ContentLoading from '../../../components/ContentLoading';
import { JobTemplatesAPI, ProjectsAPI } from '../../../api'; import { JobTemplatesAPI } from '../../../api';
import { JobTemplate } from '../../../types'; import { JobTemplate } from '../../../types';
import { getAddedAndRemoved } from '../../../util/lists'; import { getAddedAndRemoved } from '../../../util/lists';
import JobTemplateForm from '../shared/JobTemplateForm'; import JobTemplateForm from '../shared/JobTemplateForm';
const loadRelatedProjectPlaybooks = async project =>
ProjectsAPI.readPlaybooks(project);
class JobTemplateEdit extends Component { class JobTemplateEdit extends Component {
static propTypes = { static propTypes = {
template: JobTemplate.isRequired, template: JobTemplate.isRequired,
@@ -43,17 +41,8 @@ class JobTemplateEdit extends Component {
} }
async loadRelated() { async loadRelated() {
const {
template: { project },
} = this.props;
this.setState({ contentError: null, hasContentLoading: true }); this.setState({ contentError: null, hasContentLoading: true });
try { try {
if (project) {
const { data: playbook = [] } = await loadRelatedProjectPlaybooks(
project
);
this.setState({ relatedProjectPlaybooks: playbook });
}
const [relatedCredentials] = await this.loadRelatedCredentials(); const [relatedCredentials] = await this.loadRelatedCredentials();
this.setState({ this.setState({
relatedCredentials, relatedCredentials,

View File

@@ -12,6 +12,7 @@ import {
JobTemplatesAPI, JobTemplatesAPI,
LabelsAPI, LabelsAPI,
ProjectsAPI, ProjectsAPI,
InventoriesAPI,
} from '../../../api'; } from '../../../api';
import JobTemplateEdit from './JobTemplateEdit'; import JobTemplateEdit from './JobTemplateEdit';
@@ -181,6 +182,12 @@ JobTemplatesAPI.readCredentials.mockResolvedValue({
ProjectsAPI.readPlaybooks.mockResolvedValue({ ProjectsAPI.readPlaybooks.mockResolvedValue({
data: mockRelatedProjectPlaybooks, data: mockRelatedProjectPlaybooks,
}); });
InventoriesAPI.readOptions.mockResolvedValue({
data: { actions: { GET: {}, POST: {} } },
});
ProjectsAPI.readOptions.mockResolvedValue({
data: { actions: { GET: {}, POST: {} } },
});
LabelsAPI.read.mockResolvedValue({ data: { results: [] } }); LabelsAPI.read.mockResolvedValue({ data: { results: [] } });
CredentialsAPI.read.mockResolvedValue({ CredentialsAPI.read.mockResolvedValue({
data: { data: {

View File

@@ -39,7 +39,7 @@ import {
ProjectLookup, ProjectLookup,
MultiCredentialsLookup, MultiCredentialsLookup,
} from '../../../components/Lookup'; } from '../../../components/Lookup';
import { JobTemplatesAPI, ProjectsAPI } from '../../../api'; import { JobTemplatesAPI } from '../../../api';
import LabelSelect from './LabelSelect'; import LabelSelect from './LabelSelect';
import PlaybookSelect from './PlaybookSelect'; import PlaybookSelect from './PlaybookSelect';
import WebhookSubForm from './WebhookSubForm'; import WebhookSubForm from './WebhookSubForm';
@@ -100,18 +100,6 @@ function JobTemplateForm({
'webhook_credential' 'webhook_credential'
); );
const {
request: fetchProject,
error: projectContentError,
contentLoading: hasProjectLoading,
} = useRequest(
useCallback(async () => {
if (template?.project) {
await ProjectsAPI.readDetail(template?.project);
}
}, [template])
);
const { const {
request: loadRelatedInstanceGroups, request: loadRelatedInstanceGroups,
error: instanceGroupError, error: instanceGroupError,
@@ -127,10 +115,6 @@ function JobTemplateForm({
}, [setFieldValue, template]) }, [setFieldValue, template])
); );
useEffect(() => {
fetchProject();
}, [fetchProject]);
useEffect(() => { useEffect(() => {
loadRelatedInstanceGroups(); loadRelatedInstanceGroups();
}, [loadRelatedInstanceGroups]); }, [loadRelatedInstanceGroups]);
@@ -204,16 +188,12 @@ function JobTemplateForm({
callbackUrl = `${origin}${path}`; callbackUrl = `${origin}${path}`;
} }
if (instanceGroupLoading || hasProjectLoading) { if (instanceGroupLoading) {
return <ContentLoading />; return <ContentLoading />;
} }
if (contentError || instanceGroupError || projectContentError) { if (contentError || instanceGroupError) {
return ( return <ContentError error={contentError || instanceGroupError} />;
<ContentError
error={contentError || instanceGroupError || projectContentError}
/>
);
} }
return ( return (
@@ -254,17 +234,15 @@ function JobTemplateForm({
}} }}
/> />
</FieldWithPrompt> </FieldWithPrompt>
<FieldWithPrompt <>
fieldId="template-inventory"
isRequired={!askInventoryOnLaunchField.value}
label={i18n._(t`Inventory`)}
promptId="template-ask-inventory-on-launch"
promptName="ask_inventory_on_launch"
tooltip={i18n._(t`Select the inventory containing the hosts
you want this job to manage.`)}
>
<InventoryLookup <InventoryLookup
value={inventory} value={inventory}
fieldId="template-inventory"
promptId="template-ask-inventory-on-launch"
promptName="ask_inventory_on_launch"
isPromptableField
tooltip={i18n._(t`Select the inventory containing the hosts
you want this job to manage.`)}
onBlur={() => inventoryHelpers.setTouched()} onBlur={() => inventoryHelpers.setTouched()}
onChange={value => { onChange={value => {
inventoryHelpers.setValue(value ? value.id : null); inventoryHelpers.setValue(value ? value.id : null);
@@ -283,7 +261,7 @@ function JobTemplateForm({
{inventoryMeta.error} {inventoryMeta.error}
</div> </div>
)} )}
</FieldWithPrompt> </>
<ProjectLookup <ProjectLookup
value={projectField.value} value={projectField.value}
onBlur={() => projectHelpers.setTouched()} onBlur={() => projectHelpers.setTouched()}

View File

@@ -14,6 +14,7 @@ import {
ProjectsAPI, ProjectsAPI,
CredentialsAPI, CredentialsAPI,
CredentialTypesAPI, CredentialTypesAPI,
InventoriesAPI,
} from '../../../api'; } from '../../../api';
jest.mock('../../../api'); jest.mock('../../../api');
@@ -111,14 +112,23 @@ describe('<JobTemplateForm />', () => {
JobTemplatesAPI.updateWebhookKey.mockReturnValue({ JobTemplatesAPI.updateWebhookKey.mockReturnValue({
data: { webhook_key: 'webhook key' }, data: { webhook_key: 'webhook key' },
}); });
ProjectsAPI.readPlaybooks.mockReturnValue({ JobTemplatesAPI.updateWebhookKey.mockReturnValue({
data: ['debug.yml'], data: { webhook_key: 'webhook key' },
}); });
ProjectsAPI.readDetail.mockReturnValue({ ProjectsAPI.readDetail.mockReturnValue({
name: 'foo', name: 'foo',
id: 1, id: 1,
allow_override: true, allow_override: true,
}); });
ProjectsAPI.readPlaybooks.mockReturnValue({
data: ['debug.yml'],
});
InventoriesAPI.readOptions.mockResolvedValue({
data: { actions: { GET: {}, POST: {} } },
});
ProjectsAPI.readOptions.mockResolvedValue({
data: { actions: { GET: {}, POST: {} } },
});
}); });
afterEach(() => { afterEach(() => {

View File

@@ -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 { number, string, oneOfType } from 'prop-types';
import { withI18n } from '@lingui/react'; import { withI18n } from '@lingui/react';
import { t } from '@lingui/macro'; import { t } from '@lingui/macro';
@@ -7,6 +7,7 @@ import { ProjectsAPI } from '../../../api';
import useRequest from '../../../util/useRequest'; import useRequest from '../../../util/useRequest';
function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) { function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
const [isDisabled, setIsDisabled] = useState(false);
const { const {
result: options, result: options,
request: fetchOptions, request: fetchOptions,
@@ -18,6 +19,7 @@ function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
return []; return [];
} }
const { data } = await ProjectsAPI.readPlaybooks(projectId); const { data } = await ProjectsAPI.readPlaybooks(projectId);
const opts = (data || []).map(playbook => ({ const opts = (data || []).map(playbook => ({
value: playbook, value: playbook,
key: playbook, key: playbook,
@@ -42,18 +44,30 @@ function PlaybookSelect({ projectId, isValid, field, onBlur, onError, i18n }) {
useEffect(() => { useEffect(() => {
if (error) { if (error) {
onError(error); if (error.response.status === 403) {
setIsDisabled(true);
} else {
onError(error);
}
} }
}, [error, onError]); }, [error, onError]);
const isDisabledData = [
{
value: field.value || '',
label: field.value || '',
key: 1,
isDisabled: true,
},
];
return ( return (
<AnsibleSelect <AnsibleSelect
id="template-playbook" id="template-playbook"
data={options} data={isDisabled ? isDisabledData : options}
isValid={isValid} isValid={isValid}
{...field} {...field}
onBlur={onBlur} onBlur={onBlur}
isDisabled={isLoading} isDisabled={isLoading || isDisabled}
/> />
); );
} }

View File

@@ -110,23 +110,21 @@ function WorkflowJobTemplateForm({
value={organizationField.value} value={organizationField.value}
isValid={!organizationMeta.error} isValid={!organizationMeta.error}
/> />
<>
<FieldWithPrompt
fieldId="wfjt-inventory"
label={i18n._(t`Inventory`)}
promptId="wfjt-ask-inventory-on-launch"
promptName="ask_inventory_on_launch"
tooltip={i18n._(
t`Select an inventory for the workflow. This inventory is applied to all job template nodes that prompt for an inventory.`
)}
>
<InventoryLookup <InventoryLookup
promptId="wfjt-ask-inventory-on-launch"
promptName="ask_inventory_on_launch"
tooltip={i18n._(
t`Select an inventory for the workflow. This inventory is applied to all job template nodes that prompt for an inventory.`
)}
fieldId="wfjt-inventory"
isPromptableField
value={inventoryField.value} value={inventoryField.value}
onBlur={() => inventoryHelpers.setTouched()} onBlur={() => inventoryHelpers.setTouched()}
onChange={value => { onChange={value => {
inventoryHelpers.setValue(value); inventoryHelpers.setValue(value);
}} }}
required={askInventoryOnLaunchField.value} required={!askInventoryOnLaunchField.value}
touched={inventoryMeta.touched} touched={inventoryMeta.touched}
error={inventoryMeta.error} error={inventoryMeta.error}
/> />
@@ -139,8 +137,7 @@ function WorkflowJobTemplateForm({
{inventoryMeta.error} {inventoryMeta.error}
</div> </div>
)} )}
</FieldWithPrompt> </>
<FieldWithPrompt <FieldWithPrompt
fieldId="wjft-limit" fieldId="wjft-limit"
label={i18n._(t`Limit`)} label={i18n._(t`Limit`)}

View File

@@ -11,6 +11,8 @@ import {
LabelsAPI, LabelsAPI,
OrganizationsAPI, OrganizationsAPI,
InventoriesAPI, InventoriesAPI,
ProjectsAPI,
CredentialTypesAPI,
} from '../../../api'; } from '../../../api';
jest.mock('../../../api/models/CredentialTypes'); jest.mock('../../../api/models/CredentialTypes');
@@ -18,6 +20,8 @@ jest.mock('../../../api/models/WorkflowJobTemplates');
jest.mock('../../../api/models/Labels'); jest.mock('../../../api/models/Labels');
jest.mock('../../../api/models/Organizations'); jest.mock('../../../api/models/Organizations');
jest.mock('../../../api/models/Inventories'); jest.mock('../../../api/models/Inventories');
jest.mock('../../../api/models/Projects');
jest.mock('../../../api/models/Credentials');
describe('<WorkflowJobTemplateForm/>', () => { describe('<WorkflowJobTemplateForm/>', () => {
let wrapper; let wrapper;
@@ -71,6 +75,15 @@ describe('<WorkflowJobTemplateForm/>', () => {
{ id: 2, name: 'Bar' }, { 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({ history = createMemoryHistory({
initialEntries: ['/templates/workflow_job_template/6/edit'], initialEntries: ['/templates/workflow_job_template/6/edit'],