b2f36f5238542df5e703b5105f405e1a1cffce55
[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 } 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
69 type CssRules =
70   | "card"
71   | "content"
72   | "title"
73   | "header"
74   | "avatar"
75   | "iconHeader"
76   | "tableWrapper"
77   | "tableRoot"
78   | "paramValue"
79   | "keepLink"
80   | "collectionLink"
81   | "imagePreview"
82   | "valArray"
83   | "secondaryVal"
84   | "emptyValue"
85   | "halfRow"
86   | "symmetricTabs"
87   | "imagePlaceholder"
88   | "rowWithPreview"
89   | "labelColumn";
90
91 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
92     card: {
93         height: '100%'
94     },
95     header: {
96         paddingTop: theme.spacing.unit,
97         paddingBottom: theme.spacing.unit,
98     },
99     iconHeader: {
100         fontSize: '1.875rem',
101         color: theme.customs.colors.green700,
102     },
103     avatar: {
104         alignSelf: 'flex-start',
105         paddingTop: theme.spacing.unit * 0.5
106     },
107     content: {
108         height: `calc(100% - ${theme.spacing.unit * 7}px - ${theme.spacing.unit * 1.5}px)`,
109         padding: theme.spacing.unit * 1.0,
110         paddingTop: theme.spacing.unit * 0.5,
111         '&:last-child': {
112             paddingBottom: theme.spacing.unit * 1,
113         }
114     },
115     title: {
116         overflow: 'hidden',
117         paddingTop: theme.spacing.unit * 0.5
118     },
119     tableWrapper: {
120         height: `calc(100% - ${theme.spacing.unit * 6}px)`,
121         overflow: 'auto',
122     },
123     tableRoot: {
124         width: '100%',
125         '& thead th': {
126             verticalAlign: 'bottom',
127             paddingBottom: '10px',
128         },
129         '& td, & th': {
130             paddingRight: '25px',
131         }
132     },
133     paramValue: {
134         display: 'flex',
135         alignItems: 'flex-start',
136         flexDirection: 'column',
137     },
138     keepLink: {
139         color: theme.palette.primary.main,
140         textDecoration: 'none',
141         overflowWrap: 'break-word',
142         cursor: 'pointer',
143     },
144     collectionLink: {
145         margin: '10px',
146         '& a': {
147             color: theme.palette.primary.main,
148             textDecoration: 'none',
149             overflowWrap: 'break-word',
150             cursor: 'pointer',
151         }
152     },
153     imagePreview: {
154         maxHeight: '15em',
155         maxWidth: '15em',
156         marginBottom: theme.spacing.unit,
157     },
158     valArray: {
159         display: 'flex',
160         gap: '10px',
161         flexWrap: 'wrap',
162         '& span': {
163             display: 'inline',
164         }
165     },
166     secondaryVal: {
167         paddingLeft: '20px',
168     },
169     emptyValue: {
170         color: theme.customs.colors.grey500,
171     },
172     halfRow: {
173         '& td': {
174             borderBottom: 'none',
175         }
176     },
177     symmetricTabs: {
178         '& button': {
179             flexBasis: '0',
180         }
181     },
182     imagePlaceholder: {
183         width: '60px',
184         height: '60px',
185         display: 'flex',
186         alignItems: 'center',
187         justifyContent: 'center',
188         backgroundColor: '#cecece',
189         borderRadius: '10px',
190     },
191     rowWithPreview: {
192         verticalAlign: 'bottom',
193     },
194     labelColumn: {
195         minWidth: '120px',
196     },
197 });
198
199 export enum ProcessIOCardType {
200     INPUT = 'Inputs',
201     OUTPUT = 'Outputs',
202 }
203 export interface ProcessIOCardDataProps {
204     process: Process;
205     label: ProcessIOCardType;
206     params?: ProcessIOParameter[];
207     raw?: any;
208     mounts?: InputCollectionMount[];
209     outputUuid?: string;
210 }
211
212 export interface ProcessIOCardActionProps {
213     navigateTo: (uuid: string) => void;
214 }
215
216 const mapDispatchToProps = (dispatch: Dispatch): ProcessIOCardActionProps => ({
217     navigateTo: (uuid) => dispatch<any>(navigateTo(uuid)),
218 });
219
220 type ProcessIOCardProps = ProcessIOCardDataProps & ProcessIOCardActionProps & WithStyles<CssRules> & MPVPanelProps;
221
222 export const ProcessIOCard = withStyles(styles)(connect(null, mapDispatchToProps)(
223     ({ classes, label, params, raw, mounts, outputUuid, doHidePanel, doMaximizePanel, panelMaximized, panelName, process, navigateTo }: ProcessIOCardProps) => {
224         const [mainProcTabState, setMainProcTabState] = useState(0);
225         const handleMainProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
226             setMainProcTabState(value);
227         }
228
229         const [showImagePreview, setShowImagePreview] = useState(false);
230
231         const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon;
232         const mainProcess = !process.containerRequest.requestingContainerUuid;
233
234         const loading = raw === undefined || params === undefined;
235         const hasRaw = !!(raw && Object.keys(raw).length > 0);
236         const hasParams = !!(params && params.length > 0);
237
238         return <Card className={classes.card} data-cy="process-io-card">
239             <CardHeader
240                 className={classes.header}
241                 classes={{
242                     content: classes.title,
243                     avatar: classes.avatar,
244                 }}
245                 avatar={<PanelIcon className={classes.iconHeader} />}
246                 title={
247                     <Typography noWrap variant='h6' color='inherit'>
248                         {label}
249                     </Typography>
250                 }
251                 action={
252                     <div>
253                         { mainProcess && <Tooltip title={"Toggle Image Preview"} disableFocusListener>
254                             <IconButton data-cy="io-preview-image-toggle" onClick={() =>{setShowImagePreview(!showImagePreview)}}>{showImagePreview ? <ImageIcon /> : <ImageOffIcon />}</IconButton>
255                         </Tooltip> }
256                         { doMaximizePanel && !panelMaximized &&
257                         <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
258                             <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
259                         </Tooltip> }
260                         { doHidePanel &&
261                         <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
262                             <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
263                         </Tooltip> }
264                     </div>
265                 } />
266             <CardContent className={classes.content}>
267                 {mainProcess ?
268                     (<>
269                         {/* raw is undefined until params are loaded */}
270                         {loading && <Grid container item alignItems='center' justify='center'>
271                             <CircularProgress />
272                         </Grid>}
273                         {/* Once loaded, either raw or params may still be empty
274                           *   Raw when all params are empty
275                           *   Params when raw is provided by containerRequest properties but workflow mount is absent for preview
276                           */}
277                         {(!loading && (hasRaw || hasParams)) &&
278                             <>
279                                 <Tabs value={mainProcTabState} onChange={handleMainProcTabChange} variant="fullWidth" className={classes.symmetricTabs}>
280                                     {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */}
281                                     {hasParams && <Tab label="Parameters" />}
282                                     <Tab label="JSON" />
283                                 </Tabs>
284                                 {(mainProcTabState === 0 && params && hasParams) && <div className={classes.tableWrapper}>
285                                         <ProcessIOPreview data={params} showImagePreview={showImagePreview} />
286                                     </div>}
287                                 {(mainProcTabState === 1 || !hasParams) && <div className={classes.tableWrapper}>
288                                         <ProcessIORaw data={raw} />
289                                     </div>}
290                             </>}
291                         {!loading && !hasRaw && !hasParams && <Grid container item alignItems='center' justify='center'>
292                             <DefaultView messages={["No parameters found"]} />
293                         </Grid>}
294                     </>) :
295                     // Subprocess
296                     (<>
297                         {((mounts && mounts.length) || outputUuid) ?
298                             <>
299                                 <Tabs value={0} variant="fullWidth" className={classes.symmetricTabs}>
300                                     {label === ProcessIOCardType.INPUT && <Tab label="Collections" />}
301                                     {label === ProcessIOCardType.OUTPUT && <Tab label="Collection" />}
302                                 </Tabs>
303                                 <div className={classes.tableWrapper}>
304                                     {label === ProcessIOCardType.INPUT && <ProcessInputMounts mounts={mounts || []} />}
305                                     {label === ProcessIOCardType.OUTPUT && <>
306                                         {outputUuid && <Typography className={classes.collectionLink}>
307                                             Output Collection: <MuiLink className={classes.keepLink} onClick={() => {navigateTo(outputUuid || "")}}>
308                                             {outputUuid}
309                                         </MuiLink></Typography>}
310                                         <ProcessOutputCollectionFiles isWritable={false} currentItemUuid={outputUuid} />
311                                     </>}
312                                 </div>
313                             </> :
314                             <Grid container item alignItems='center' justify='center'>
315                                 <DefaultView messages={["No collection(s) found"]} />
316                             </Grid>
317                         }
318                     </>)
319                 }
320             </CardContent>
321         </Card>;
322     }
323 ));
324
325 export type ProcessIOValue = {
326     display: ReactElement<any, any>;
327     imageUrl?: string;
328     collection?: ReactElement<any, any>;
329     secondary?: boolean;
330 }
331
332 export type ProcessIOParameter = {
333     id: string;
334     label: string;
335     value: ProcessIOValue[];
336 }
337
338 interface ProcessIOPreviewDataProps {
339     data: ProcessIOParameter[];
340     showImagePreview: boolean;
341 }
342
343 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
344
345 const ProcessIOPreview = withStyles(styles)(
346     ({ classes, data, showImagePreview }: ProcessIOPreviewProps) => {
347         const showLabel = data.some((param: ProcessIOParameter) => param.label);
348         return <Table className={classes.tableRoot} aria-label="Process IO Preview">
349             <TableHead>
350                 <TableRow>
351                     <TableCell>Name</TableCell>
352                     {showLabel && <TableCell className={classes.labelColumn}>Label</TableCell>}
353                     <TableCell>Value</TableCell>
354                     <TableCell>Collection</TableCell>
355                 </TableRow>
356             </TableHead>
357             <TableBody>
358                 {data.map((param: ProcessIOParameter) => {
359                     const firstVal = param.value.length > 0 ? param.value[0] : undefined;
360                     const rest = param.value.slice(1);
361                     const rowClass = rest.length > 0 ? classes.halfRow : undefined;
362
363                     return <>
364                         <TableRow className={rowClass} data-cy="process-io-param">
365                             <TableCell>
366                                 {param.id}
367                             </TableCell>
368                             {showLabel && <TableCell >{param.label}</TableCell>}
369                             <TableCell>
370                                 {firstVal && <ProcessValuePreview value={firstVal} showImagePreview={showImagePreview} />}
371                             </TableCell>
372                             <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
373                                 <Typography className={classes.paramValue}>
374                                     {firstVal?.collection}
375                                 </Typography>
376                             </TableCell>
377                         </TableRow>
378                         {rest.map((val, i) => (
379                             <TableRow className={(i < rest.length-1) ? rowClass : undefined}>
380                                 <TableCell />
381                                 {showLabel && <TableCell />}
382                                 <TableCell>
383                                     <ProcessValuePreview value={val} showImagePreview={showImagePreview} />
384                                 </TableCell>
385                                 <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
386                                     <Typography className={classes.paramValue}>
387                                         {val.collection}
388                                     </Typography>
389                                 </TableCell>
390                             </TableRow>
391                         ))}
392                     </>;
393                 })}
394             </TableBody>
395         </Table>;
396 });
397
398 interface ProcessValuePreviewProps {
399     value: ProcessIOValue;
400     showImagePreview: boolean;
401 }
402
403 const ProcessValuePreview = withStyles(styles)(
404     ({value, showImagePreview, classes}: ProcessValuePreviewProps & WithStyles<CssRules>) =>
405         <Typography className={classes.paramValue}>
406             {value.imageUrl && showImagePreview ? <img className={classes.imagePreview} src={value.imageUrl} alt="Inline Preview" /> : ""}
407             {value.imageUrl && !showImagePreview ? <ImagePlaceholder /> : ""}
408             <span className={classNames(classes.valArray, value.secondary && classes.secondaryVal)}>
409                 {value.display}
410             </span>
411         </Typography>
412 )
413
414 interface ProcessIORawDataProps {
415     data: ProcessIOParameter[];
416 }
417
418 const ProcessIORaw = withStyles(styles)(
419     ({ data }: ProcessIORawDataProps) =>
420         <Paper elevation={0}>
421             <DefaultCodeSnippet lines={[JSON.stringify(data, null, 2)]} linked />
422         </Paper>
423 );
424
425 interface ProcessInputMountsDataProps {
426     mounts: InputCollectionMount[];
427 }
428
429 type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
430
431 const ProcessInputMounts = withStyles(styles)(connect((state: RootState) => ({
432     auth: state.auth,
433 }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
434     <Table className={classes.tableRoot} aria-label="Process Input Mounts">
435         <TableHead>
436             <TableRow>
437                 <TableCell>Path</TableCell>
438                 <TableCell>Portable Data Hash</TableCell>
439             </TableRow>
440         </TableHead>
441         <TableBody>
442             {mounts.map(mount => (
443                 <TableRow key={mount.path}>
444                     <TableCell><pre>{mount.path}</pre></TableCell>
445                     <TableCell>
446                         <RouterLink to={getNavUrl(mount.pdh, auth)} className={classes.keepLink}>{mount.pdh}</RouterLink>
447                     </TableCell>
448                 </TableRow>
449             ))}
450         </TableBody>
451     </Table>
452 )));
453
454 type FileWithSecondaryFiles = {
455     secondaryFiles: File[];
456 }
457
458 export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
459     switch (true) {
460         case isPrimitiveOfType(input, CWLType.BOOLEAN):
461             const boolValue = (input as BooleanCommandInputParameter).value;
462
463             return boolValue !== undefined &&
464                     !(Array.isArray(boolValue) && boolValue.length === 0) ?
465                 [{display: <pre>{String(boolValue)}</pre> }] :
466                 [{display: <EmptyValue />}];
467
468         case isPrimitiveOfType(input, CWLType.INT):
469         case isPrimitiveOfType(input, CWLType.LONG):
470             const intValue = (input as IntCommandInputParameter).value;
471
472             return intValue !== undefined &&
473                     // Missing values are empty array
474                     !(Array.isArray(intValue) && intValue.length === 0) ?
475                 [{display: <pre>{String(intValue)}</pre> }]
476                 : [{display: <EmptyValue />}];
477
478         case isPrimitiveOfType(input, CWLType.FLOAT):
479         case isPrimitiveOfType(input, CWLType.DOUBLE):
480             const floatValue = (input as FloatCommandInputParameter).value;
481
482             return floatValue !== undefined &&
483                     !(Array.isArray(floatValue) && floatValue.length === 0) ?
484                 [{display: <pre>{String(floatValue)}</pre> }]:
485                 [{display: <EmptyValue />}];
486
487         case isPrimitiveOfType(input, CWLType.STRING):
488             const stringValue = (input as StringCommandInputParameter).value || undefined;
489
490             return stringValue !== undefined &&
491                     !(Array.isArray(stringValue) && stringValue.length === 0) ?
492                 [{display: <pre>{stringValue}</pre> }] :
493                 [{display: <EmptyValue />}];
494
495         case isPrimitiveOfType(input, CWLType.FILE):
496             const mainFile = (input as FileCommandInputParameter).value;
497             // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
498             const secondaryFiles = ((mainFile as unknown) as FileWithSecondaryFiles)?.secondaryFiles || [];
499             const files = [
500                 ...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []),
501                 ...secondaryFiles
502             ];
503             const mainFilePdhUrl = mainFile ? getResourcePdhUrl(mainFile, pdh) : "";
504
505             return files.length ?
506                 files.map((file, i) => fileToProcessIOValue(file, (i > 0), auth, pdh, (i > 0 ? mainFilePdhUrl : ""))) :
507                 [{display: <EmptyValue />}];
508
509         case isPrimitiveOfType(input, CWLType.DIRECTORY):
510             const directory = (input as DirectoryCommandInputParameter).value;
511
512             return directory !== undefined &&
513                     !(Array.isArray(directory) && directory.length === 0) ?
514                 [directoryToProcessIOValue(directory, auth, pdh)] :
515                 [{display: <EmptyValue />}];
516
517         case typeof input.type === 'object' &&
518             !(input.type instanceof Array) &&
519             input.type.type === 'enum':
520             const enumValue = (input as EnumCommandInputParameter).value;
521
522             return enumValue !== undefined ?
523                 [{ display: <pre>{(input as EnumCommandInputParameter).value || ''}</pre> }] :
524                 [{display: <EmptyValue />}];
525
526         case isArrayOfType(input, CWLType.STRING):
527             const strArray = (input as StringArrayCommandInputParameter).value || [];
528             return strArray.length ?
529                 [{ display: <>{((input as StringArrayCommandInputParameter).value || []).map((val) => <Chip label={val} />)}</> }] :
530                 [{display: <EmptyValue />}];
531
532         case isArrayOfType(input, CWLType.INT):
533         case isArrayOfType(input, CWLType.LONG):
534             const intArray = (input as IntArrayCommandInputParameter).value || [];
535
536             return intArray.length ?
537                 [{ display: <>{((input as IntArrayCommandInputParameter).value || []).map((val) => <Chip label={val} />)}</> }] :
538                 [{display: <EmptyValue />}];
539
540         case isArrayOfType(input, CWLType.FLOAT):
541         case isArrayOfType(input, CWLType.DOUBLE):
542             const floatArray = (input as FloatArrayCommandInputParameter).value || [];
543
544             return floatArray.length ?
545                 [{ display: <>{floatArray.map((val) => <Chip label={val} />)}</> }] :
546                 [{display: <EmptyValue />}];
547
548         case isArrayOfType(input, CWLType.FILE):
549             const fileArrayMainFiles = ((input as FileArrayCommandInputParameter).value || []);
550             const firstMainFilePdh = (fileArrayMainFiles.length > 0 && fileArrayMainFiles[0]) ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : "";
551
552             // Convert each main file into separate arrays of ProcessIOValue to preserve secondaryFile grouping
553             const fileArrayValues = fileArrayMainFiles.map((mainFile: File, i): ProcessIOValue[] => {
554                 const secondaryFiles = ((mainFile as unknown) as FileWithSecondaryFiles)?.secondaryFiles || [];
555                 return [
556                     // Pass firstMainFilePdh to secondary files and every main file besides the first to hide pdh if equal
557                     ...(mainFile ? [fileToProcessIOValue(mainFile, false, auth, pdh, i > 0 ? firstMainFilePdh : "")] : []),
558                     ...(secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh)))
559                 ];
560             // Reduce each mainFile/secondaryFile group into single array preserving ordering
561             }).reduce((acc: ProcessIOValue[], mainFile: ProcessIOValue[]) => (acc.concat(mainFile)), []);
562
563             return fileArrayValues.length ?
564                 fileArrayValues :
565                 [{display: <EmptyValue />}];
566
567         case isArrayOfType(input, CWLType.DIRECTORY):
568             const directories = (input as DirectoryArrayCommandInputParameter).value || [];
569
570             return directories.length ?
571                 directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) :
572                 [{display: <EmptyValue />}];
573
574         default:
575             return [];
576     }
577 };
578
579 /*
580  * @returns keep url without keep: prefix
581  */
582 const getKeepUrl = (file: File | Directory, pdh?: string): string => {
583     const isKeepUrl = file.location?.startsWith('keep:') || false;
584     const keepUrl = isKeepUrl ?
585                         file.location?.replace('keep:', '') :
586                         pdh ? `${pdh}/${file.location}` : file.location;
587     return keepUrl || '';
588 };
589
590 interface KeepUrlProps {
591     auth: AuthState;
592     res: File | Directory;
593     pdh?: string;
594 }
595
596 const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => {
597     const keepUrl = getKeepUrl(res, pdh);
598     return keepUrl ? keepUrl.split('/').slice(0, 1)[0] : '';
599 };
600
601 const KeepUrlBase = withStyles(styles)(({auth, res, pdh, classes}: KeepUrlProps & WithStyles<CssRules>) => {
602     const pdhUrl = getResourcePdhUrl(res, pdh);
603     // Passing a pdh always returns a relative wb2 collection url
604     const pdhWbPath = getNavUrl(pdhUrl, auth);
605     return pdhUrl && pdhWbPath ?
606         <Tooltip title={"View collection in Workbench"}><RouterLink to={pdhWbPath} className={classes.keepLink}>{pdhUrl}</RouterLink></Tooltip> :
607         <></>;
608 });
609
610 const KeepUrlPath = withStyles(styles)(({auth, res, pdh, classes}: KeepUrlProps & WithStyles<CssRules>) => {
611     const keepUrl = getKeepUrl(res, pdh);
612     const keepUrlParts = keepUrl ? keepUrl.split('/') : [];
613     const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join('/') : '';
614
615     const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
616     return keepUrlPathNav ?
617         <Tooltip title={"View in keep-web"}><a className={classes.keepLink} href={keepUrlPathNav} target="_blank" rel="noopener noreferrer">{keepUrlPath || '/'}</a></Tooltip> :
618         <EmptyValue />;
619 });
620
621 const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
622     let keepUrl = getKeepUrl(file, pdh);
623     return (getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl));
624 };
625
626 const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
627     const keepUrl = getKeepUrl(file, pdh);
628     return getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl);
629 };
630
631 const isFileImage = (basename?: string): boolean => {
632     return basename ? (mime.getType(basename) || "").startsWith('image/') : false;
633 };
634
635 const normalizeDirectoryLocation = (directory: Directory): Directory => {
636     if (!directory.location) {
637         return directory;
638     }
639     return {
640         ...directory,
641         location: (directory.location || '').endsWith('/') ? directory.location : directory.location + '/',
642     };
643 };
644
645 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
646     const normalizedDirectory = normalizeDirectoryLocation(directory);
647     return {
648         display: <KeepUrlPath auth={auth} res={normalizedDirectory} pdh={pdh}/>,
649         collection: <KeepUrlBase auth={auth} res={normalizedDirectory} pdh={pdh}/>,
650     };
651 };
652
653 const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => {
654     const resourcePdh = getResourcePdhUrl(file, pdh);
655     return {
656         display: <KeepUrlPath auth={auth} res={file} pdh={pdh}/>,
657         secondary,
658         imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
659         collection: (resourcePdh !== mainFilePdh) ? <KeepUrlBase auth={auth} res={file} pdh={pdh}/> : <></>,
660     }
661 };
662
663 const EmptyValue = withStyles(styles)(
664     ({classes}: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>
665 );
666
667 const ImagePlaceholder = withStyles(styles)(
668     ({classes}: WithStyles<CssRules>) => <span className={classes.imagePlaceholder}><ImageIcon /></span>
669 );