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