diff --git a/client/src/components/Applications/AppForm/AppForm.jsx b/client/src/components/Applications/AppForm/AppForm.jsx index 4cb3cfbf9..65a7e61b9 100644 --- a/client/src/components/Applications/AppForm/AppForm.jsx +++ b/client/src/components/Applications/AppForm/AppForm.jsx @@ -19,10 +19,14 @@ import { Link } from 'react-router-dom'; import { getSystemName } from 'utils/systems'; import FormSchema from './AppFormSchema'; import { + checkAndSetDefaultTargetPath, + isTargetPathField, + getInputFieldFromTargetPathField, getQueueMaxMinutes, getMaxMinutesValidation, getNodeCountValidation, getCoresPerNodeValidation, + getTargetPathFieldName, updateValuesForQueue, } from './AppFormUtils'; import DataFilesSelectModal from '../../DataFiles/DataFilesModals/DataFilesSelectModal'; @@ -459,17 +463,49 @@ export const AppSchemaForm = ({ app }) => { onSubmit={(values, { setSubmitting, resetForm }) => { const job = cloneDeep(values); - job.fileInputs = Object.entries(job.fileInputs) - .map(([k, v]) => { - // filter out read only inputs. 'FIXED' inputs are tracked as readOnly - if ( - Object.hasOwn(appFields.fileInputs, k) && - appFields.fileInputs[k].readOnly - ) - return; - return { name: k, sourceUrl: v }; - }) - .filter((fileInput) => fileInput && fileInput.sourceUrl); // filter out any empty values + // Transform input field values into format that jobs service wants. + // File Input structure will have 2 fields if target path is required by the app. + // field 1 - has source url + // field 2 - has target path for the source url. + // tapis wants only 1 field with 2 properties - source url and target path. + // The logic below handles that scenario by merging the related fields into 1 field. + job.fileInputs = Object.values( + Object.entries(job.fileInputs) + .map(([k, v]) => { + // filter out read only inputs. 'FIXED' inputs are tracked as readOnly + if ( + Object.hasOwn(appFields.fileInputs, k) && + appFields.fileInputs[k].readOnly + ) + return; + return { + name: k, + sourceUrl: !isTargetPathField(k) ? v : null, + targetDir: isTargetPathField(k) ? v : null, + }; + }) + .reduce((acc, entry) => { + // merge input field and targetPath fields into one. + const key = getInputFieldFromTargetPathField(entry.name); + if (!acc[key]) { + acc[key] = {}; + } + acc[key]['name'] = key; + acc[key]['sourceUrl'] = + acc[key]['sourceUrl'] ?? entry.sourceUrl; + acc[key]['targetPath'] = + acc[key]['targetPath'] ?? entry.targetDir; + return acc; + }, {}) + ) + .flat() + .filter((fileInput) => fileInput.sourceUrl) // filter out any empty values + .map((fileInput) => { + fileInput.targetPath = checkAndSetDefaultTargetPath( + fileInput.targetPath + ); + return fileInput; + }); job.parameterSet = Object.assign( {}, @@ -559,8 +595,15 @@ export const AppSchemaForm = ({ app }) => { {Object.entries(appFields.fileInputs).map( ([name, field]) => { - // TODOv3 handle fileInputArrays https://jira.tacc.utexas.edu/browse/TV3-81 - return ( + // TODOv3 handle fileInputArrays https://jira.tacc.utexas.edu/browse/WP-81 + return isTargetPathField(name) ? ( + + ) : ( { const appFields = { @@ -97,6 +101,9 @@ const FormSchema = (app) => { } ); + // The default is to not show target path for file inputs. + const showTargetPathForFileInputs = + app.definition.notes.showTargetPath ?? false; (app.definition.jobAttributes.fileInputs || []).forEach((i) => { const input = i; /* TODOv3 consider hidden file inputs https://jira.tacc.utexas.edu/browse/WP-102 @@ -131,6 +138,33 @@ const FormSchema = (app) => { input.sourceUrl === null || typeof input.sourceUrl === 'undefined' ? '' : input.sourceUrl; + + // Add targetDir for all sourceUrl + if (!showTargetPathForFileInputs) { + return; + } + const targetPathName = getTargetPathFieldName(input.name); + appFields.schema.fileInputs[targetPathName] = Yup.string(); + appFields.schema.fileInputs[targetPathName] = appFields.schema.fileInputs[ + targetPathName + ].matches( + /^tapis:\/\//g, + "Input file Target Directory must be a valid Tapis URI, starting with 'tapis://'" + ); + + appFields.schema.fileInputs[targetPathName] = false; + appFields.fileInputs[targetPathName] = { + label: 'Target Path for ' + input.name, + description: + 'The name of the ' + + input.name + + ' after it is copied to the target system, but before the job is run. Leave this value blank to just use the name of the input file.', + required: false, + readOnly: field.readOnly, + type: 'text', + }; + appFields.defaults.fileInputs[targetPathName] = + checkAndSetDefaultTargetPath(input.targetPath); }); return appFields; }; diff --git a/client/src/components/Applications/AppForm/AppFormUtils.js b/client/src/components/Applications/AppForm/AppFormUtils.js index 812fe3379..1b5b30c7e 100644 --- a/client/src/components/Applications/AppForm/AppFormUtils.js +++ b/client/src/components/Applications/AppForm/AppFormUtils.js @@ -1,6 +1,8 @@ import * as Yup from 'yup'; import { getSystemName } from 'utils/systems'; +export const TARGET_PATH_FIELD_PREFIX = '_TargetPath_'; + export const getQueueMaxMinutes = (app, queueName) => { return app.exec_sys.batchLogicalQueues.find((q) => q.name === queueName) .maxMinutes; @@ -165,3 +167,57 @@ export const updateValuesForQueue = (app, values) => { return updatedValues; }; + +/** + * Get the field name used for target path in AppForm + * + * @function + * @param {String} inputFieldName + * @returns {String} field Name prefixed with target path + */ +export const getTargetPathFieldName = (inputFieldName) => { + return TARGET_PATH_FIELD_PREFIX + inputFieldName; +}; + +/** + * Whether a field name is a system defined field for Target Path + * + * @function + * @param {String} inputFieldName + * @returns {String} field Name suffixed with target path + */ +export const isTargetPathField = (inputFieldName) => { + return inputFieldName && inputFieldName.startsWith(TARGET_PATH_FIELD_PREFIX); +}; + +/** + * From target path field name, derive the original input field name. + * + * @function + * @param {String} targetPathFieldName + * @returns {String} actual field name + */ +export const getInputFieldFromTargetPathField = (targetPathFieldName) => { + return targetPathFieldName.replace(TARGET_PATH_FIELD_PREFIX, ''); +}; + +/** + * Sets the default value if target path is not set. + * + * @function + * @param {String} targetPathFieldValue + * @returns {String} target path value + */ +export const checkAndSetDefaultTargetPath = (targetPathFieldValue) => { + if (targetPathFieldValue === null || targetPathFieldValue === undefined) { + return '*'; + } + + targetPathFieldValue = targetPathFieldValue.trim(); + + if (targetPathFieldValue.trim() === '') { + return '*'; + } + + return targetPathFieldValue; +};