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