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