Merge branch '21128-toolbar-context-menu'
[arvados-workbench2.git] / src / views / process-panel / process-io-card.tsx
1 // Copyright (C) The Arvados Authors. All rights reserved.
2 //
3 // SPDX-License-Identifier: AGPL-3.0
4
5 import React, { ReactElement, memo, useState } from "react";
6 import { Dispatch } from "redux";
7 import {
8     StyleRulesCallback,
9     WithStyles,
10     withStyles,
11     Card,
12     CardHeader,
13     IconButton,
14     CardContent,
15     Tooltip,
16     Typography,
17     Tabs,
18     Tab,
19     Table,
20     TableHead,
21     TableBody,
22     TableRow,
23     TableCell,
24     Paper,
25     Grid,
26     Chip,
27     CircularProgress,
28 } from "@material-ui/core";
29 import { ArvadosTheme } from "common/custom-theme";
30 import { CloseIcon, ImageIcon, InputIcon, ImageOffIcon, OutputIcon, MaximizeIcon, UnMaximizeIcon, InfoIcon } from "components/icon/icon";
31 import { MPVPanelProps } from "components/multi-panel-view/multi-panel-view";
32 import {
33     BooleanCommandInputParameter,
34     CommandInputParameter,
35     CWLType,
36     Directory,
37     DirectoryArrayCommandInputParameter,
38     DirectoryCommandInputParameter,
39     EnumCommandInputParameter,
40     FileArrayCommandInputParameter,
41     FileCommandInputParameter,
42     FloatArrayCommandInputParameter,
43     FloatCommandInputParameter,
44     IntArrayCommandInputParameter,
45     IntCommandInputParameter,
46     isArrayOfType,
47     isPrimitiveOfType,
48     StringArrayCommandInputParameter,
49     StringCommandInputParameter,
50     getEnumType,
51 } from "models/workflow";
52 import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
53 import { File } from "models/workflow";
54 import { getInlineFileUrl } from "views-components/context-menu/actions/helpers";
55 import { AuthState } from "store/auth/auth-reducer";
56 import mime from "mime";
57 import { DefaultView } from "components/default-view/default-view";
58 import { getNavUrl } from "routes/routes";
59 import { Link as RouterLink } from "react-router-dom";
60 import { Link as MuiLink } from "@material-ui/core";
61 import { InputCollectionMount } from "store/processes/processes-actions";
62 import { connect } from "react-redux";
63 import { RootState } from "store/store";
64 import { ProcessOutputCollectionFiles } from "./process-output-collection-files";
65 import { Process } from "store/processes/process";
66 import { navigateTo } from "store/navigation/navigation-action";
67 import classNames from "classnames";
68 import { DefaultCodeSnippet } from "components/default-code-snippet/default-code-snippet";
69 import { KEEP_URL_REGEX } from "models/resource";
70
71 type CssRules =
72     | "card"
73     | "content"
74     | "title"
75     | "header"
76     | "avatar"
77     | "iconHeader"
78     | "tableWrapper"
79     | "tableRoot"
80     | "paramValue"
81     | "keepLink"
82     | "collectionLink"
83     | "imagePreview"
84     | "valArray"
85     | "secondaryVal"
86     | "secondaryRow"
87     | "emptyValue"
88     | "noBorderRow"
89     | "symmetricTabs"
90     | "imagePlaceholder"
91     | "rowWithPreview"
92     | "labelColumn";
93
94 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
95     card: {
96         height: "100%",
97     },
98     header: {
99         paddingTop: theme.spacing.unit,
100         paddingBottom: 0,
101     },
102     iconHeader: {
103         fontSize: "1.875rem",
104         color: theme.customs.colors.greyL,
105     },
106     avatar: {
107         alignSelf: "flex-start",
108         paddingTop: theme.spacing.unit * 0.5,
109     },
110     content: {
111         height: `calc(100% - ${theme.spacing.unit * 7}px - ${theme.spacing.unit * 1.5}px)`,
112         padding: theme.spacing.unit * 1.0,
113         paddingTop: 0,
114         "&:last-child": {
115             paddingBottom: theme.spacing.unit * 1,
116         },
117     },
118     title: {
119         overflow: "hidden",
120         paddingTop: theme.spacing.unit * 0.5,
121         color: theme.customs.colors.greyD,
122         fontSize: "1.875rem",
123     },
124     tableWrapper: {
125         height: "auto",
126         maxHeight: `calc(100% - ${theme.spacing.unit * 4.5}px)`,
127         overflow: "auto",
128     },
129     tableRoot: {
130         width: "100%",
131         "& thead th": {
132             verticalAlign: "bottom",
133             paddingBottom: "10px",
134         },
135         "& td, & th": {
136             paddingRight: "25px",
137         },
138     },
139     paramValue: {
140         display: "flex",
141         alignItems: "flex-start",
142         flexDirection: "column",
143     },
144     keepLink: {
145         color: theme.palette.primary.main,
146         textDecoration: "none",
147         overflowWrap: "break-word",
148         cursor: "pointer",
149     },
150     collectionLink: {
151         margin: "10px",
152         "& a": {
153             color: theme.palette.primary.main,
154             textDecoration: "none",
155             overflowWrap: "break-word",
156             cursor: "pointer",
157         },
158     },
159     imagePreview: {
160         maxHeight: "15em",
161         maxWidth: "15em",
162         marginBottom: theme.spacing.unit,
163     },
164     valArray: {
165         display: "flex",
166         gap: "10px",
167         flexWrap: "wrap",
168         "& span": {
169             display: "inline",
170         },
171     },
172     secondaryVal: {
173         paddingLeft: "20px",
174     },
175     secondaryRow: {
176         height: "29px",
177         verticalAlign: "top",
178         position: "relative",
179         top: "-9px",
180     },
181     emptyValue: {
182         color: theme.customs.colors.grey700,
183     },
184     noBorderRow: {
185         "& td": {
186             borderBottom: "none",
187         },
188     },
189     symmetricTabs: {
190         "& button": {
191             flexBasis: "0",
192         },
193     },
194     imagePlaceholder: {
195         width: "60px",
196         height: "60px",
197         display: "flex",
198         alignItems: "center",
199         justifyContent: "center",
200         backgroundColor: "#cecece",
201         borderRadius: "10px",
202     },
203     rowWithPreview: {
204         verticalAlign: "bottom",
205     },
206     labelColumn: {
207         minWidth: "120px",
208     },
209 });
210
211 export enum ProcessIOCardType {
212     INPUT = "Inputs",
213     OUTPUT = "Outputs",
214 }
215 export interface ProcessIOCardDataProps {
216     process?: Process;
217     label: ProcessIOCardType;
218     params: ProcessIOParameter[] | null;
219     raw: any;
220     mounts?: InputCollectionMount[];
221     outputUuid?: string;
222     showParams?: boolean;
223 }
224
225 export interface ProcessIOCardActionProps {
226     navigateTo: (uuid: string) => void;
227 }
228
229 const mapDispatchToProps = (dispatch: Dispatch): ProcessIOCardActionProps => ({
230     navigateTo: uuid => dispatch<any>(navigateTo(uuid)),
231 });
232
233 type ProcessIOCardProps = ProcessIOCardDataProps & ProcessIOCardActionProps & WithStyles<CssRules> & MPVPanelProps;
234
235 export const ProcessIOCard = withStyles(styles)(
236     connect(
237         null,
238         mapDispatchToProps
239     )(
240         ({
241             classes,
242             label,
243             params,
244             raw,
245             mounts,
246             outputUuid,
247             doHidePanel,
248             doMaximizePanel,
249             doUnMaximizePanel,
250             panelMaximized,
251             panelName,
252             process,
253             navigateTo,
254             showParams,
255         }: ProcessIOCardProps) => {
256             const [mainProcTabState, setMainProcTabState] = useState(0);
257             const [subProcTabState, setSubProcTabState] = useState(0);
258             const handleMainProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
259                 setMainProcTabState(value);
260             };
261             const handleSubProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
262                 setSubProcTabState(value);
263             };
264
265             const [showImagePreview, setShowImagePreview] = useState(false);
266
267             const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon;
268             const mainProcess = !(process && process!.containerRequest.requestingContainerUuid);
269
270             const loading = raw === null || raw === undefined || params === null;
271             const hasRaw = !!(raw && Object.keys(raw).length > 0);
272             const hasParams = !!(params && params.length > 0);
273
274             // Subprocess
275             const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length);
276             const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid);
277
278             return (
279                 <Card
280                     className={classes.card}
281                     data-cy="process-io-card"
282                 >
283                     <CardHeader
284                         className={classes.header}
285                         classes={{
286                             content: classes.title,
287                             avatar: classes.avatar,
288                         }}
289                         avatar={<PanelIcon className={classes.iconHeader} />}
290                         title={
291                             <Typography
292                                 noWrap
293                                 variant="h6"
294                                 color="inherit"
295                             >
296                                 {label}
297                             </Typography>
298                         }
299                         action={
300                             <div>
301                                 {mainProcess && (
302                                     <Tooltip
303                                         title={"Toggle Image Preview"}
304                                         disableFocusListener
305                                     >
306                                         <IconButton
307                                             data-cy="io-preview-image-toggle"
308                                             onClick={() => {
309                                                 setShowImagePreview(!showImagePreview);
310                                             }}
311                                         >
312                                             {showImagePreview ? <ImageIcon /> : <ImageOffIcon />}
313                                         </IconButton>
314                                     </Tooltip>
315                                 )}
316                                 {doUnMaximizePanel && panelMaximized && (
317                                     <Tooltip
318                                         title={`Unmaximize ${panelName || "panel"}`}
319                                         disableFocusListener
320                                     >
321                                         <IconButton onClick={doUnMaximizePanel}>
322                                             <UnMaximizeIcon />
323                                         </IconButton>
324                                     </Tooltip>
325                                 )}
326                                 {doMaximizePanel && !panelMaximized && (
327                                     <Tooltip
328                                         title={`Maximize ${panelName || "panel"}`}
329                                         disableFocusListener
330                                     >
331                                         <IconButton onClick={doMaximizePanel}>
332                                             <MaximizeIcon />
333                                         </IconButton>
334                                     </Tooltip>
335                                 )}
336                                 {doHidePanel && (
337                                     <Tooltip
338                                         title={`Close ${panelName || "panel"}`}
339                                         disableFocusListener
340                                     >
341                                         <IconButton
342                                             disabled={panelMaximized}
343                                             onClick={doHidePanel}
344                                         >
345                                             <CloseIcon />
346                                         </IconButton>
347                                     </Tooltip>
348                                 )}
349                             </div>
350                         }
351                     />
352                     <CardContent className={classes.content}>
353                         {mainProcess || showParams ? (
354                             <>
355                                 {/* raw is undefined until params are loaded */}
356                                 {loading && (
357                                     <Grid
358                                         container
359                                         item
360                                         alignItems="center"
361                                         justify="center"
362                                     >
363                                         <CircularProgress />
364                                     </Grid>
365                                 )}
366                                 {/* Once loaded, either raw or params may still be empty
367                                  *   Raw when all params are empty
368                                  *   Params when raw is provided by containerRequest properties but workflow mount is absent for preview
369                                  */}
370                                 {!loading && (hasRaw || hasParams) && (
371                                     <>
372                                         <Tabs
373                                             value={mainProcTabState}
374                                             onChange={handleMainProcTabChange}
375                                             variant="fullWidth"
376                                             className={classes.symmetricTabs}
377                                         >
378                                             {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */}
379                                             {hasParams && <Tab label="Parameters" />}
380                                             {!showParams && <Tab label="JSON" />}
381                                         </Tabs>
382                                         {mainProcTabState === 0 && params && hasParams && (
383                                             <div className={classes.tableWrapper}>
384                                                 <ProcessIOPreview
385                                                     data={params}
386                                                     showImagePreview={showImagePreview}
387                                                     valueLabel={showParams ? "Default value" : "Value"}
388                                                 />
389                                             </div>
390                                         )}
391                                         {(mainProcTabState === 1 || !hasParams) && (
392                                             <div className={classes.tableWrapper}>
393                                                 <ProcessIORaw data={raw} />
394                                             </div>
395                                         )}
396                                     </>
397                                 )}
398                                 {!loading && !hasRaw && !hasParams && (
399                                     <Grid
400                                         container
401                                         item
402                                         alignItems="center"
403                                         justify="center"
404                                     >
405                                         <DefaultView messages={["No parameters found"]} />
406                                     </Grid>
407                                 )}
408                             </>
409                         ) : (
410                             // Subprocess
411                             <>
412                                 {loading && (
413                                     <Grid
414                                         container
415                                         item
416                                         alignItems="center"
417                                         justify="center"
418                                     >
419                                         <CircularProgress />
420                                     </Grid>
421                                 )}
422                                 {!loading && (hasInputMounts || hasOutputCollecton || hasRaw) ? (
423                                     <>
424                                         <Tabs
425                                             value={subProcTabState}
426                                             onChange={handleSubProcTabChange}
427                                             variant="fullWidth"
428                                             className={classes.symmetricTabs}
429                                         >
430                                             {hasInputMounts && <Tab label="Collections" />}
431                                             {hasOutputCollecton && <Tab label="Collection" />}
432                                             <Tab label="JSON" />
433                                         </Tabs>
434                                         <div className={classes.tableWrapper}>
435                                             {subProcTabState === 0 && hasInputMounts && <ProcessInputMounts mounts={mounts || []} />}
436                                             {subProcTabState === 0 && hasOutputCollecton && (
437                                                 <>
438                                                     {outputUuid && (
439                                                         <Typography className={classes.collectionLink}>
440                                                             Output Collection:{" "}
441                                                             <MuiLink
442                                                                 className={classes.keepLink}
443                                                                 onClick={() => {
444                                                                     navigateTo(outputUuid || "");
445                                                                 }}
446                                                             >
447                                                                 {outputUuid}
448                                                             </MuiLink>
449                                                         </Typography>
450                                                     )}
451                                                     <ProcessOutputCollectionFiles
452                                                         isWritable={false}
453                                                         currentItemUuid={outputUuid}
454                                                     />
455                                                 </>
456                                             )}
457                                             {(subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && (
458                                                 <div className={classes.tableWrapper}>
459                                                     <ProcessIORaw data={raw} />
460                                                 </div>
461                                             )}
462                                         </div>
463                                     </>
464                                 ) : (
465                                     <Grid
466                                         container
467                                         item
468                                         alignItems="center"
469                                         justify="center"
470                                     >
471                                         <DefaultView messages={["No data to display"]} />
472                                     </Grid>
473                                 )}
474                             </>
475                         )}
476                     </CardContent>
477                 </Card>
478             );
479         }
480     )
481 );
482
483 export type ProcessIOValue = {
484     display: ReactElement<any, any>;
485     imageUrl?: string;
486     collection?: ReactElement<any, any>;
487     secondary?: boolean;
488 };
489
490 export type ProcessIOParameter = {
491     id: string;
492     label: string;
493     value: ProcessIOValue[];
494 };
495
496 interface ProcessIOPreviewDataProps {
497     data: ProcessIOParameter[];
498     showImagePreview: boolean;
499     valueLabel: string;
500 }
501
502 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
503
504 const ProcessIOPreview = memo(
505     withStyles(styles)(({ classes, data, showImagePreview, valueLabel }: ProcessIOPreviewProps) => {
506         const showLabel = data.some((param: ProcessIOParameter) => param.label);
507         return (
508             <Table
509                 className={classes.tableRoot}
510                 aria-label="Process IO Preview"
511             >
512                 <TableHead>
513                     <TableRow>
514                         <TableCell>Name</TableCell>
515                         {showLabel && <TableCell className={classes.labelColumn}>Label</TableCell>}
516                         <TableCell>{valueLabel}</TableCell>
517                         <TableCell>Collection</TableCell>
518                     </TableRow>
519                 </TableHead>
520                 <TableBody>
521                     {data.map((param: ProcessIOParameter) => {
522                         const firstVal = param.value.length > 0 ? param.value[0] : undefined;
523                         const rest = param.value.slice(1);
524                         const mainRowClasses = {
525                             [classes.noBorderRow]: rest.length > 0,
526                         };
527
528                         return (
529                             <React.Fragment key={param.id}>
530                                 <TableRow
531                                     className={classNames(mainRowClasses)}
532                                     data-cy="process-io-param"
533                                 >
534                                     <TableCell>{param.id}</TableCell>
535                                     {showLabel && <TableCell>{param.label}</TableCell>}
536                                     <TableCell>
537                                         {firstVal && (
538                                             <ProcessValuePreview
539                                                 value={firstVal}
540                                                 showImagePreview={showImagePreview}
541                                             />
542                                         )}
543                                     </TableCell>
544                                     <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
545                                         <Typography className={classes.paramValue}>{firstVal?.collection}</Typography>
546                                     </TableCell>
547                                 </TableRow>
548                                 {rest.map((val, i) => {
549                                     const rowClasses = {
550                                         [classes.noBorderRow]: i < rest.length - 1,
551                                         [classes.secondaryRow]: val.secondary,
552                                     };
553                                     return (
554                                         <TableRow
555                                             className={classNames(rowClasses)}
556                                             key={i}
557                                         >
558                                             <TableCell />
559                                             {showLabel && <TableCell />}
560                                             <TableCell>
561                                                 <ProcessValuePreview
562                                                     value={val}
563                                                     showImagePreview={showImagePreview}
564                                                 />
565                                             </TableCell>
566                                             <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
567                                                 <Typography className={classes.paramValue}>{val.collection}</Typography>
568                                             </TableCell>
569                                         </TableRow>
570                                     );
571                                 })}
572                             </React.Fragment>
573                         );
574                     })}
575                 </TableBody>
576             </Table>
577         );
578     })
579 );
580
581 interface ProcessValuePreviewProps {
582     value: ProcessIOValue;
583     showImagePreview: boolean;
584 }
585
586 const ProcessValuePreview = withStyles(styles)(({ value, showImagePreview, classes }: ProcessValuePreviewProps & WithStyles<CssRules>) => (
587     <Typography className={classes.paramValue}>
588         {value.imageUrl && showImagePreview ? (
589             <img
590                 className={classes.imagePreview}
591                 src={value.imageUrl}
592                 alt="Inline Preview"
593             />
594         ) : (
595             ""
596         )}
597         {value.imageUrl && !showImagePreview ? <ImagePlaceholder /> : ""}
598         <span className={classNames(classes.valArray, value.secondary && classes.secondaryVal)}>{value.display}</span>
599     </Typography>
600 ));
601
602 interface ProcessIORawDataProps {
603     data: ProcessIOParameter[];
604 }
605
606 const ProcessIORaw = withStyles(styles)(({ data }: ProcessIORawDataProps) => (
607     <Paper elevation={0}>
608         <DefaultCodeSnippet
609             lines={[JSON.stringify(data, null, 2)]}
610             linked
611         />
612     </Paper>
613 ));
614
615 interface ProcessInputMountsDataProps {
616     mounts: InputCollectionMount[];
617 }
618
619 type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
620
621 const ProcessInputMounts = withStyles(styles)(
622     connect((state: RootState) => ({
623         auth: state.auth,
624     }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
625         <Table
626             className={classes.tableRoot}
627             aria-label="Process Input Mounts"
628         >
629             <TableHead>
630                 <TableRow>
631                     <TableCell>Path</TableCell>
632                     <TableCell>Portable Data Hash</TableCell>
633                 </TableRow>
634             </TableHead>
635             <TableBody>
636                 {mounts.map(mount => (
637                     <TableRow key={mount.path}>
638                         <TableCell>
639                             <pre>{mount.path}</pre>
640                         </TableCell>
641                         <TableCell>
642                             <RouterLink
643                                 to={getNavUrl(mount.pdh, auth)}
644                                 className={classes.keepLink}
645                             >
646                                 {mount.pdh}
647                             </RouterLink>
648                         </TableCell>
649                     </TableRow>
650                 ))}
651             </TableBody>
652         </Table>
653     ))
654 );
655
656 type FileWithSecondaryFiles = {
657     secondaryFiles: File[];
658 };
659
660 export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
661     switch (true) {
662         case isPrimitiveOfType(input, CWLType.BOOLEAN):
663             const boolValue = (input as BooleanCommandInputParameter).value;
664             return boolValue !== undefined && !(Array.isArray(boolValue) && boolValue.length === 0)
665                 ? [{ display: renderPrimitiveValue(boolValue, false) }]
666                 : [{ display: <EmptyValue /> }];
667
668         case isPrimitiveOfType(input, CWLType.INT):
669         case isPrimitiveOfType(input, CWLType.LONG):
670             const intValue = (input as IntCommandInputParameter).value;
671             return intValue !== undefined &&
672                 // Missing values are empty array
673                 !(Array.isArray(intValue) && intValue.length === 0)
674                 ? [{ display: renderPrimitiveValue(intValue, false) }]
675                 : [{ display: <EmptyValue /> }];
676
677         case isPrimitiveOfType(input, CWLType.FLOAT):
678         case isPrimitiveOfType(input, CWLType.DOUBLE):
679             const floatValue = (input as FloatCommandInputParameter).value;
680             return floatValue !== undefined && !(Array.isArray(floatValue) && floatValue.length === 0)
681                 ? [{ display: renderPrimitiveValue(floatValue, false) }]
682                 : [{ display: <EmptyValue /> }];
683
684         case isPrimitiveOfType(input, CWLType.STRING):
685             const stringValue = (input as StringCommandInputParameter).value || undefined;
686             return stringValue !== undefined && !(Array.isArray(stringValue) && stringValue.length === 0)
687                 ? [{ display: renderPrimitiveValue(stringValue, false) }]
688                 : [{ display: <EmptyValue /> }];
689
690         case isPrimitiveOfType(input, CWLType.FILE):
691             const mainFile = (input as FileCommandInputParameter).value;
692             // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
693             const secondaryFiles = (mainFile as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
694             const files = [...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []), ...secondaryFiles];
695             const mainFilePdhUrl = mainFile ? getResourcePdhUrl(mainFile, pdh) : "";
696             return files.length
697                 ? files.map((file, i) => fileToProcessIOValue(file, i > 0, auth, pdh, i > 0 ? mainFilePdhUrl : ""))
698                 : [{ display: <EmptyValue /> }];
699
700         case isPrimitiveOfType(input, CWLType.DIRECTORY):
701             const directory = (input as DirectoryCommandInputParameter).value;
702             return directory !== undefined && !(Array.isArray(directory) && directory.length === 0)
703                 ? [directoryToProcessIOValue(directory, auth, pdh)]
704                 : [{ display: <EmptyValue /> }];
705
706         case getEnumType(input) !== null:
707             const enumValue = (input as EnumCommandInputParameter).value;
708             return enumValue !== undefined && enumValue ? [{ display: <pre>{enumValue}</pre> }] : [{ display: <EmptyValue /> }];
709
710         case isArrayOfType(input, CWLType.STRING):
711             const strArray = (input as StringArrayCommandInputParameter).value || [];
712             return strArray.length ? [{ display: <>{strArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
713
714         case isArrayOfType(input, CWLType.INT):
715         case isArrayOfType(input, CWLType.LONG):
716             const intArray = (input as IntArrayCommandInputParameter).value || [];
717             return intArray.length ? [{ display: <>{intArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
718
719         case isArrayOfType(input, CWLType.FLOAT):
720         case isArrayOfType(input, CWLType.DOUBLE):
721             const floatArray = (input as FloatArrayCommandInputParameter).value || [];
722             return floatArray.length ? [{ display: <>{floatArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
723
724         case isArrayOfType(input, CWLType.FILE):
725             const fileArrayMainFiles = (input as FileArrayCommandInputParameter).value || [];
726             const firstMainFilePdh = fileArrayMainFiles.length > 0 && fileArrayMainFiles[0] ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : "";
727
728             // Convert each main and secondaryFiles into array of ProcessIOValue preserving ordering
729             let fileArrayValues: ProcessIOValue[] = [];
730             for (let i = 0; i < fileArrayMainFiles.length; i++) {
731                 const secondaryFiles = (fileArrayMainFiles[i] as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
732                 fileArrayValues.push(
733                     // Pass firstMainFilePdh to secondary files and every main file besides the first to hide pdh if equal
734                     ...(fileArrayMainFiles[i] ? [fileToProcessIOValue(fileArrayMainFiles[i], false, auth, pdh, i > 0 ? firstMainFilePdh : "")] : []),
735                     ...secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh))
736                 );
737             }
738
739             return fileArrayValues.length ? fileArrayValues : [{ display: <EmptyValue /> }];
740
741         case isArrayOfType(input, CWLType.DIRECTORY):
742             const directories = (input as DirectoryArrayCommandInputParameter).value || [];
743             return directories.length ? directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) : [{ display: <EmptyValue /> }];
744
745         default:
746             return [{ display: <UnsupportedValue /> }];
747     }
748 };
749
750 const renderPrimitiveValue = (value: any, asChip: boolean) => {
751     const isObject = typeof value === "object";
752     if (!isObject) {
753         return asChip ? (
754             <Chip
755                 key={value}
756                 label={String(value)}
757             />
758         ) : (
759             <pre key={value}>{String(value)}</pre>
760         );
761     } else {
762         return asChip ? <UnsupportedValueChip /> : <UnsupportedValue />;
763     }
764 };
765
766 /*
767  * @returns keep url without keep: prefix
768  */
769 const getKeepUrl = (file: File | Directory, pdh?: string): string => {
770     const isKeepUrl = file.location?.startsWith("keep:") || false;
771     const keepUrl = isKeepUrl ? file.location?.replace("keep:", "") : pdh ? `${pdh}/${file.location}` : file.location;
772     return keepUrl || "";
773 };
774
775 interface KeepUrlProps {
776     auth: AuthState;
777     res: File | Directory;
778     pdh?: string;
779 }
780
781 const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => {
782     const keepUrl = getKeepUrl(res, pdh);
783     return keepUrl ? keepUrl.split("/").slice(0, 1)[0] : "";
784 };
785
786 const KeepUrlBase = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
787     const pdhUrl = getResourcePdhUrl(res, pdh);
788     // Passing a pdh always returns a relative wb2 collection url
789     const pdhWbPath = getNavUrl(pdhUrl, auth);
790     return pdhUrl && pdhWbPath ? (
791         <Tooltip title={"View collection in Workbench"}>
792             <RouterLink
793                 to={pdhWbPath}
794                 className={classes.keepLink}
795             >
796                 {pdhUrl}
797             </RouterLink>
798         </Tooltip>
799     ) : (
800         <></>
801     );
802 });
803
804 const KeepUrlPath = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
805     const keepUrl = getKeepUrl(res, pdh);
806     const keepUrlParts = keepUrl ? keepUrl.split("/") : [];
807     const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join("/") : "";
808
809     const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
810     return keepUrlPathNav ? (
811         <Tooltip title={"View in keep-web"}>
812             <a
813                 className={classes.keepLink}
814                 href={keepUrlPathNav}
815                 target="_blank"
816                 rel="noopener noreferrer"
817             >
818                 {keepUrlPath || "/"}
819             </a>
820         </Tooltip>
821     ) : (
822         <EmptyValue />
823     );
824 });
825
826 const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
827     let keepUrl = getKeepUrl(file, pdh);
828     return getInlineFileUrl(
829         `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
830         auth.config.keepWebServiceUrl,
831         auth.config.keepWebInlineServiceUrl
832     );
833 };
834
835 const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
836     const keepUrl = getKeepUrl(file, pdh);
837     return getInlineFileUrl(
838         `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
839         auth.config.keepWebServiceUrl,
840         auth.config.keepWebInlineServiceUrl
841     );
842 };
843
844 const isFileImage = (basename?: string): boolean => {
845     return basename ? (mime.getType(basename) || "").startsWith("image/") : false;
846 };
847
848 const isFileUrl = (location?: string): boolean =>
849     !!location && !KEEP_URL_REGEX.exec(location) && (location.startsWith("http://") || location.startsWith("https://"));
850
851 const normalizeDirectoryLocation = (directory: Directory): Directory => {
852     if (!directory.location) {
853         return directory;
854     }
855     return {
856         ...directory,
857         location: (directory.location || "").endsWith("/") ? directory.location : directory.location + "/",
858     };
859 };
860
861 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
862     if (isExternalValue(directory)) {
863         return { display: <UnsupportedValue /> };
864     }
865
866     const normalizedDirectory = normalizeDirectoryLocation(directory);
867     return {
868         display: (
869             <KeepUrlPath
870                 auth={auth}
871                 res={normalizedDirectory}
872                 pdh={pdh}
873             />
874         ),
875         collection: (
876             <KeepUrlBase
877                 auth={auth}
878                 res={normalizedDirectory}
879                 pdh={pdh}
880             />
881         ),
882     };
883 };
884
885 const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => {
886     if (isExternalValue(file)) {
887         return { display: <UnsupportedValue /> };
888     }
889
890     if (isFileUrl(file.location)) {
891         return {
892             display: (
893                 <MuiLink
894                     href={file.location}
895                     target="_blank"
896                 >
897                     {file.location}
898                 </MuiLink>
899             ),
900             secondary,
901         };
902     }
903
904     const resourcePdh = getResourcePdhUrl(file, pdh);
905     return {
906         display: (
907             <KeepUrlPath
908                 auth={auth}
909                 res={file}
910                 pdh={pdh}
911             />
912         ),
913         secondary,
914         imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
915         collection:
916             resourcePdh !== mainFilePdh ? (
917                 <KeepUrlBase
918                     auth={auth}
919                     res={file}
920                     pdh={pdh}
921                 />
922             ) : (
923                 <></>
924             ),
925     };
926 };
927
928 const isExternalValue = (val: any) => Object.keys(val).includes("$import") || Object.keys(val).includes("$include");
929
930 export const EmptyValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>);
931
932 const UnsupportedValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>Cannot display value</span>);
933
934 const UnsupportedValueChip = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
935     <Chip
936         icon={<InfoIcon />}
937         label={"Cannot display value"}
938     />
939 ));
940
941 const ImagePlaceholder = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
942     <span className={classes.imagePlaceholder}>
943         <ImageIcon />
944     </span>
945 ));