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