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;
+};