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