1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import React, { ReactElement, useState } from 'react';
26 } from '@material-ui/core';
27 import { ArvadosTheme } from 'common/custom-theme';
28 import { CloseIcon, InfoIcon, ProcessIcon, InputIcon, OutputIcon } from 'components/icon/icon';
29 import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
31 BooleanCommandInputParameter,
32 CommandInputParameter,
35 DirectoryArrayCommandInputParameter,
36 DirectoryCommandInputParameter,
37 EnumCommandInputParameter,
38 FileArrayCommandInputParameter,
39 FileCommandInputParameter,
40 FloatArrayCommandInputParameter,
41 FloatCommandInputParameter,
42 IntArrayCommandInputParameter,
43 IntCommandInputParameter,
46 StringArrayCommandInputParameter,
47 StringCommandInputParameter,
48 } from "models/workflow";
49 import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
50 import { File } from 'models/workflow';
51 import { getInlineFileUrl } from 'views-components/context-menu/actions/helpers';
52 import { AuthState } from 'store/auth/auth-reducer';
53 import mime from 'mime';
54 import { DefaultView } from 'components/default-view/default-view';
55 import { getNavUrl } from 'routes/routes';
56 import { Link as RouterLink } from 'react-router-dom';
57 import { Link as MuiLink } from '@material-ui/core';
58 import { InputCollectionMount } from 'store/processes/processes-actions';
59 import { connect } from 'react-redux';
60 import { RootState } from 'store/store';
61 import { ProcessOutputCollectionFiles } from './process-output-collection-files';
63 type CssRules = 'card' | 'content' | 'title' | 'header' | 'avatar' | 'iconHeader' | 'tableWrapper' | 'tableRoot' | 'paramValue' | 'keepLink' | 'imagePreview' | 'valArray' | 'emptyValue';
65 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
70 paddingTop: theme.spacing.unit,
71 paddingBottom: theme.spacing.unit,
75 color: theme.customs.colors.green700,
78 alignSelf: 'flex-start',
79 paddingTop: theme.spacing.unit * 0.5
82 padding: theme.spacing.unit * 1.0,
83 paddingTop: theme.spacing.unit * 0.5,
85 paddingBottom: theme.spacing.unit * 1,
90 paddingTop: theme.spacing.unit * 0.5
100 alignItems: 'flex-start',
101 flexDirection: 'column',
104 color: theme.palette.primary.main,
105 textDecoration: 'none',
106 overflowWrap: 'break-word',
112 marginBottom: theme.spacing.unit,
123 color: theme.customs.colors.grey500,
127 export enum ProcessIOCardType {
131 export interface ProcessIOCardDataProps {
132 label: ProcessIOCardType;
133 params: ProcessIOParameter[];
135 mounts?: InputCollectionMount[];
139 type ProcessIOCardProps = ProcessIOCardDataProps & WithStyles<CssRules> & MPVPanelProps;
141 export const ProcessIOCard = withStyles(styles)(
142 ({ classes, label, params, raw, mounts, outputUuid, doHidePanel, panelName }: ProcessIOCardProps) => {
143 const [tabState, setTabState] = useState(0);
144 const handleChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
147 const PanelIcon = label == ProcessIOCardType.INPUT ? InputIcon : OutputIcon;
149 return <Card className={classes.card} data-cy="process-io-card">
151 className={classes.header}
153 content: classes.title,
154 avatar: classes.avatar,
156 avatar={<PanelIcon className={classes.iconHeader} />}
158 <Typography noWrap variant='h6' color='inherit'>
165 <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
166 <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
170 <CardContent className={classes.content}>
172 <Tabs value={tabState} onChange={handleChange} variant="fullWidth">
173 <Tab label="Preview" />
175 {label === ProcessIOCardType.INPUT && <Tab label="Input Mounts" />}
176 {label === ProcessIOCardType.OUTPUT && <Tab label="Output Collection" />}
178 {tabState === 0 && <div className={classes.tableWrapper}>
180 <ProcessIOPreview data={params} /> :
181 <Grid container item alignItems='center' justify='center'>
182 <DefaultView messages={["No parameters found"]} icon={InfoIcon} />
185 {tabState === 1 && <div className={classes.tableWrapper}>
187 <ProcessIORaw data={raw || params} /> :
188 <Grid container item alignItems='center' justify='center'>
189 <DefaultView messages={["No parameters found"]} icon={InfoIcon} />
192 {tabState === 2 && <div className={classes.tableWrapper}>
193 {label === ProcessIOCardType.INPUT && <ProcessInputMounts mounts={mounts || []} />}
194 {label === ProcessIOCardType.OUTPUT && <ProcessOutputCollectionFiles isWritable={false} currentItemUuid={outputUuid} />}
202 export type ProcessIOValue = {
203 display: ReactElement<any, any>;
207 export type ProcessIOParameter = {
210 value: ProcessIOValue[];
213 interface ProcessIOPreviewDataProps {
214 data: ProcessIOParameter[];
217 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
219 const ProcessIOPreview = withStyles(styles)(
220 ({ classes, data }: ProcessIOPreviewProps) =>
221 <Table className={classes.tableRoot} aria-label="Process IO Preview">
224 <TableCell>Label</TableCell>
225 <TableCell>Description</TableCell>
226 <TableCell>Value</TableCell>
230 {data.map((param: ProcessIOParameter) => {
231 return <TableRow key={param.id}>
232 <TableCell component="th" scope="row">
235 <TableCell>{param.doc}</TableCell>
236 <TableCell>{param.value.map(val => (
237 <Typography className={classes.paramValue}>
238 {val.imageUrl ? <img className={classes.imagePreview} src={val.imageUrl} alt="Inline Preview" /> : ""}
239 <span className={classes.valArray}>
250 const handleClick = (url) => {
251 window.open(url, '_blank');
254 const ProcessIORaw = withStyles(styles)(
255 ({ data }: ProcessIOPreviewProps) =>
256 <Paper elevation={0}>
258 {JSON.stringify(data, null, 2)}
263 interface ProcessInputMountsDataProps {
264 mounts: InputCollectionMount[];
267 type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
269 const ProcessInputMounts = withStyles(styles)(connect((state: RootState) => ({
271 }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
272 <Table className={classes.tableRoot} aria-label="Process Input Mounts">
275 <TableCell>Path</TableCell>
276 <TableCell>Portable Data Hash</TableCell>
280 {mounts.map(mount => (
281 <TableRow key={mount.path}>
282 <TableCell><pre>{mount.path}</pre></TableCell>
284 <RouterLink to={getNavUrl(mount.pdh, auth)} className={classes.keepLink}>{mount.pdh}</RouterLink>
292 type FileWithSecondaryFiles = {
293 secondaryFiles: File[];
296 export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
298 case isPrimitiveOfType(input, CWLType.BOOLEAN):
299 const boolValue = (input as BooleanCommandInputParameter).value;
301 return boolValue !== undefined &&
302 !(Array.isArray(boolValue) && boolValue.length === 0) ?
303 [{display: <pre>{String(boolValue)}</pre> }] :
304 [{display: <EmptyValue />}];
306 case isPrimitiveOfType(input, CWLType.INT):
307 case isPrimitiveOfType(input, CWLType.LONG):
308 const intValue = (input as IntCommandInputParameter).value;
310 return intValue !== undefined &&
311 // Missing values are empty array
312 !(Array.isArray(intValue) && intValue.length === 0) ?
313 [{display: <pre>{String(intValue)}</pre> }]
314 : [{display: <EmptyValue />}];
316 case isPrimitiveOfType(input, CWLType.FLOAT):
317 case isPrimitiveOfType(input, CWLType.DOUBLE):
318 const floatValue = (input as FloatCommandInputParameter).value;
320 return floatValue !== undefined &&
321 !(Array.isArray(floatValue) && floatValue.length === 0) ?
322 [{display: <pre>{String(floatValue)}</pre> }]:
323 [{display: <EmptyValue />}];
325 case isPrimitiveOfType(input, CWLType.STRING):
326 const stringValue = (input as StringCommandInputParameter).value || undefined;
328 return stringValue !== undefined &&
329 !(Array.isArray(stringValue) && stringValue.length === 0) ?
330 [{display: <pre>{stringValue}</pre> }] :
331 [{display: <EmptyValue />}];
333 case isPrimitiveOfType(input, CWLType.FILE):
334 const mainFile = (input as FileCommandInputParameter).value;
335 // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
336 const secondaryFiles = ((mainFile as unknown) as FileWithSecondaryFiles)?.secondaryFiles || [];
338 ...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []),
342 return files.length ?
343 files.map(file => fileToProcessIOValue(file, auth, pdh)) :
344 [{display: <EmptyValue />}];
346 case isPrimitiveOfType(input, CWLType.DIRECTORY):
347 const directory = (input as DirectoryCommandInputParameter).value;
349 return directory !== undefined &&
350 !(Array.isArray(directory) && directory.length === 0) ?
351 [directoryToProcessIOValue(directory, auth, pdh)] :
352 [{display: <EmptyValue />}];
354 case typeof input.type === 'object' &&
355 !(input.type instanceof Array) &&
356 input.type.type === 'enum':
357 const enumValue = (input as EnumCommandInputParameter).value;
359 return enumValue !== undefined ?
360 [{ display: <pre>{(input as EnumCommandInputParameter).value || ''}</pre> }] :
361 [{display: <EmptyValue />}];
363 case isArrayOfType(input, CWLType.STRING):
364 const strArray = (input as StringArrayCommandInputParameter).value || [];
365 return strArray.length ?
366 [{ display: <>{((input as StringArrayCommandInputParameter).value || []).map((val) => <Chip label={val} />)}</> }] :
367 [{display: <EmptyValue />}];
369 case isArrayOfType(input, CWLType.INT):
370 case isArrayOfType(input, CWLType.LONG):
371 const intArray = (input as IntArrayCommandInputParameter).value || [];
373 return intArray.length ?
374 [{ display: <>{((input as IntArrayCommandInputParameter).value || []).map((val) => <Chip label={val} />)}</> }] :
375 [{display: <EmptyValue />}];
377 case isArrayOfType(input, CWLType.FLOAT):
378 case isArrayOfType(input, CWLType.DOUBLE):
379 const floatArray = (input as FloatArrayCommandInputParameter).value || [];
381 return floatArray.length ?
382 [{ display: <>{floatArray.map((val) => <Chip label={val} />)}</> }] :
383 [{display: <EmptyValue />}];
385 case isArrayOfType(input, CWLType.FILE):
386 const fileArrayMainFile = ((input as FileArrayCommandInputParameter).value || []);
387 const fileArraySecondaryFiles = fileArrayMainFile.map((file) => (
388 ((file as unknown) as FileWithSecondaryFiles)?.secondaryFiles || []
389 )).reduce((acc: File[], params: File[]) => (acc.concat(params)), []);
391 const fileArrayFiles = [
392 ...fileArrayMainFile,
393 ...fileArraySecondaryFiles
396 return fileArrayFiles.length ?
397 fileArrayFiles.map(file => fileToProcessIOValue(file, auth, pdh)) :
398 [{display: <EmptyValue />}];
400 case isArrayOfType(input, CWLType.DIRECTORY):
401 const directories = (input as DirectoryArrayCommandInputParameter).value || [];
403 return directories.length ?
404 directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) :
405 [{display: <EmptyValue />}];
412 const getKeepUrl = (file: File | Directory, pdh?: string): string => {
413 const isKeepUrl = file.location?.startsWith('keep:') || false;
414 const keepUrl = isKeepUrl ? file.location : pdh ? `keep:${pdh}/${file.location}` : file.location;
415 return keepUrl || '';
418 interface KeepUrlProps {
420 res: File | Directory;
424 const KeepUrlBase = withStyles(styles)(({auth, res, pdh, classes}: KeepUrlProps & WithStyles<CssRules>) => {
425 const keepUrl = getKeepUrl(res, pdh);
426 const pdhUrl = keepUrl ? keepUrl.split('/').slice(0, 1)[0] : '';
427 // Passing a pdh always returns a relative wb2 collection url
428 const pdhWbPath = getNavUrl(pdhUrl.replace('keep:', ''), auth);
429 return pdhUrl && pdhWbPath ?
430 <RouterLink to={pdhWbPath} className={classes.keepLink}>{pdhUrl}</RouterLink> :
434 const KeepUrlPath = withStyles(styles)(({auth, res, pdh, classes}: KeepUrlProps & WithStyles<CssRules>) => {
435 const keepUrl = getKeepUrl(res, pdh);
436 const keepUrlParts = keepUrl ? keepUrl.split('/') : [];
437 const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join('/') : '';
439 const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
440 return keepUrlPath && keepUrlPathNav ?
441 <MuiLink className={classes.keepLink} onClick={() => handleClick(keepUrlPathNav)}>{keepUrlPath}</MuiLink> :
445 const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
446 let keepUrl = getKeepUrl(file, pdh).replace('keep:', '');
447 return (getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl));
450 const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
451 const keepUrl = getKeepUrl(file, pdh).replace('keep:', '');
452 return getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl);
455 const isFileImage = (basename?: string): boolean => {
456 return basename ? (mime.getType(basename) || "").startsWith('image/') : false;
459 const normalizeDirectoryLocation = (directory: Directory): Directory => {
460 if (!directory.location) {
465 location: (directory.location || '').endsWith('/') ? directory.location : directory.location + '/',
469 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
470 const normalizedDirectory = normalizeDirectoryLocation(directory);
473 <KeepUrlBase auth={auth} res={normalizedDirectory} pdh={pdh}/>/<KeepUrlPath auth={auth} res={normalizedDirectory} pdh={pdh}/>
478 const fileToProcessIOValue = (file: File, auth: AuthState, pdh?: string): ProcessIOValue => {
481 <KeepUrlBase auth={auth} res={file} pdh={pdh}/>/<KeepUrlPath auth={auth} res={file} pdh={pdh}/>
483 imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
487 const EmptyValue = withStyles(styles)(
488 ({classes}: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>