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