diff --git a/src/tapis-api/apps/createApp.ts b/src/tapis-api/apps/createApp.ts new file mode 100644 index 000000000..cad240718 --- /dev/null +++ b/src/tapis-api/apps/createApp.ts @@ -0,0 +1,20 @@ +import { Apps } from '@tapis/tapis-typescript'; +import { apiGenerator, errorDecoder } from 'tapis-api/utils'; + +const createApp = ( + createAppVersionRequest: Apps.CreateAppVersionRequest, + basepath: string, + jwt: string +) => { + const api: Apps.ApplicationsApi = apiGenerator( + Apps, + Apps.ApplicationsApi, + basepath, + jwt + ); + return errorDecoder(() => + api.createAppVersion(createAppVersionRequest) + ); +}; + +export default createApp; diff --git a/src/tapis-api/apps/index.ts b/src/tapis-api/apps/index.ts index c71f31d46..a13ee9a12 100644 --- a/src/tapis-api/apps/index.ts +++ b/src/tapis-api/apps/index.ts @@ -1,2 +1,3 @@ export { default as list } from './list'; export { default as detail } from './detail'; +export { default as createApp } from './createApp'; diff --git a/src/tapis-app/Apps/_Layout/Layout.tsx b/src/tapis-app/Apps/_Layout/Layout.tsx index 40b2a5b45..225897765 100644 --- a/src/tapis-app/Apps/_Layout/Layout.tsx +++ b/src/tapis-app/Apps/_Layout/Layout.tsx @@ -6,6 +6,7 @@ import { LayoutHeader, LayoutNavWrapper, } from 'tapis-ui/_common'; +import AppsToolbar from '../_components/AppsToolbar'; import { Router } from '../_Router'; @@ -13,6 +14,7 @@ const Layout: React.FC = () => { const header = (
Apps
+
); diff --git a/src/tapis-app/Apps/_components/AppsToolbar/AppsToolbar.module.scss b/src/tapis-app/Apps/_components/AppsToolbar/AppsToolbar.module.scss new file mode 100644 index 000000000..9d0a6e26d --- /dev/null +++ b/src/tapis-app/Apps/_components/AppsToolbar/AppsToolbar.module.scss @@ -0,0 +1,20 @@ +.toolbar-wrapper { + padding: 0; + margin: 0; + margin-top: 0.5em; + display: flex !important; +} + +.toolbar-btn { + height: 2rem; + align-items: center; + margin-left: 0.5em; + font-size: 0.7em !important; + border-radius: 0 !important; + background-color: #f4f4f4 !important; + color: #333333 !important; +} + +.toolbar-btn:disabled { + color: #999999 !important; +} diff --git a/src/tapis-app/Apps/_components/AppsToolbar/AppsToolbar.tsx b/src/tapis-app/Apps/_components/AppsToolbar/AppsToolbar.tsx new file mode 100644 index 000000000..2da8d6a22 --- /dev/null +++ b/src/tapis-app/Apps/_components/AppsToolbar/AppsToolbar.tsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import { Button } from 'reactstrap'; +import { Icon } from 'tapis-ui/_common'; +import styles from './AppsToolbar.module.scss'; +import { useLocation } from 'react-router-dom'; +import CreateAppModal from './CreateAppModal'; + +type ToolbarButtonProps = { + text: string; + icon: string; + onClick: () => void; + disabled: boolean; +}; + +export type ToolbarModalProps = { + toggle: () => void; +}; + +export const ToolbarButton: React.FC = ({ + text, + icon, + onClick, + disabled = true, + ...rest +}) => { + return ( +
+ +
+ ); +}; + +const AppsToolbar: React.FC = () => { + const [modal, setModal] = useState(undefined); + const { pathname } = useLocation(); + + const toggle = () => { + setModal(undefined); + }; + return ( +
+ {pathname && ( +
+ setModal('createApp')} + aria-label="createApp" + /> + + {modal === 'createApp' && } +
+ )} +
+ ); +}; + +export default AppsToolbar; diff --git a/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/CreateAppModal.module.scss b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/CreateAppModal.module.scss new file mode 100644 index 000000000..811ec6742 --- /dev/null +++ b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/CreateAppModal.module.scss @@ -0,0 +1,24 @@ +.modal-settings { + max-height: 38rem; + overflow-y: scroll; +} + +.item { + border: 1px dotted lightgray; + padding: 0.5em 0.5em 0.5em 0.5em; + margin-bottom: 1em; +} + +.array { + border: 1px solid gray; + padding: 0.5em 0.5em 0.5em 0.5em; + margin-bottom: 0.5em; +} + +.hidden { + display: none; +} + +.section-divider { + margin-bottom: 100px; +} diff --git a/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/CreateAppModal.tsx b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/CreateAppModal.tsx new file mode 100644 index 000000000..9aed47fd9 --- /dev/null +++ b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/CreateAppModal.tsx @@ -0,0 +1,547 @@ +/* eslint-disable no-template-curly-in-string */ +import { Button, Input, FormGroup, Label } from 'reactstrap'; +import { GenericModal } from 'tapis-ui/_common'; +import { SubmitWrapper } from 'tapis-ui/_wrappers'; +import { ToolbarModalProps } from '../AppsToolbar'; +import { ErrorMessage, Form, Formik } from 'formik'; +import { FormikInput } from 'tapis-ui/_common'; +import { FormikSelect } from 'tapis-ui/_common/FieldWrapperFormik'; +import { useEffect, useCallback, useState } from 'react'; +import styles from './CreateAppModal.module.scss'; +import * as Yup from 'yup'; +import { useQueryClient } from 'react-query'; +import { default as queryKeys } from 'tapis-hooks/apps/queryKeys'; +import AdvancedSettings from './Settings/AdvancedSettings'; + +import { useCreateApp } from 'tapis-hooks/apps'; +import { + RuntimeEnum, + RuntimeOptionEnum, + JobTypeEnum, +} from '@tapis/tapis-typescript-apps'; + +import { + AppArgSpec, + KeyValuePair, + ParameterSetArchiveFilter, + ParameterSetLogConfig, + AppFileInput, + AppFileInputArray, +} from '@tapis/tapis-typescript-apps'; + +const CreateAppModal: React.FC = ({ toggle }) => { + const queryClient = useQueryClient(); + const onSuccess = useCallback(() => { + queryClient.invalidateQueries(queryKeys.list); + }, [queryClient]); + + const { isLoading, isSuccess, error, reset, createApp } = useCreateApp(); + + useEffect(() => { + reset(); + }, [reset]); + + const [simplified, setSimplified] = useState(false); + const onChange = useCallback(() => { + setSimplified(!simplified); + }, [setSimplified, simplified]); + + const runtimeValues = Object.values(RuntimeEnum); + const runtimeOptionsValues = Object.values(RuntimeOptionEnum); // as RuntimeOptionEnum[]; + + const validationSchema = Yup.object({ + id: Yup.string() + .min(1, 'ID must be at least 1 character long') + .max(80, 'ID should not be longer than 80 characters') + .matches( + /^[a-zA-Z0-9_.-]+$/, + "ID must contain only alphanumeric characters, '.', '_', or '-'" + ) + .required('ID is a required field'), + version: Yup.string() + .min(1, 'Version must be at least 1 character long') + .max(80, 'Version should not be longer than 80 characters') + .matches( + /^[a-zA-Z0-9_.-]+$/, + "Version must contain only alphanumeric characters, '.', '_', or '-'" + ) + .required('Version is a required field'), + containerImage: Yup.string() + .min(1, 'Container Image must be at least 1 character long') + .max(80, 'Container Image should not be longer than 80 characters') + .matches( + /^[a-zA-Z0-9_.\-/:]+$/, + "Container Image must contain only alphanumeric characters, '.', '_', '-', '/', ':'" + ) + .required('Container Image is a required field'), + description: Yup.string().max( + 2048, + 'Description should not be longer than 2048 characters' + ), + owner: Yup.string().max( + 60, + 'Owner should not be longer than 60 characters' + ), + enabled: Yup.boolean(), + locked: Yup.boolean(), + runtimeVersion: Yup.string(), + runtimeOptions: Yup.string() + .nullable(true) + .oneOf([...runtimeOptionsValues, ''], 'Invalid runtime option') + .required('Runtime option is required unless using Docker'), + maxJobs: Yup.number().integer('Max Jobs must be an integer').nullable(), + maxJobsPerUser: Yup.number() + .integer('Max Jobs Per User must be an integer') + .nullable(), + strictFileInputs: Yup.boolean(), + tags: Yup.array().of( + Yup.string().max(50, 'Tags should not be longer than 50 characters') + ), + jobAttributes: Yup.object({ + dynamicExecSystem: Yup.boolean(), + execSystemId: Yup.string(), + execSystemExecDir: Yup.string(), + execSystemInputDir: Yup.string(), + execSystemOutputDir: Yup.string(), + execSystemLogicalQueue: Yup.string(), + archiveSystemId: Yup.string(), + archiveSystemDir: Yup.string(), + archiveOnAppError: Yup.boolean(), + isMpi: Yup.boolean(), + mpiCmd: Yup.string(), + cmdPrefix: Yup.string(), + nodeCount: Yup.number() + .integer() + .min(1, 'Node count must be at least 1') + .nullable(), + coresPerNode: Yup.number() + .integer() + .min(1, 'Cores per node must be at least 1') + .nullable(), + memoryMB: Yup.number() + .integer() + .min(1, 'Memory in MB must be at least 1') + .nullable(), + maxMinutes: Yup.number() + .integer() + .min(1, 'Max minutes must be at least 1') + .nullable(), + parameterSet: Yup.object({ + envVariables: Yup.array( + Yup.object({ + key: Yup.string() + .min(1) + .required('A key name is required for this environment variable'), + value: Yup.string().required( + 'A value is required for this environment variable' + ), + }) + ), + archiveFilter: Yup.object({ + includes: Yup.array( + Yup.string() + .min(1) + .required('A pattern must be specified for this include') + ), + excludes: Yup.array( + Yup.string() + .min(1) + .required('A pattern must be specified for this exclude') + ), + includeLaunchFiles: Yup.boolean(), + }), + fileInputs: Yup.array().of( + Yup.object().shape({ + name: Yup.string().min(1).required('A fileInput name is required'), + targetPath: Yup.string() + .min(1) + .required('A targetPath is required'), + autoMountLocal: Yup.boolean(), + }) + ), + fileInputArrays: Yup.array().of( + Yup.object().shape({ + name: Yup.string() + .min(1) + .required('A fileInputArray name is required'), + targetDir: Yup.string().min(1).required('A targetDir is required'), + }) + ), + }), + }), + }); + + const initialValues = { + // Top Level Attributes + id: '', + version: '1.0', + containerImage: '', + description: undefined, + runtime: undefined, + runtimeOptions: undefined, + jobType: undefined, + + // Advanced Attributes + // eslint-disable-next-line no-template-curly-in-string + owner: '${apiUserId}', + enabled: true, + locked: false, + runtimeVersion: undefined, + maxJobs: -1, + maxJobsPerUser: -1, + strictFileInputs: false, + tags: [], + + jobAttributes: { + description: '', + dynamicExecSystem: false, + execSystemConstraints: undefined, + execSystemId: undefined, + execSystemExecDir: undefined, + execSystemInputDir: undefined, + execSystemOutputDir: undefined, + execSystemLogicalQueue: undefined, + + archiveSystemId: undefined, + archiveSystemDir: undefined, + archiveOnAppError: true, + isMpi: undefined, + mpiCmd: undefined, + cmdPrefix: undefined, + + parameterSet: { + appArgs: undefined, + containerArgs: undefined, + schedulerOptions: undefined, + envVariables: undefined, + archiveFilter: undefined, + logConfig: undefined, + }, + fileInputs: undefined, + fileInputArrays: undefined, + nodeCount: undefined, + coresPerNode: undefined, + memoryMB: undefined, + maxMinutes: undefined, + }, + }; + + const onSubmit = ({ + id, + version, + containerImage, + description, + runtime, + runtimeOptions, + jobType, + owner, + enabled, + locked, + runtimeVersion, + maxJobs, + maxJobsPerUser, + strictFileInputs, + tags, + jobAttributes: { + // eslint-disable-next-line @typescript-eslint/no-redeclare + // description, + dynamicExecSystem, + execSystemConstraints, + execSystemId, + execSystemExecDir, + execSystemInputDir, + execSystemOutputDir, + execSystemLogicalQueue, + archiveSystemId, + archiveSystemDir, + archiveOnAppError, + isMpi, + mpiCmd, + cmdPrefix, + parameterSet: { + appArgs, + containerArgs, + schedulerOptions, + envVariables, + archiveFilter, + logConfig, + }, + fileInputs, + fileInputArrays, + nodeCount, + coresPerNode, + memoryMB, + maxMinutes, + }, + }: { + id: string; + version: string; + containerImage: string; + description: string | undefined; + runtime: RuntimeEnum | undefined; + runtimeOptions: RuntimeOptionEnum | undefined; + jobType: JobTypeEnum | undefined; + owner: string | undefined; + enabled: boolean | undefined; + locked: boolean | undefined; + runtimeVersion: string | undefined; + maxJobs: number | undefined; + maxJobsPerUser: number | undefined; + strictFileInputs: boolean | undefined; + tags: string[] | undefined; + + // jobAttributes: JobAttributes; + jobAttributes: { + // jobDescription: string | undefined; + dynamicExecSystem: boolean | undefined; + execSystemConstraints: string[] | undefined; + execSystemId: string | undefined; + execSystemExecDir: string | undefined; + execSystemInputDir: string | undefined; + execSystemOutputDir: string | undefined; + execSystemLogicalQueue: string | undefined; + archiveSystemId: string | undefined; + archiveSystemDir: string | undefined; + archiveOnAppError: boolean | undefined; + isMpi: boolean | undefined; + mpiCmd: string | undefined; + cmdPrefix: string | undefined; + // parameterSet: ParameterSet; + parameterSet: { + appArgs: Array | undefined; + containerArgs: Array | undefined; + schedulerOptions: Array | undefined; + envVariables: Array | undefined; + archiveFilter: ParameterSetArchiveFilter | undefined; + logConfig: ParameterSetLogConfig | undefined; + }; + fileInputs: Array | undefined; + fileInputArrays: AppFileInputArray[] | undefined; + nodeCount: number | undefined; + coresPerNode: number | undefined; + memoryMB: number | undefined; + maxMinutes: number | undefined; + }; + }) => { + console.log('Submitting form with values:', { + id, + version, + containerImage, + description, + runtime, + runtimeOptions, + jobType, + owner, + enabled, + locked, + runtimeVersion, + maxJobs, + maxJobsPerUser, + strictFileInputs, + + tags, + jobAttributes: { + // eslint-disable-next-line @typescript-eslint/no-redeclare + // description, + dynamicExecSystem, + execSystemConstraints, + execSystemId, + execSystemExecDir, + execSystemInputDir, + execSystemOutputDir, + execSystemLogicalQueue, + archiveSystemId, + archiveSystemDir, + archiveOnAppError, + isMpi, + mpiCmd, + cmdPrefix, + parameterSet: { + appArgs, + containerArgs, + schedulerOptions, + envVariables, + archiveFilter, + logConfig, + }, + fileInputs, + fileInputArrays, + nodeCount, + coresPerNode, + memoryMB, + maxMinutes, + }, + }); + + const runtimeOptionsArray = runtimeOptions ? [runtimeOptions] : undefined; + + createApp( + { + reqPostApp: { + id, + version, + containerImage, + description, + runtime, + runtimeOptions: runtimeOptionsArray, + jobType, + owner, + enabled, + locked, + runtimeVersion, + maxJobs, + maxJobsPerUser, + strictFileInputs, + + tags, + jobAttributes: { + // eslint-disable-next-line @typescript-eslint/no-redeclare + // jobDescription, + dynamicExecSystem, + execSystemConstraints, + execSystemId, + execSystemExecDir, + execSystemInputDir, + execSystemOutputDir, + execSystemLogicalQueue, + archiveSystemId, + archiveSystemDir, + archiveOnAppError, + isMpi, + mpiCmd, + cmdPrefix, + parameterSet: { + appArgs, + containerArgs, + schedulerOptions, + envVariables, + archiveFilter, + logConfig, + }, + fileInputs, + fileInputArrays, + nodeCount, + coresPerNode, + memoryMB, + maxMinutes, + }, + }, + }, + true, + { onSuccess } + ); + }; + + return ( + + + {(formikProps) => ( +
+ + + + + + + + {runtimeValues.map((values) => { + return ; + })} + + + {formikProps.values.runtime !== RuntimeEnum.Docker && ( + + + {runtimeOptionsValues.map((values) => { + return ; + })} + + )} + + + + + + {/* */} + + )} +
+ + } + footer={ + + + + } + /> + ); +}; + +export default CreateAppModal; diff --git a/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/Settings/AdvancedSettings.tsx b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/Settings/AdvancedSettings.tsx new file mode 100644 index 000000000..7b9bf5c31 --- /dev/null +++ b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/Settings/AdvancedSettings.tsx @@ -0,0 +1,26 @@ +import AppAttributes from './AppAttributes'; +import JobAttributes from './JobAttributes'; +import TagsSettings from './TagsSettings'; + +type AdvancedSettingsProp = { + simplified: boolean; +}; + +const AdvancedSettings: React.FC = ({ simplified }) => { + if (simplified) { + return ( +
+
+ +
+ +
+ +
+ ); + } else { + return null; + } +}; + +export default AdvancedSettings; diff --git a/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/Settings/AppAttributes.tsx b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/Settings/AppAttributes.tsx new file mode 100644 index 000000000..1fe1dc171 --- /dev/null +++ b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/Settings/AppAttributes.tsx @@ -0,0 +1,67 @@ +import { FormikInput, Collapse } from 'tapis-ui/_common'; +import { FormikCheck } from 'tapis-ui/_common/FieldWrapperFormik'; +import styles from '../CreateAppModal.module.scss'; + +const AppAttributes: React.FC = () => { + return ( +
+

Application Attributes

+ + + + + + + + + +
+ ); +}; + +export default AppAttributes; diff --git a/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/Settings/JobAttributes.tsx b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/Settings/JobAttributes.tsx new file mode 100644 index 000000000..fda56782b --- /dev/null +++ b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/Settings/JobAttributes.tsx @@ -0,0 +1,45 @@ +import { Collapse } from 'tapis-ui/_common'; +import styles from '../CreateAppModal.module.scss'; +import { FileInputArrays } from 'tapis-ui/components/apps/AppCreate/JobAttributes/FileInputArrays'; +import { FileInputs } from 'tapis-ui/components/apps/AppCreate/JobAttributes/FileInputs'; +import { ExecOptions } from 'tapis-ui/components/apps/AppCreate/JobAttributes/ExecOptions'; +import { Args } from 'tapis-ui/components/apps/AppCreate/JobAttributes/Args'; +import { SchedulerOptions } from 'tapis-ui/components/apps/AppCreate/JobAttributes/SchedulerOptions'; +import { EnvVariables } from 'tapis-ui/components/apps/AppCreate/JobAttributes/EnvVariables'; +import { Archive } from 'tapis-ui/components/apps/AppCreate/JobAttributes/Archive'; + +const JobSettings: React.FC = () => { + return ( +
+

Exec Options

+ + + +
+ +

App Arguments

+ +
+ +

Scheduler Arguments

+ +
+ +

Environment Variables

+ +
+ +

Archive Options

+ + + +
+ +

File Options

+ + +
+ ); +}; + +export default JobSettings; diff --git a/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/Settings/TagsSettings.tsx b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/Settings/TagsSettings.tsx new file mode 100644 index 000000000..9556c2ee3 --- /dev/null +++ b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/Settings/TagsSettings.tsx @@ -0,0 +1,70 @@ +import React, { useState } from 'react'; +import { Button, Input, FormGroup, Label, Collapse } from 'reactstrap'; +import { useFormikContext, FieldArray } from 'formik'; +import { Apps } from '@tapis/tapis-typescript'; + +const TagsSettings: React.FC = () => { + const { values, setFieldValue } = useFormikContext(); + const [newTag, setNewTag] = useState(''); + const [isOpen, setIsOpen] = useState(false); + + const tags = values.tags ?? []; + + const toggleCollapse = () => setIsOpen(!isOpen); + + const handleAddTag = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && newTag.trim() !== '') { + e.preventDefault(); // Prevent form submission + const newTags = [...tags, newTag.trim()]; + setFieldValue('tags', newTags); + setNewTag(''); // Clear input for next tag + } + }; + + const handleRemoveTag = (index: number) => { + const newTags = tags.filter((_, tagIndex) => index !== tagIndex); + setFieldValue('tags', newTags); + }; + + return ( + <> + + + setNewTag(e.target.value)} + onKeyDown={handleAddTag} + placeholder="Type tag and press Enter" + /> + ( + <> + {tags.map((tag, index) => ( + + + + + ))} + + )} + /> + + + ); +}; + +export default TagsSettings; diff --git a/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/index.ts b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/index.ts new file mode 100644 index 000000000..3f36c341b --- /dev/null +++ b/src/tapis-app/Apps/_components/AppsToolbar/CreateAppModal/index.ts @@ -0,0 +1,3 @@ +import CreateAppModal from './CreateAppModal'; + +export default CreateAppModal; diff --git a/src/tapis-app/Apps/_components/AppsToolbar/index.ts b/src/tapis-app/Apps/_components/AppsToolbar/index.ts new file mode 100644 index 000000000..c846981d9 --- /dev/null +++ b/src/tapis-app/Apps/_components/AppsToolbar/index.ts @@ -0,0 +1,3 @@ +import AppsToolbar from './AppsToolbar'; + +export default AppsToolbar; diff --git a/src/tapis-app/_Router/Router.tsx b/src/tapis-app/_Router/Router.tsx index e0578c84a..7a5408cfc 100644 --- a/src/tapis-app/_Router/Router.tsx +++ b/src/tapis-app/_Router/Router.tsx @@ -10,7 +10,6 @@ import Jobs from '../Jobs'; import Systems from '../Systems'; import Files from '../Files'; import Workflows from '../Workflows'; -import Pods from '../Pods'; import UIPatterns from '../UIPatterns'; const Router: React.FC = () => { @@ -49,9 +48,6 @@ const Router: React.FC = () => { {/* TODO: create ML Hub */} - - - UI Patterns diff --git a/src/tapis-hooks/apps/index.ts b/src/tapis-hooks/apps/index.ts index 8e9a9e3cc..66300c064 100644 --- a/src/tapis-hooks/apps/index.ts +++ b/src/tapis-hooks/apps/index.ts @@ -1,2 +1,3 @@ export { default as useList } from './useList'; export { default as useDetail } from './useDetail'; +export { default as useCreateApp } from './useCreateApp'; diff --git a/src/tapis-hooks/apps/queryKeys.ts b/src/tapis-hooks/apps/queryKeys.ts index bc854539b..0aa49452c 100644 --- a/src/tapis-hooks/apps/queryKeys.ts +++ b/src/tapis-hooks/apps/queryKeys.ts @@ -1,5 +1,6 @@ const QueryKeys = { list: 'apps/list', + createApp: 'apps/createApp', }; export default QueryKeys; diff --git a/src/tapis-hooks/apps/useCreateApp.ts b/src/tapis-hooks/apps/useCreateApp.ts new file mode 100644 index 000000000..034b00ee4 --- /dev/null +++ b/src/tapis-hooks/apps/useCreateApp.ts @@ -0,0 +1,40 @@ +import { useMutation, MutateOptions } from 'react-query'; +import { useTapisConfig } from '../context'; +import QueryKeys from './queryKeys'; +import { Apps } from '@tapis/tapis-typescript'; +import { createApp } from 'tapis-api/apps'; + +type createAppParams = { + createAppVersionRequest: Apps.CreateAppVersionRequest; + skipCredentialCheck: boolean; +}; + +const useCreateApp = () => { + const { basePath, accessToken } = useTapisConfig(); + const jwt = accessToken?.access_token || ''; + + const { mutate, isLoading, isError, isSuccess, data, error, reset } = + useMutation( + [QueryKeys.createApp, basePath, jwt], + ({ createAppVersionRequest }) => + createApp(createAppVersionRequest, basePath, jwt) + ); + + return { + isLoading, + isError, + isSuccess, + data, + error, + reset, + createApp: ( + createAppVersionRequest: Apps.CreateAppVersionRequest, + skipCredentialCheck: boolean = true, + options?: MutateOptions + ) => { + return mutate({ createAppVersionRequest, skipCredentialCheck }, options); + }, + }; +}; + +export default useCreateApp; diff --git a/src/tapis-ui/components/apps/AppCreate/JobAttributes/Archive.tsx b/src/tapis-ui/components/apps/AppCreate/JobAttributes/Archive.tsx new file mode 100644 index 000000000..d76f00c08 --- /dev/null +++ b/src/tapis-ui/components/apps/AppCreate/JobAttributes/Archive.tsx @@ -0,0 +1,191 @@ +import React from 'react'; +import { Apps } from '@tapis/tapis-typescript'; +import FieldWrapper from 'tapis-ui/_common/FieldWrapper'; +import { Input } from 'reactstrap'; +import { Button } from 'reactstrap'; +import fieldArrayStyles from './FieldArray.module.scss'; +import { Collapse } from 'tapis-ui/_common'; +import { + FieldArray, + useFormikContext, + Field, + ErrorMessage, + FieldProps, +} from 'formik'; + +import { InputGroup, InputGroupAddon } from 'reactstrap'; +import { + FormikCheck, + FormikTapisFile, + FormikSelect, +} from 'tapis-ui/_common/FieldWrapperFormik'; +import formStyles from 'tapis-ui/_common/FieldWrapperFormik/FieldWrapperFormik.module.css'; +import { useList } from 'tapis-hooks/systems'; +import { ListTypeEnum } from '@tapis/tapis-typescript-systems'; + +type ArrayGroupProps = { + values: Array; + name: string; + label: string; + description: string; +}; + +const ArrayGroup: React.FC = ({ + values, + name, + label, + description, +}) => { + return ( + ( + 0} + title={label} + note={`${values.length} items`} + isCollapsable={true} + className={fieldArrayStyles.array} + > + +
+ {values.map((value, index) => ( + <> + + {({ field }: FieldProps) => ( + + + + + + + )} + + + {(message) => ( +
+ {message} +
+ )} +
+ + ))} +
+ +
+
+ )} + /> + ); +}; + +const ArchiveFilterRender: React.FC = () => { + const { values } = useFormikContext(); + const includes = + (values as Partial).jobAttributes?.parameterSet + ?.archiveFilter?.includes ?? []; + const excludes = + (values as Partial).jobAttributes?.parameterSet + ?.archiveFilter?.excludes ?? []; + return ( +
+

Archive Filters

+ + + +
+ ); +}; + +const ArchiveOptions: React.FC = () => { + const { data, isLoading, isError } = useList({ listType: ListTypeEnum.All }); + + const { values } = useFormikContext(); + + if (isLoading) return
Loading systems...
; + if (isError) return
Error loading systems.
; + + const archiveSystemId = values.jobAttributes?.archiveSystemId; + + return ( + <> +
+ + + {data?.result?.map((system) => ( + + ))} + + + +
+ + ); +}; + +export const Archive: React.FC = () => { + return ( +
+ + +
+ ); +}; + +export default Archive; diff --git a/src/tapis-ui/components/apps/AppCreate/JobAttributes/Args.tsx b/src/tapis-ui/components/apps/AppCreate/JobAttributes/Args.tsx new file mode 100644 index 000000000..8280b7055 --- /dev/null +++ b/src/tapis-ui/components/apps/AppCreate/JobAttributes/Args.tsx @@ -0,0 +1,190 @@ +import React, { useMemo } from 'react'; +import { Apps, Jobs } from '@tapis/tapis-typescript'; +import { Button } from 'reactstrap'; +import fieldArrayStyles from './FieldArray.module.scss'; +import { Collapse } from 'tapis-ui/_common'; +import { + FieldArray, + useField, + FieldArrayRenderProps, + useFormikContext, +} from 'formik'; +import { FormikInput } from 'tapis-ui/_common'; +import { FormikCheck } from 'tapis-ui/_common/FieldWrapperFormik'; +import { getArgMode } from 'tapis-api/utils/jobArgs'; +import * as Yup from 'yup'; + +type ArgFieldProps = { + index: number; + name: string; + argType: string; + arrayHelpers: FieldArrayRenderProps; + inputMode?: Apps.ArgInputModeEnum; +}; + +export const ArgField: React.FC = ({ + index, + name, + argType, + arrayHelpers, + inputMode, +}) => { + const [field] = useField(`${name}.name`); + const argName = useMemo(() => field.value, [field]); + return ( + + + + + + + + ); +}; + +type ArgsFieldArrayProps = { + argSpecs: Array; + name: string; + argType: string; +}; + +export const ArgsFieldArray: React.FC = ({ + argSpecs, + name, + argType, +}) => { + const [field] = useField(name); + const args = useMemo( + () => (field.value as Array) ?? [], + [field] + ); + return ( + ( +
+

{`${argType}s`}

+
+ {args.map((arg, index) => { + const inputMode = arg.name + ? getArgMode(arg.name, argSpecs) + : undefined; + return ( + + ); + })} +
+ +
+ )} + /> + ); +}; + +export const argsSchema = Yup.array( + Yup.object({ + name: Yup.string(), + description: Yup.string(), + include: Yup.boolean(), + arg: Yup.string().min(1).required('The argument cannot be blank'), + }) +); + +export const Args: React.FC = () => { + const { values } = useFormikContext(); + + const appArgSpecs = useMemo( + () => + (values as Partial).jobAttributes?.parameterSet + ?.appArgs ?? [], + [values] + ); + const containerArgSpecs = useMemo( + () => + (values as Partial).jobAttributes?.parameterSet + ?.containerArgs ?? [], + [values] + ); + + return ( +
+ {/* delete? */} + + +
+ ); +}; + +export const assembleArgSpec = (argSpecs: Array) => + argSpecs.reduce( + (previous, current) => + `${previous}${current.include ? ` ${current.arg}` : ``}`, + '' + ); + +export default ; diff --git a/src/tapis-ui/components/apps/AppCreate/JobAttributes/EnvVariables.tsx b/src/tapis-ui/components/apps/AppCreate/JobAttributes/EnvVariables.tsx new file mode 100644 index 000000000..4787d6fa1 --- /dev/null +++ b/src/tapis-ui/components/apps/AppCreate/JobAttributes/EnvVariables.tsx @@ -0,0 +1,98 @@ +import React, { useMemo } from 'react'; +import { Apps } from '@tapis/tapis-typescript'; +import { Button } from 'reactstrap'; +import fieldArrayStyles from './FieldArray.module.scss'; +import { Collapse } from 'tapis-ui/_common'; +import { + FieldArray, + useFormikContext, + useField, + FieldArrayRenderProps, +} from 'formik'; +import { FormikInput } from 'tapis-ui/_common'; + +type EnvVariableFieldProps = { + index: number; + arrayHelpers: FieldArrayRenderProps; +}; + +const EnvVariableField: React.FC = ({ + index, + arrayHelpers, +}) => { + const [field] = useField( + `jobAttributes.parameterSet.envVariables.${index}.key` + ); + const key = useMemo(() => field.value, [field]); + return ( + + + + + + ); +}; + +const EnvVariablesRender: React.FC = () => { + const { values } = useFormikContext(); + const envVariables = + (values as Partial).jobAttributes?.parameterSet + ?.envVariables ?? []; + + return ( + ( + <> +
+

{`Environment Variables`}

+
+ {envVariables.map((envVariable, index) => ( + + ))} +
+ +
+ + )} + /> + ); +}; + +export const EnvVariables: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default EnvVariables; diff --git a/src/tapis-ui/components/apps/AppCreate/JobAttributes/ExecOptions.tsx b/src/tapis-ui/components/apps/AppCreate/JobAttributes/ExecOptions.tsx new file mode 100644 index 000000000..8468e5cda --- /dev/null +++ b/src/tapis-ui/components/apps/AppCreate/JobAttributes/ExecOptions.tsx @@ -0,0 +1,242 @@ +import { useMemo, useEffect, useState } from 'react'; +import { Apps, Systems } from '@tapis/tapis-typescript'; +import { + FormikInput, + FormikCheck, + FormikSelect, + FormikTapisFile, +} from 'tapis-ui/_common/FieldWrapperFormik'; +import { useFormikContext } from 'formik'; +import { Collapse } from 'tapis-ui/_common'; +import React from 'react'; +import fieldArrayStyles from './FieldArray.module.scss'; +import { useList } from 'tapis-hooks/systems'; +import { ListTypeEnum } from '@tapis/tapis-typescript-systems'; +import { JobTypeEnum } from '@tapis/tapis-typescript-apps'; + +const ExecSystemDirs: React.FC = () => { + const { values } = useFormikContext(); + const execSystemId = useMemo( + () => (values as Partial).jobAttributes?.execSystemId, + [values] + ); + return ( + + + + + + ); +}; + +const ExecSystemQueueOptions: React.FC = () => { + const { errors } = useFormikContext(); + const queueErrors = errors as QueueErrors; + const hasErrors = + queueErrors.coresPerNode || + queueErrors.maxMinutes || + queueErrors.memoryMB || + queueErrors.nodeCount; + return ( + + + + + + + ); +}; + +const MPIOptions: React.FC = () => { + const { values } = useFormikContext(); + const isMpi = useMemo( + () => (values as Partial).jobAttributes?.isMpi, + [values] + ); + return ( + + + + + + ); +}; + +export const ExecOptions: React.FC = () => { + const { values, setFieldValue } = useFormikContext(); + const isBatch = useMemo( + () => values?.jobType === JobTypeEnum.Batch, + [values?.jobType] + ); + + const { data, isLoading, isError } = useList({ + listType: ListTypeEnum.All, + select: 'allAttributes', + }); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [selectedSystem, setSelectedSystem] = + useState(null); + const [queues, setQueues] = useState>([]); + + const getLogicalQueues = (system?: Systems.TapisSystem) => + system?.batchLogicalQueues ?? []; + + useEffect(() => { + const systemId = values.jobAttributes?.execSystemId; + const system = data?.result?.find((sys) => sys.id === systemId) || null; + setSelectedSystem(system); + + if (system) { + const newQueues = getLogicalQueues(system); + setQueues(newQueues); + } else { + setQueues([]); + } + + if (!isBatch) { + setFieldValue('jobAttributes.execSystemLogicalQueue', undefined); + } + }, [ + data?.result, + values.jobAttributes?.execSystemId, + setFieldValue, + isBatch, + ]); + + if (isLoading) return
Loading systems...
; + if (isError) return
Error fetching systems.
; + + return ( +
+ {/* System selection */} +
+ + + {data?.result?.map((system) => ( + + ))} + + {/* Job type selection */} + + + + + + {/* Queue selection for batch jobs */} + {isBatch && ( + + + {queues.map((queue) => ( + + ))} + + )} +
+ + {isBatch && } + +
+ ); +}; + +type QueueErrors = { + nodeCount?: string; + coresPerNode?: string; + memoryMB?: string; + maxMinutes?: string; + execSystemId?: string; + execSystemLogicalQueue?: string; +}; + +export default ; diff --git a/src/tapis-ui/components/apps/AppCreate/JobAttributes/FieldArray.module.scss b/src/tapis-ui/components/apps/AppCreate/JobAttributes/FieldArray.module.scss new file mode 100644 index 000000000..e175189b7 --- /dev/null +++ b/src/tapis-ui/components/apps/AppCreate/JobAttributes/FieldArray.module.scss @@ -0,0 +1,21 @@ +.array { + border: 1px solid gray; + padding: 0.5em 0.5em 0.5em 0.5em; + margin-bottom: 0.5em; +} + +.item { + border: 1px dotted lightgray; + padding: 0.5em 0.5em 0.5em 0.5em; + margin-bottom: 1em; +} + +.array-group { + margin-bottom: 0.5em; +} + +.description { + font-style: italic; + font-size: smaller; + margin-bottom: 0.5em; +} diff --git a/src/tapis-ui/components/apps/AppCreate/JobAttributes/FileInputArrays.module.scss b/src/tapis-ui/components/apps/AppCreate/JobAttributes/FileInputArrays.module.scss new file mode 100644 index 000000000..440f5c852 --- /dev/null +++ b/src/tapis-ui/components/apps/AppCreate/JobAttributes/FileInputArrays.module.scss @@ -0,0 +1,7 @@ +.sourceUrls { + margin-bottom: 0.5em; +} + +.inputMargin { + margin-bottom: 5px; +} diff --git a/src/tapis-ui/components/apps/AppCreate/JobAttributes/FileInputArrays.tsx b/src/tapis-ui/components/apps/AppCreate/JobAttributes/FileInputArrays.tsx new file mode 100644 index 000000000..f55b9f790 --- /dev/null +++ b/src/tapis-ui/components/apps/AppCreate/JobAttributes/FileInputArrays.tsx @@ -0,0 +1,399 @@ +import React, { useMemo, useCallback } from 'react'; +import { Apps, Files, Jobs } from '@tapis/tapis-typescript'; +import { Input, Button, FormGroup } from 'reactstrap'; +import { generateFileInputArrayFromAppInput } from 'tapis-api/utils/jobFileInputArrays'; +import { Collapse, Icon, FieldWrapper } from 'tapis-ui/_common'; +import { useModal } from 'tapis-ui/_common/GenericModal'; +import { FileSelectModal } from 'tapis-ui/components/files'; +import { + FieldArray, + useFormikContext, + FieldArrayRenderProps, + Field, + ErrorMessage, + FieldProps, +} from 'formik'; +import { + FormikInput, + FormikTapisFileInput, +} from 'tapis-ui/_common/FieldWrapperFormik'; +import arrayStyles from './FileInputArrays.module.scss'; +import styles from './FileInputs.module.scss'; +import fieldArrayStyles from './FieldArray.module.scss'; +import { useJobLauncher } from 'tapis-ui/components/jobs/JobLauncher/components'; + +export type FieldWrapperProps = { + fileInputArrayIndex: number; + arrayHelpers: FieldArrayRenderProps; +}; + +const SourceUrlsField: React.FC = ({ + fileInputArrayIndex, + arrayHelpers, +}) => { + const { values, setFieldValue } = + useFormikContext>(); + + const { modal, open, close } = useModal(); + const { push } = arrayHelpers; + + const sourceUrls: Array = useMemo( + () => + values.jobAttributes?.fileInputArrays?.[fileInputArrayIndex] + ?.sourceUrls ?? [], + [values, fileInputArrayIndex] + ); + + const onSelect = useCallback( + (systemId: string | null, files: Array) => { + files.forEach((file) => { + const newSourceUrl = `tapis://${systemId ?? 'here'}${file.path}`; + if (!sourceUrls.some((sourceUrl) => sourceUrl === newSourceUrl)) { + push(newSourceUrl); + } + }); + }, + [push, sourceUrls] + ); + + const handleUrlChange = useCallback( + (index, event) => { + const newUrls = [...sourceUrls]; + newUrls[index] = event.target.value; + setFieldValue( + `jobAttributes.fileInputArrays.${fileInputArrayIndex}.sourceUrls`, + newUrls + ); + }, + [sourceUrls, setFieldValue, fileInputArrayIndex] + ); + + const addUrlField = () => { + const newUrls = [...sourceUrls, '']; + setFieldValue( + `jobAttributes.fileInputArrays.${fileInputArrayIndex}.sourceUrls`, + newUrls + ); + }; + + return ( + +
+ {sourceUrls.map((url, index) => ( +
+ + {({ field }: FieldProps) => ( + handleUrlChange(index, event)} + append={ + + } + /> + )} + + + +
+ ))} +
+
+ + +
+ {modal && } +
+ ); +}; + +type AppInputArrayFieldProps = { + item: Apps.AppFileInputArray; + index: number; + remove: (index: number) => Apps.AppFileInputArray | undefined; +}; + +const upperCaseFirstLetter = (str: string) => { + const lower = str.toLowerCase(); + return `${lower.slice(0, 1).toUpperCase()}${lower.slice(1)}`; +}; + +const AppInputArrayField: React.FC = ({ + item, + index, + remove, +}) => { + const { name, sourceUrls } = item; + const inputMode: Apps.FileInputModeEnum | undefined = undefined; + const isRequired = inputMode === Apps.FileInputModeEnum.Required; + const note = `${ + inputMode ? upperCaseFirstLetter(inputMode) : 'User Defined' + }`; + return ( + + + ( + + + + )} + /> + + + {!isRequired && ( + + )} + + ); +}; + +const getFileInputArraysOfMode = ( + app: Apps.TapisApp, + inputMode: Apps.FileInputModeEnum +) => + app.jobAttributes?.fileInputArrays?.filter( + (appInputArray) => appInputArray.inputMode === inputMode + ) ?? []; + +const inputArrayIncluded = ( + input: Apps.AppFileInputArray, + jobInputArrays: Array +) => { + return jobInputArrays.some( + (jobInputArray) => jobInputArray.name === input.name + ); +}; + +type OptionalInputArrayProps = { + inputArray: Apps.AppFileInputArray; + included: boolean; + onInclude: () => any; +}; + +const OptionalInputArray: React.FC = ({ + inputArray, + included, + onInclude, +}) => { + return ( + +
+ {inputArray.description ?? ''} +
+ + {inputArray.sourceUrls?.map((sourceUrl) => ( + + ))} + + + + + + {included && ( +
+ This optional input array has already been included with your job file + inputs. +
+ )} +
+ ); +}; + +const OptionalInputArrays: React.FC<{ arrayHelpers: FieldArrayRenderProps }> = + ({ arrayHelpers }) => { + const { app } = useJobLauncher(); + const { values } = useFormikContext(); + + const optionalInputArrays = useMemo( + () => getFileInputArraysOfMode(app, Apps.FileInputModeEnum.Optional), + /* eslint-disable-next-line */ + [app.id, app.version] + ); + + const formFileInputArrays = + (values as Partial)?.jobAttributes?.fileInputArrays ?? + []; + + return !!optionalInputArrays.length ? ( + +
+ These File Inputs are defined in the application and can be included + with your job. +
+ {optionalInputArrays.map((optionalInputArray) => { + const alreadyIncluded = inputArrayIncluded( + optionalInputArray, + formFileInputArrays + ); + const onInclude = () => { + arrayHelpers.push( + generateFileInputArrayFromAppInput(optionalInputArray) + ); + }; + return ( +
+ +
+ ); + })} +
+ ) : null; + }; + +const AppInputArrays: React.FC<{ arrayHelpers: FieldArrayRenderProps }> = ({ + arrayHelpers, +}) => { + const { values, setFieldValue } = + useFormikContext>(); + let requiredText = ''; + const appInputArrays = + (values as Partial)?.jobAttributes?.fileInputArrays ?? []; + + return ( + +
+ These File Input Arrays will be submitted with your job. +
+ {appInputArrays.map((appInputArray, index) => ( + + ))} + +
+ ); +}; + +export const FileInputArrays: React.FC = () => { + return ( +
+

File Input Arrays

+ { + return ( + <> + + + {/* */} + + ); + }} + /> +
+ ); +}; + +export default FileInputArrays; diff --git a/src/tapis-ui/components/apps/AppCreate/JobAttributes/FileInputs.module.scss b/src/tapis-ui/components/apps/AppCreate/JobAttributes/FileInputs.module.scss new file mode 100644 index 000000000..23cb29f05 --- /dev/null +++ b/src/tapis-ui/components/apps/AppCreate/JobAttributes/FileInputs.module.scss @@ -0,0 +1,12 @@ +.remove { + margin-top: 0.75em; +} + +.nospace { + padding-top: 0; + padding-bottom: 0; +} + +.job-input { + margin-bottom: 0.5em; +} diff --git a/src/tapis-ui/components/apps/AppCreate/JobAttributes/FileInputs.tsx b/src/tapis-ui/components/apps/AppCreate/JobAttributes/FileInputs.tsx new file mode 100644 index 000000000..5c56143d2 --- /dev/null +++ b/src/tapis-ui/components/apps/AppCreate/JobAttributes/FileInputs.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { Jobs } from '@tapis/tapis-typescript'; +import { Button } from 'reactstrap'; +import fieldArrayStyles from './FieldArray.module.scss'; +import { Collapse } from 'tapis-ui/_common'; +import { FieldArray, useFormikContext, FieldArrayRenderProps } from 'formik'; +import { + FormikInput, + FormikCheck, + FormikTapisFile, + FormikSelect, +} from 'tapis-ui/_common/FieldWrapperFormik'; + +import { FileInputModeEnum } from '@tapis/tapis-typescript-apps'; + +type FileInputFieldProps = { + item: Jobs.JobFileInput; + index: number; + remove: (index: number) => Jobs.JobFileInput | undefined; +}; + +const JobInputField: React.FC = ({ + item, + index, + remove, +}) => { + const { name, sourceUrl } = item; + const isRequired = false; + const note = 'User Defined'; + const fileInputModeValues = Object.values(FileInputModeEnum); + + return ( + <> + + + + + + + + + {fileInputModeValues.map((values) => { + return ; + })} + + + {!isRequired && ( + + )} + + + ); +}; + +const JobInputs: React.FC<{ arrayHelpers: FieldArrayRenderProps }> = ({ + arrayHelpers, +}) => { + const { values } = useFormikContext(); + let requiredText = ''; + const jobInputs = (values as Partial)?.fileInputs ?? []; + + return ( + +
+ These File Inputs will be submitted with your job. +
+ {jobInputs.map((jobInput, index) => ( + + ))} + +
+ ); +}; + +export const FileInputs: React.FC = () => { + return ( +
+ { + return ( + <> + + + ); + }} + /> +
+ ); +}; + +export default FileInputs; diff --git a/src/tapis-ui/components/apps/AppCreate/JobAttributes/SchedulerOptions.module.scss b/src/tapis-ui/components/apps/AppCreate/JobAttributes/SchedulerOptions.module.scss new file mode 100644 index 000000000..d24abc588 --- /dev/null +++ b/src/tapis-ui/components/apps/AppCreate/JobAttributes/SchedulerOptions.module.scss @@ -0,0 +1,9 @@ +.scheduler-option { + font-size: 0.75em; +} + +.scheduler-option-list { + dd { + padding-left: 1rem !important; + } +} diff --git a/src/tapis-ui/components/apps/AppCreate/JobAttributes/SchedulerOptions.tsx b/src/tapis-ui/components/apps/AppCreate/JobAttributes/SchedulerOptions.tsx new file mode 100644 index 000000000..8e67a33a5 --- /dev/null +++ b/src/tapis-ui/components/apps/AppCreate/JobAttributes/SchedulerOptions.tsx @@ -0,0 +1,63 @@ +import React, { useMemo } from 'react'; +import { Jobs } from '@tapis/tapis-typescript'; +import { Button } from 'reactstrap'; +import fieldArrayStyles from './FieldArray.module.scss'; +import { FieldArray, useField } from 'formik'; +import { ArgField } from './Args'; + +const SchedulerOptionArray: React.FC = () => { + const [field] = useField('jobAttributes.parameterSet.schedulerOptions'); + const args = useMemo( + () => (field.value as Array) ?? [], + [field] + ); + + return ( + ( + <> +
+

{`Scheduler Arguments`}

+
+ {args.map((arg, index) => { + return ( + + ); + })} +
+ +
+ + )} + /> + ); +}; + +export const SchedulerOptions: React.FC = () => { + return ( +
+ +
+ ); +}; + +export default SchedulerOptions; diff --git a/src/tapis-ui/components/apps/AppCreate/JobAttributes/index.ts b/src/tapis-ui/components/apps/AppCreate/JobAttributes/index.ts new file mode 100644 index 000000000..925785779 --- /dev/null +++ b/src/tapis-ui/components/apps/AppCreate/JobAttributes/index.ts @@ -0,0 +1,7 @@ +export { default as Archive } from './Archive'; +export { default as Args } from './Args'; +export { default as EnvVariables } from './EnvVariables'; +export { default as ExecOptions } from './ExecOptions'; +export { default as FileInputArrays } from './FileInputArrays'; +export { default as FileInputs } from './FileInputs'; +export { default as SchedulerOptions } from './SchedulerOptions'; diff --git a/src/tapis-ui/components/apps/AppCreate/JobAttributes/utils.ts b/src/tapis-ui/components/apps/AppCreate/JobAttributes/utils.ts new file mode 100644 index 000000000..9dbfc3a74 --- /dev/null +++ b/src/tapis-ui/components/apps/AppCreate/JobAttributes/utils.ts @@ -0,0 +1,16 @@ +export const upperCaseFirstLetter = (str: string) => { + const lower = str.toLowerCase(); + return `${lower.slice(0, 1).toUpperCase()}${lower.slice(1)}`; +}; + +export const capitalize = (str: string) => { + return str!.charAt(0).toUpperCase() + str!.slice(1); +}; + +export const reduceRecord = (record: Record<'id', string>) => { + const { id, ...contents } = record; + return Object.values(contents).reduce( + (prev, current) => ((prev as string) + current) as string, + '' + ); +};