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