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