Merge branch '21366-subprocess-output-loading-bug' into main. Closes #21366
[arvados.git] / services / workbench2 / 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     forceShowParams?: 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             forceShowParams,
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             const showParamTable = mainProcess || forceShowParams;
270
271             const loading = raw === null || raw === undefined || params === null;
272
273             const hasRaw = !!(raw && Object.keys(raw).length > 0);
274             const hasParams = !!(params && params.length > 0);
275             // isRawLoaded allows subprocess panel to display raw even if it's {}
276             const isRawLoaded = !!(raw && Object.keys(raw).length >= 0);
277
278             // Subprocess
279             const hasInputMounts = !!(label === ProcessIOCardType.INPUT && mounts && mounts.length);
280             const hasOutputCollecton = !!(label === ProcessIOCardType.OUTPUT && outputUuid);
281             // Subprocess should not show loading if hasOutputCollection or hasInputMounts
282             const subProcessLoading = loading && !hasOutputCollecton && !hasInputMounts;
283
284             return (
285                 <Card
286                     className={classes.card}
287                     data-cy="process-io-card"
288                 >
289                     <CardHeader
290                         className={classes.header}
291                         classes={{
292                             content: classes.title,
293                             avatar: classes.avatar,
294                         }}
295                         avatar={<PanelIcon className={classes.iconHeader} />}
296                         title={
297                             <Typography
298                                 noWrap
299                                 variant="h6"
300                                 color="inherit"
301                             >
302                                 {label}
303                             </Typography>
304                         }
305                         action={
306                             <div>
307                                 {mainProcess && (
308                                     <Tooltip
309                                         title={"Toggle Image Preview"}
310                                         disableFocusListener
311                                     >
312                                         <IconButton
313                                             data-cy="io-preview-image-toggle"
314                                             onClick={() => {
315                                                 setShowImagePreview(!showImagePreview);
316                                             }}
317                                         >
318                                             {showImagePreview ? <ImageIcon /> : <ImageOffIcon />}
319                                         </IconButton>
320                                     </Tooltip>
321                                 )}
322                                 {doUnMaximizePanel && panelMaximized && (
323                                     <Tooltip
324                                         title={`Unmaximize ${panelName || "panel"}`}
325                                         disableFocusListener
326                                     >
327                                         <IconButton onClick={doUnMaximizePanel}>
328                                             <UnMaximizeIcon />
329                                         </IconButton>
330                                     </Tooltip>
331                                 )}
332                                 {doMaximizePanel && !panelMaximized && (
333                                     <Tooltip
334                                         title={`Maximize ${panelName || "panel"}`}
335                                         disableFocusListener
336                                     >
337                                         <IconButton onClick={doMaximizePanel}>
338                                             <MaximizeIcon />
339                                         </IconButton>
340                                     </Tooltip>
341                                 )}
342                                 {doHidePanel && (
343                                     <Tooltip
344                                         title={`Close ${panelName || "panel"}`}
345                                         disableFocusListener
346                                     >
347                                         <IconButton
348                                             disabled={panelMaximized}
349                                             onClick={doHidePanel}
350                                         >
351                                             <CloseIcon />
352                                         </IconButton>
353                                     </Tooltip>
354                                 )}
355                             </div>
356                         }
357                     />
358                     <CardContent className={classes.content}>
359                         {showParamTable ? (
360                             <>
361                                 {/* raw is undefined until params are loaded */}
362                                 {loading && (
363                                     <Grid
364                                         container
365                                         item
366                                         alignItems="center"
367                                         justify="center"
368                                     >
369                                         <CircularProgress />
370                                     </Grid>
371                                 )}
372                                 {/* Once loaded, either raw or params may still be empty
373                                  *   Raw when all params are empty
374                                  *   Params when raw is provided by containerRequest properties but workflow mount is absent for preview
375                                  */}
376                                 {!loading && (hasRaw || hasParams) && (
377                                     <>
378                                         <Tabs
379                                             value={mainProcTabState}
380                                             onChange={handleMainProcTabChange}
381                                             variant="fullWidth"
382                                             className={classes.symmetricTabs}
383                                         >
384                                             {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */}
385                                             {hasParams && <Tab label="Parameters" />}
386                                             {!forceShowParams && <Tab label="JSON" />}
387                                         </Tabs>
388                                         {mainProcTabState === 0 && params && hasParams && (
389                                             <div className={classes.tableWrapper}>
390                                                 <ProcessIOPreview
391                                                     data={params}
392                                                     showImagePreview={showImagePreview}
393                                                     valueLabel={forceShowParams ? "Default value" : "Value"}
394                                                 />
395                                             </div>
396                                         )}
397                                         {(mainProcTabState === 1 || !hasParams) && (
398                                             <div className={classes.tableWrapper}>
399                                                 <ProcessIORaw data={raw} />
400                                             </div>
401                                         )}
402                                     </>
403                                 )}
404                                 {!loading && !hasRaw && !hasParams && (
405                                     <Grid
406                                         container
407                                         item
408                                         alignItems="center"
409                                         justify="center"
410                                     >
411                                         <DefaultView messages={["No parameters found"]} />
412                                     </Grid>
413                                 )}
414                             </>
415                         ) : (
416                             // Subprocess
417                             <>
418                                 {subProcessLoading ? (
419                                     <Grid
420                                         container
421                                         item
422                                         alignItems="center"
423                                         justify="center"
424                                     >
425                                         <CircularProgress />
426                                     </Grid>
427                                 ) : !subProcessLoading && (hasInputMounts || hasOutputCollecton || isRawLoaded) ? (
428                                     <>
429                                         <Tabs
430                                             value={subProcTabState}
431                                             onChange={handleSubProcTabChange}
432                                             variant="fullWidth"
433                                             className={classes.symmetricTabs}
434                                         >
435                                             {hasInputMounts && <Tab label="Collections" />}
436                                             {hasOutputCollecton && <Tab label="Collection" />}
437                                             {isRawLoaded && <Tab label="JSON" />}
438                                         </Tabs>
439                                         <div className={classes.tableWrapper}>
440                                             {subProcTabState === 0 && hasInputMounts && <ProcessInputMounts mounts={mounts || []} />}
441                                             {subProcTabState === 0 && hasOutputCollecton && (
442                                                 <>
443                                                     {outputUuid && (
444                                                         <Typography className={classes.collectionLink}>
445                                                             Output Collection:{" "}
446                                                             <MuiLink
447                                                                 className={classes.keepLink}
448                                                                 onClick={() => {
449                                                                     navigateTo(outputUuid || "");
450                                                                 }}
451                                                             >
452                                                                 {outputUuid}
453                                                             </MuiLink>
454                                                         </Typography>
455                                                     )}
456                                                     <ProcessOutputCollectionFiles
457                                                         isWritable={false}
458                                                         currentItemUuid={outputUuid}
459                                                     />
460                                                 </>
461                                             )}
462                                             {isRawLoaded && (subProcTabState === 1 || (!hasInputMounts && !hasOutputCollecton)) && (
463                                                 <div className={classes.tableWrapper}>
464                                                     <ProcessIORaw data={raw} />
465                                                 </div>
466                                             )}
467                                         </div>
468                                     </>
469                                 ) : (
470                                     <Grid
471                                         container
472                                         item
473                                         alignItems="center"
474                                         justify="center"
475                                     >
476                                         <DefaultView messages={["No data to display"]} />
477                                     </Grid>
478                                 )}
479                             </>
480                         )}
481                     </CardContent>
482                 </Card>
483             );
484         }
485     )
486 );
487
488 export type ProcessIOValue = {
489     display: ReactElement<any, any>;
490     imageUrl?: string;
491     collection?: ReactElement<any, any>;
492     secondary?: boolean;
493 };
494
495 export type ProcessIOParameter = {
496     id: string;
497     label: string;
498     value: ProcessIOValue[];
499 };
500
501 interface ProcessIOPreviewDataProps {
502     data: ProcessIOParameter[];
503     showImagePreview: boolean;
504     valueLabel: string;
505 }
506
507 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
508
509 const ProcessIOPreview = memo(
510     withStyles(styles)(({ classes, data, showImagePreview, valueLabel }: ProcessIOPreviewProps) => {
511         const showLabel = data.some((param: ProcessIOParameter) => param.label);
512         return (
513             <Table
514                 className={classes.tableRoot}
515                 aria-label="Process IO Preview"
516             >
517                 <TableHead>
518                     <TableRow>
519                         <TableCell>Name</TableCell>
520                         {showLabel && <TableCell className={classes.labelColumn}>Label</TableCell>}
521                         <TableCell>{valueLabel}</TableCell>
522                         <TableCell>Collection</TableCell>
523                     </TableRow>
524                 </TableHead>
525                 <TableBody>
526                     {data.map((param: ProcessIOParameter) => {
527                         const firstVal = param.value.length > 0 ? param.value[0] : undefined;
528                         const rest = param.value.slice(1);
529                         const mainRowClasses = {
530                             [classes.noBorderRow]: rest.length > 0,
531                         };
532
533                         return (
534                             <React.Fragment key={param.id}>
535                                 <TableRow
536                                     className={classNames(mainRowClasses)}
537                                     data-cy="process-io-param"
538                                 >
539                                     <TableCell>{param.id}</TableCell>
540                                     {showLabel && <TableCell>{param.label}</TableCell>}
541                                     <TableCell>
542                                         {firstVal && (
543                                             <ProcessValuePreview
544                                                 value={firstVal}
545                                                 showImagePreview={showImagePreview}
546                                             />
547                                         )}
548                                     </TableCell>
549                                     <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
550                                         <Typography className={classes.paramValue}>{firstVal?.collection}</Typography>
551                                     </TableCell>
552                                 </TableRow>
553                                 {rest.map((val, i) => {
554                                     const rowClasses = {
555                                         [classes.noBorderRow]: i < rest.length - 1,
556                                         [classes.secondaryRow]: val.secondary,
557                                     };
558                                     return (
559                                         <TableRow
560                                             className={classNames(rowClasses)}
561                                             key={i}
562                                         >
563                                             <TableCell />
564                                             {showLabel && <TableCell />}
565                                             <TableCell>
566                                                 <ProcessValuePreview
567                                                     value={val}
568                                                     showImagePreview={showImagePreview}
569                                                 />
570                                             </TableCell>
571                                             <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
572                                                 <Typography className={classes.paramValue}>{val.collection}</Typography>
573                                             </TableCell>
574                                         </TableRow>
575                                     );
576                                 })}
577                             </React.Fragment>
578                         );
579                     })}
580                 </TableBody>
581             </Table>
582         );
583     })
584 );
585
586 interface ProcessValuePreviewProps {
587     value: ProcessIOValue;
588     showImagePreview: boolean;
589 }
590
591 const ProcessValuePreview = withStyles(styles)(({ value, showImagePreview, classes }: ProcessValuePreviewProps & WithStyles<CssRules>) => (
592     <Typography className={classes.paramValue}>
593         {value.imageUrl && showImagePreview ? (
594             <img
595                 className={classes.imagePreview}
596                 src={value.imageUrl}
597                 alt="Inline Preview"
598             />
599         ) : (
600             ""
601         )}
602         {value.imageUrl && !showImagePreview ? <ImagePlaceholder /> : ""}
603         <span className={classNames(classes.valArray, value.secondary && classes.secondaryVal)}>{value.display}</span>
604     </Typography>
605 ));
606
607 interface ProcessIORawDataProps {
608     data: ProcessIOParameter[];
609 }
610
611 const ProcessIORaw = withStyles(styles)(({ data }: ProcessIORawDataProps) => (
612     <Paper elevation={0}>
613         <DefaultCodeSnippet
614             lines={[JSON.stringify(data, null, 2)]}
615             linked
616         />
617     </Paper>
618 ));
619
620 interface ProcessInputMountsDataProps {
621     mounts: InputCollectionMount[];
622 }
623
624 type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
625
626 const ProcessInputMounts = withStyles(styles)(
627     connect((state: RootState) => ({
628         auth: state.auth,
629     }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
630         <Table
631             className={classes.tableRoot}
632             aria-label="Process Input Mounts"
633         >
634             <TableHead>
635                 <TableRow>
636                     <TableCell>Path</TableCell>
637                     <TableCell>Portable Data Hash</TableCell>
638                 </TableRow>
639             </TableHead>
640             <TableBody>
641                 {mounts.map(mount => (
642                     <TableRow key={mount.path}>
643                         <TableCell>
644                             <pre>{mount.path}</pre>
645                         </TableCell>
646                         <TableCell>
647                             <RouterLink
648                                 to={getNavUrl(mount.pdh, auth)}
649                                 className={classes.keepLink}
650                             >
651                                 {mount.pdh}
652                             </RouterLink>
653                         </TableCell>
654                     </TableRow>
655                 ))}
656             </TableBody>
657         </Table>
658     ))
659 );
660
661 type FileWithSecondaryFiles = {
662     secondaryFiles: File[];
663 };
664
665 export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
666     switch (true) {
667         case isPrimitiveOfType(input, CWLType.BOOLEAN):
668             const boolValue = (input as BooleanCommandInputParameter).value;
669             return boolValue !== undefined && !(Array.isArray(boolValue) && boolValue.length === 0)
670                 ? [{ display: renderPrimitiveValue(boolValue, false) }]
671                 : [{ display: <EmptyValue /> }];
672
673         case isPrimitiveOfType(input, CWLType.INT):
674         case isPrimitiveOfType(input, CWLType.LONG):
675             const intValue = (input as IntCommandInputParameter).value;
676             return intValue !== undefined &&
677                 // Missing values are empty array
678                 !(Array.isArray(intValue) && intValue.length === 0)
679                 ? [{ display: renderPrimitiveValue(intValue, false) }]
680                 : [{ display: <EmptyValue /> }];
681
682         case isPrimitiveOfType(input, CWLType.FLOAT):
683         case isPrimitiveOfType(input, CWLType.DOUBLE):
684             const floatValue = (input as FloatCommandInputParameter).value;
685             return floatValue !== undefined && !(Array.isArray(floatValue) && floatValue.length === 0)
686                 ? [{ display: renderPrimitiveValue(floatValue, false) }]
687                 : [{ display: <EmptyValue /> }];
688
689         case isPrimitiveOfType(input, CWLType.STRING):
690             const stringValue = (input as StringCommandInputParameter).value || undefined;
691             return stringValue !== undefined && !(Array.isArray(stringValue) && stringValue.length === 0)
692                 ? [{ display: renderPrimitiveValue(stringValue, false) }]
693                 : [{ display: <EmptyValue /> }];
694
695         case isPrimitiveOfType(input, CWLType.FILE):
696             const mainFile = (input as FileCommandInputParameter).value;
697             // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
698             const secondaryFiles = (mainFile as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
699             const files = [...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []), ...secondaryFiles];
700             const mainFilePdhUrl = mainFile ? getResourcePdhUrl(mainFile, pdh) : "";
701             return files.length
702                 ? files.map((file, i) => fileToProcessIOValue(file, i > 0, auth, pdh, i > 0 ? mainFilePdhUrl : ""))
703                 : [{ display: <EmptyValue /> }];
704
705         case isPrimitiveOfType(input, CWLType.DIRECTORY):
706             const directory = (input as DirectoryCommandInputParameter).value;
707             return directory !== undefined && !(Array.isArray(directory) && directory.length === 0)
708                 ? [directoryToProcessIOValue(directory, auth, pdh)]
709                 : [{ display: <EmptyValue /> }];
710
711         case getEnumType(input) !== null:
712             const enumValue = (input as EnumCommandInputParameter).value;
713             return enumValue !== undefined && enumValue ? [{ display: <pre>{enumValue}</pre> }] : [{ display: <EmptyValue /> }];
714
715         case isArrayOfType(input, CWLType.STRING):
716             const strArray = (input as StringArrayCommandInputParameter).value || [];
717             return strArray.length ? [{ display: <>{strArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
718
719         case isArrayOfType(input, CWLType.INT):
720         case isArrayOfType(input, CWLType.LONG):
721             const intArray = (input as IntArrayCommandInputParameter).value || [];
722             return intArray.length ? [{ display: <>{intArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
723
724         case isArrayOfType(input, CWLType.FLOAT):
725         case isArrayOfType(input, CWLType.DOUBLE):
726             const floatArray = (input as FloatArrayCommandInputParameter).value || [];
727             return floatArray.length ? [{ display: <>{floatArray.map(val => renderPrimitiveValue(val, true))}</> }] : [{ display: <EmptyValue /> }];
728
729         case isArrayOfType(input, CWLType.FILE):
730             const fileArrayMainFiles = (input as FileArrayCommandInputParameter).value || [];
731             const firstMainFilePdh = fileArrayMainFiles.length > 0 && fileArrayMainFiles[0] ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : "";
732
733             // Convert each main and secondaryFiles into array of ProcessIOValue preserving ordering
734             let fileArrayValues: ProcessIOValue[] = [];
735             for (let i = 0; i < fileArrayMainFiles.length; i++) {
736                 const secondaryFiles = (fileArrayMainFiles[i] as unknown as FileWithSecondaryFiles)?.secondaryFiles || [];
737                 fileArrayValues.push(
738                     // Pass firstMainFilePdh to secondary files and every main file besides the first to hide pdh if equal
739                     ...(fileArrayMainFiles[i] ? [fileToProcessIOValue(fileArrayMainFiles[i], false, auth, pdh, i > 0 ? firstMainFilePdh : "")] : []),
740                     ...secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh))
741                 );
742             }
743
744             return fileArrayValues.length ? fileArrayValues : [{ display: <EmptyValue /> }];
745
746         case isArrayOfType(input, CWLType.DIRECTORY):
747             const directories = (input as DirectoryArrayCommandInputParameter).value || [];
748             return directories.length ? directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) : [{ display: <EmptyValue /> }];
749
750         default:
751             return [{ display: <UnsupportedValue /> }];
752     }
753 };
754
755 const renderPrimitiveValue = (value: any, asChip: boolean) => {
756     const isObject = typeof value === "object";
757     if (!isObject) {
758         return asChip ? (
759             <Chip
760                 key={value}
761                 label={String(value)}
762             />
763         ) : (
764             <pre key={value}>{String(value)}</pre>
765         );
766     } else {
767         return asChip ? <UnsupportedValueChip /> : <UnsupportedValue />;
768     }
769 };
770
771 /*
772  * @returns keep url without keep: prefix
773  */
774 const getKeepUrl = (file: File | Directory, pdh?: string): string => {
775     const isKeepUrl = file.location?.startsWith("keep:") || false;
776     const keepUrl = isKeepUrl ? file.location?.replace("keep:", "") : pdh ? `${pdh}/${file.location}` : file.location;
777     return keepUrl || "";
778 };
779
780 interface KeepUrlProps {
781     auth: AuthState;
782     res: File | Directory;
783     pdh?: string;
784 }
785
786 const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => {
787     const keepUrl = getKeepUrl(res, pdh);
788     return keepUrl ? keepUrl.split("/").slice(0, 1)[0] : "";
789 };
790
791 const KeepUrlBase = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
792     const pdhUrl = getResourcePdhUrl(res, pdh);
793     // Passing a pdh always returns a relative wb2 collection url
794     const pdhWbPath = getNavUrl(pdhUrl, auth);
795     return pdhUrl && pdhWbPath ? (
796         <Tooltip title={"View collection in Workbench"}>
797             <RouterLink
798                 to={pdhWbPath}
799                 className={classes.keepLink}
800             >
801                 {pdhUrl}
802             </RouterLink>
803         </Tooltip>
804     ) : (
805         <></>
806     );
807 });
808
809 const KeepUrlPath = withStyles(styles)(({ auth, res, pdh, classes }: KeepUrlProps & WithStyles<CssRules>) => {
810     const keepUrl = getKeepUrl(res, pdh);
811     const keepUrlParts = keepUrl ? keepUrl.split("/") : [];
812     const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join("/") : "";
813
814     const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
815     return keepUrlPathNav ? (
816         <Tooltip title={"View in keep-web"}>
817             <a
818                 className={classes.keepLink}
819                 href={keepUrlPathNav}
820                 target="_blank"
821                 rel="noopener noreferrer"
822             >
823                 {keepUrlPath || "/"}
824             </a>
825         </Tooltip>
826     ) : (
827         <EmptyValue />
828     );
829 });
830
831 const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
832     let keepUrl = getKeepUrl(file, pdh);
833     return getInlineFileUrl(
834         `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
835         auth.config.keepWebServiceUrl,
836         auth.config.keepWebInlineServiceUrl
837     );
838 };
839
840 const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
841     const keepUrl = getKeepUrl(file, pdh);
842     return getInlineFileUrl(
843         `${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`,
844         auth.config.keepWebServiceUrl,
845         auth.config.keepWebInlineServiceUrl
846     );
847 };
848
849 const isFileImage = (basename?: string): boolean => {
850     return basename ? (mime.getType(basename) || "").startsWith("image/") : false;
851 };
852
853 const isFileUrl = (location?: string): boolean =>
854     !!location && !KEEP_URL_REGEX.exec(location) && (location.startsWith("http://") || location.startsWith("https://"));
855
856 const normalizeDirectoryLocation = (directory: Directory): Directory => {
857     if (!directory.location) {
858         return directory;
859     }
860     return {
861         ...directory,
862         location: (directory.location || "").endsWith("/") ? directory.location : directory.location + "/",
863     };
864 };
865
866 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
867     if (isExternalValue(directory)) {
868         return { display: <UnsupportedValue /> };
869     }
870
871     const normalizedDirectory = normalizeDirectoryLocation(directory);
872     return {
873         display: (
874             <KeepUrlPath
875                 auth={auth}
876                 res={normalizedDirectory}
877                 pdh={pdh}
878             />
879         ),
880         collection: (
881             <KeepUrlBase
882                 auth={auth}
883                 res={normalizedDirectory}
884                 pdh={pdh}
885             />
886         ),
887     };
888 };
889
890 const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => {
891     if (isExternalValue(file)) {
892         return { display: <UnsupportedValue /> };
893     }
894
895     if (isFileUrl(file.location)) {
896         return {
897             display: (
898                 <MuiLink
899                     href={file.location}
900                     target="_blank"
901                 >
902                     {file.location}
903                 </MuiLink>
904             ),
905             secondary,
906         };
907     }
908
909     const resourcePdh = getResourcePdhUrl(file, pdh);
910     return {
911         display: (
912             <KeepUrlPath
913                 auth={auth}
914                 res={file}
915                 pdh={pdh}
916             />
917         ),
918         secondary,
919         imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
920         collection:
921             resourcePdh !== mainFilePdh ? (
922                 <KeepUrlBase
923                     auth={auth}
924                     res={file}
925                     pdh={pdh}
926                 />
927             ) : (
928                 <></>
929             ),
930     };
931 };
932
933 const isExternalValue = (val: any) => Object.keys(val).includes("$import") || Object.keys(val).includes("$include");
934
935 export const EmptyValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>);
936
937 const UnsupportedValue = withStyles(styles)(({ classes }: WithStyles<CssRules>) => <span className={classes.emptyValue}>Cannot display value</span>);
938
939 const UnsupportedValueChip = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
940     <Chip
941         icon={<InfoIcon />}
942         label={"Cannot display value"}
943     />
944 ));
945
946 const ImagePlaceholder = withStyles(styles)(({ classes }: WithStyles<CssRules>) => (
947     <span className={classes.imagePlaceholder}>
948         <ImageIcon />
949     </span>
950 ));