diff --git a/client/src/components/Applications/AppForm/AppForm.jsx b/client/src/components/Applications/AppForm/AppForm.jsx index 4104ab246..b2b7ed793 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; +}; diff --git a/client/src/components/DataFiles/DataFilesModals/DataFilesMoveModal.jsx b/client/src/components/DataFiles/DataFilesModals/DataFilesMoveModal.jsx index 0f079795a..1faa2995e 100644 --- a/client/src/components/DataFiles/DataFilesModals/DataFilesMoveModal.jsx +++ b/client/src/components/DataFiles/DataFilesModals/DataFilesMoveModal.jsx @@ -50,10 +50,7 @@ const DataFilesMoveModal = React.memo(() => { const onOpened = () => { fetchListing({ - api: 'tapis', - scheme: 'private', - system: selectedSystem.system, - path: `${selectedSystem.homeDir || ''}`, + ...params, }); }; @@ -68,7 +65,7 @@ const DataFilesMoveModal = React.memo(() => { setDisabled(true); move({ destSystem: system, - destPath: path, + destPath: path || '/', callback: reloadPage, }); }, diff --git a/client/src/components/Jobs/Jobs.jsx b/client/src/components/Jobs/Jobs.jsx index bbef04a96..1945d1bf7 100644 --- a/client/src/components/Jobs/Jobs.jsx +++ b/client/src/components/Jobs/Jobs.jsx @@ -75,11 +75,14 @@ function JobsView({ const infiniteScrollCallback = useCallback(() => { // TODOv3: dropV2Jobs const dispatchType = version === 'v3' ? 'GET_JOBS' : 'GET_V2_JOBS'; - dispatch({ - type: dispatchType, - params: { offset: jobs.length, queryString: query.query_string || '' }, - }); - }, [dispatch, jobs, query.query_string]); + + if (!isJobLoading) { + dispatch({ + type: dispatchType, + params: { offset: jobs.length, queryString: query.query_string || '' }, + }); + } + }, [dispatch, jobs, query.query_string, isJobLoading]); const jobDetailLink = useCallback( ({ @@ -217,22 +220,20 @@ function JobsView({ disabled={isJobLoading || isNotificationLoading} /> )} -
- - {noDataText} - - } - getRowProps={rowProps} - columnMemoProps={[version]} /* TODOv3: dropV2Jobs. */ - /> -
+ + {noDataText} + + } + getRowProps={rowProps} + columnMemoProps={[version]} /* TODOv3: dropV2Jobs. */ + /> ); } diff --git a/client/src/components/Jobs/Jobs.test.js b/client/src/components/Jobs/Jobs.test.js index ea30d1934..05dcb53f6 100644 --- a/client/src/components/Jobs/Jobs.test.js +++ b/client/src/components/Jobs/Jobs.test.js @@ -1,5 +1,5 @@ import React from 'react'; -import { render } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import Jobs from './Jobs'; import { createMemoryHistory } from 'history'; import { default as jobsList } from './Jobs.fixture'; @@ -135,4 +135,35 @@ describe('Jobs View', () => { const { getByText } = renderJobsComponent(store, history); expect(getByText(/unable to retrieve your jobs/)).toBeDefined(); }); + + it('should dispatch another get jobs event on scroll with proper offset', async () => { + const store = mockStore({ + notifications, + jobs: { ...jobs, list: jobsList }, + workbench: { ...workbench, config: { hideDataFiles: false } }, + apps: { + appIcons: {}, + }, + }); + + const { container } = render( + + + + + + ); + + const scrollContainer = container.querySelector('.table-container'); + + fireEvent.scroll(scrollContainer, { target: { scrollTop: 1 } }); + + expect(store.getActions()).toEqual([ + { type: 'GET_JOBS', params: { offset: 0, queryString: '' } }, + { + type: 'GET_JOBS', + params: { offset: jobsList.length, queryString: '' }, + }, + ]); + }); }); diff --git a/client/src/components/_common/InfiniteScrollTable/InfiniteScrollTable.jsx b/client/src/components/_common/InfiniteScrollTable/InfiniteScrollTable.jsx index f8496eeb8..a3bbe85e5 100644 --- a/client/src/components/_common/InfiniteScrollTable/InfiniteScrollTable.jsx +++ b/client/src/components/_common/InfiniteScrollTable/InfiniteScrollTable.jsx @@ -62,51 +62,60 @@ const InfiniteScrollTable = ({ useTable({ columns, data }); const onScroll = ({ target }) => { - const bottom = - target.scrollHeight - target.scrollTop === target.clientHeight; + const scrollbarHeight = target.offsetHeight - target.clientHeight; + const clientRectHeight = target.getBoundingClientRect().height; + const clientCalcHeight = clientRectHeight - scrollbarHeight; + const difference = Math.floor(target.scrollHeight - target.scrollTop); + + const bottom = difference <= clientCalcHeight; + if (bottom && target.scrollTop > 0) { onInfiniteScroll(tableData.length); } }; return ( - - - {headerGroups.map((headerGroup) => ( - - {headerGroup.headers.map((column) => ( - - ))} - - ))} - - - {rows.map((row) => { - prepareRow(row); - return ( - - {row.cells.map((cell) => { - return ( - - ); - })} +
+
{column.render('Header')}
- {cell.render('Cell')} -
+ + {headerGroups.map((headerGroup) => ( + + {headerGroup.headers.map((column) => ( + + ))} - ); - })} - - - -
{column.render('Header')}
+ ))} + + + {rows.map((row) => { + prepareRow(row); + return ( + + {row.cells.map((cell) => { + return ( + + {cell.render('Cell')} + + ); + })} + + ); + })} + + + + + ); }; diff --git a/client/src/components/_common/InfiniteScrollTable/InfiniteScrollTable.scss b/client/src/components/_common/InfiniteScrollTable/InfiniteScrollTable.scss index 55b98d918..4361f7586 100644 --- a/client/src/components/_common/InfiniteScrollTable/InfiniteScrollTable.scss +++ b/client/src/components/_common/InfiniteScrollTable/InfiniteScrollTable.scss @@ -79,3 +79,8 @@ width: 100%; } } + +.table-container { + height: 100%; + overflow: scroll; +} diff --git a/client/src/redux/sagas/datafiles.sagas.js b/client/src/redux/sagas/datafiles.sagas.js index 730a63c44..2ef66a565 100644 --- a/client/src/redux/sagas/datafiles.sagas.js +++ b/client/src/redux/sagas/datafiles.sagas.js @@ -254,7 +254,7 @@ export function* renameFile(action) { action.payload.api, action.payload.scheme, file.system, - file.path, + '/' + file.path, action.payload.newName ); yield put({ diff --git a/server/portal/apps/workspace/api/views.py b/server/portal/apps/workspace/api/views.py index 9a94db829..f76b36402 100644 --- a/server/portal/apps/workspace/api/views.py +++ b/server/portal/apps/workspace/api/views.py @@ -179,7 +179,7 @@ def listing(self, client, request): data = client.jobs.getJobSearchList( limit=limit, - startAfter=offset, + skip=offset, orderBy='lastUpdated(desc),name(asc)', _tapis_query_parameters={'tags.contains': f'portalName: {portal_name}'}, select='allAttributes'