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