16073: Add tooltip hints to collection/keep links
[arvados.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 {
7     StyleRulesCallback,
8     WithStyles,
9     withStyles,
10     Card,
11     CardHeader,
12     IconButton,
13     CardContent,
14     Tooltip,
15     Typography,
16     Tabs,
17     Tab,
18     Table,
19     TableHead,
20     TableBody,
21     TableRow,
22     TableCell,
23     Paper,
24     Grid,
25     Chip,
26 } from '@material-ui/core';
27 import { ArvadosTheme } from 'common/custom-theme';
28 import { CloseIcon, InfoIcon, ProcessIcon, InputIcon, OutputIcon } 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   StringArrayCommandInputParameter,
47   StringCommandInputParameter,
48 } from "models/workflow";
49 import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
50 import { File } from 'models/workflow';
51 import { getInlineFileUrl } from 'views-components/context-menu/actions/helpers';
52 import { AuthState } from 'store/auth/auth-reducer';
53 import mime from 'mime';
54 import { DefaultView } from 'components/default-view/default-view';
55 import { getNavUrl } from 'routes/routes';
56 import { Link as RouterLink } from 'react-router-dom';
57 import { Link as MuiLink } from '@material-ui/core';
58 import { InputCollectionMount } from 'store/processes/processes-actions';
59 import { connect } from 'react-redux';
60 import { RootState } from 'store/store';
61 import { ProcessOutputCollectionFiles } from './process-output-collection-files';
62 import { Process } from 'store/processes/process';
63
64 type CssRules = 'card' | 'content' | 'title' | 'header' | 'avatar' | 'iconHeader' | 'tableWrapper' | 'tableRoot' | 'paramValue' | 'keepLink' | 'imagePreview' | 'valArray' | 'emptyValue';
65
66 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
67     card: {
68         height: '100%'
69     },
70     header: {
71         paddingTop: theme.spacing.unit,
72         paddingBottom: theme.spacing.unit,
73     },
74     iconHeader: {
75         fontSize: '1.875rem',
76         color: theme.customs.colors.green700,
77     },
78     avatar: {
79         alignSelf: 'flex-start',
80         paddingTop: theme.spacing.unit * 0.5
81     },
82     content: {
83         padding: theme.spacing.unit * 1.0,
84         paddingTop: theme.spacing.unit * 0.5,
85         '&:last-child': {
86             paddingBottom: theme.spacing.unit * 1,
87         }
88     },
89     title: {
90         overflow: 'hidden',
91         paddingTop: theme.spacing.unit * 0.5
92     },
93     tableWrapper: {
94         overflow: 'auto',
95     },
96     tableRoot: {
97         width: '100%',
98         '& thead th': {
99             verticalAlign: 'bottom',
100             paddingBottom: '10px',
101         }
102     },
103     paramValue: {
104         display: 'flex',
105         alignItems: 'flex-start',
106         flexDirection: 'column',
107     },
108     keepLink: {
109         color: theme.palette.primary.main,
110         textDecoration: 'none',
111         overflowWrap: 'break-word',
112         cursor: 'pointer',
113     },
114     imagePreview: {
115         maxHeight: '15em',
116         maxWidth: '15em',
117         marginBottom: theme.spacing.unit,
118     },
119     valArray: {
120         display: 'flex',
121         gap: '10px',
122         flexWrap: 'wrap',
123         '& span': {
124             display: 'inline',
125         }
126     },
127     emptyValue: {
128         color: theme.customs.colors.grey500,
129     },
130 });
131
132 export enum ProcessIOCardType {
133     INPUT = 'Inputs',
134     OUTPUT = 'Outputs',
135 }
136 export interface ProcessIOCardDataProps {
137     process: Process;
138     label: ProcessIOCardType;
139     params: ProcessIOParameter[];
140     raw?: any;
141     mounts?: InputCollectionMount[];
142     outputUuid?: string;
143 }
144
145 type ProcessIOCardProps = ProcessIOCardDataProps & WithStyles<CssRules> & MPVPanelProps;
146
147 export const ProcessIOCard = withStyles(styles)(
148     ({ classes, label, params, raw, mounts, outputUuid, doHidePanel, panelName, process }: ProcessIOCardProps) => {
149         const [mainProcTabState, setMainProcTabState] = useState(0);
150         const handleMainProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
151             setMainProcTabState(value);
152         }
153
154         const PanelIcon = label == ProcessIOCardType.INPUT ? InputIcon : OutputIcon;
155
156         return <Card className={classes.card} data-cy="process-io-card">
157             <CardHeader
158                 className={classes.header}
159                 classes={{
160                     content: classes.title,
161                     avatar: classes.avatar,
162                 }}
163                 avatar={<PanelIcon className={classes.iconHeader} />}
164                 title={
165                     <Typography noWrap variant='h6' color='inherit'>
166                         {label}
167                     </Typography>
168                 }
169                 action={
170                     <div>
171                         { doHidePanel &&
172                         <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
173                             <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
174                         </Tooltip> }
175                     </div>
176                 } />
177             <CardContent className={classes.content}>
178                 <div>
179                     {!process.containerRequest.requestingContainerUuid ?
180                         (<>
181                             <Tabs value={mainProcTabState} onChange={handleMainProcTabChange} variant="fullWidth">
182                                 <Tab label="Parameters" />
183                                 <Tab label="JSON" />
184                             </Tabs>
185                             {mainProcTabState === 0 && <div className={classes.tableWrapper}>
186                                 {params.length ?
187                                     <ProcessIOPreview data={params} /> :
188                                     <Grid container item alignItems='center' justify='center'>
189                                         <DefaultView messages={["No parameters found"]} icon={InfoIcon} />
190                                     </Grid>}
191                                 </div>}
192                             {mainProcTabState === 1 && <div className={classes.tableWrapper}>
193                                 {params.length ?
194                                     <ProcessIORaw data={raw || params} /> :
195                                     <Grid container item alignItems='center' justify='center'>
196                                         <DefaultView messages={["No parameters found"]} icon={InfoIcon} />
197                                     </Grid>}
198                                 </div>}
199                         </>) :
200                         (<>
201                             <Tabs value={0} variant="fullWidth">
202                                 {label === ProcessIOCardType.INPUT && <Tab label="Collections" />}
203                                 {label === ProcessIOCardType.OUTPUT && <Tab label="Collection" />}
204                             </Tabs>
205                             <div className={classes.tableWrapper}>
206                                 {label === ProcessIOCardType.INPUT && <ProcessInputMounts mounts={mounts || []} />}
207                                 {label === ProcessIOCardType.OUTPUT && <ProcessOutputCollectionFiles isWritable={false} currentItemUuid={outputUuid} />}
208                             </div>
209                         </>)
210                     }
211                 </div>
212             </CardContent>
213         </Card>;
214     }
215 );
216
217 export type ProcessIOValue = {
218     display: ReactElement<any, any>;
219     imageUrl?: string;
220 }
221
222 export type ProcessIOParameter = {
223     id: string;
224     doc: string;
225     value: ProcessIOValue[];
226 }
227
228 interface ProcessIOPreviewDataProps {
229     data: ProcessIOParameter[];
230 }
231
232 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
233
234 const ProcessIOPreview = withStyles(styles)(
235     ({ classes, data }: ProcessIOPreviewProps) =>
236         <Table className={classes.tableRoot} aria-label="Process IO Preview">
237             <TableHead>
238                 <TableRow>
239                     <TableCell>Label</TableCell>
240                     <TableCell>Description</TableCell>
241                     <TableCell>Value</TableCell>
242                 </TableRow>
243             </TableHead>
244             <TableBody>
245                 {data.map((param: ProcessIOParameter) => {
246                     return <TableRow key={param.id}>
247                         <TableCell component="th" scope="row">
248                             {param.id}
249                         </TableCell>
250                         <TableCell>{param.doc}</TableCell>
251                         <TableCell>{param.value.map(val => (
252                             <Typography className={classes.paramValue}>
253                                 {val.imageUrl ? <img className={classes.imagePreview} src={val.imageUrl} alt="Inline Preview" /> : ""}
254                                 <span className={classes.valArray}>
255                                     {val.display}
256                                 </span>
257                             </Typography>
258                         ))}</TableCell>
259                     </TableRow>;
260                 })}
261             </TableBody>
262         </Table>
263 );
264
265 const handleClick = (url) => {
266     window.open(url, '_blank');
267 }
268
269 const ProcessIORaw = withStyles(styles)(
270     ({ data }: ProcessIOPreviewProps) =>
271         <Paper elevation={0}>
272             <pre>
273                 {JSON.stringify(data, null, 2)}
274             </pre>
275         </Paper>
276 );
277
278 interface ProcessInputMountsDataProps {
279     mounts: InputCollectionMount[];
280 }
281
282 type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
283
284 const ProcessInputMounts = withStyles(styles)(connect((state: RootState) => ({
285     auth: state.auth,
286 }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
287     <Table className={classes.tableRoot} aria-label="Process Input Mounts">
288         <TableHead>
289             <TableRow>
290                 <TableCell>Path</TableCell>
291                 <TableCell>Portable Data Hash</TableCell>
292             </TableRow>
293         </TableHead>
294         <TableBody>
295             {mounts.map(mount => (
296                 <TableRow key={mount.path}>
297                     <TableCell><pre>{mount.path}</pre></TableCell>
298                     <TableCell>
299                         <RouterLink to={getNavUrl(mount.pdh, auth)} className={classes.keepLink}>{mount.pdh}</RouterLink>
300                     </TableCell>
301                 </TableRow>
302             ))}
303         </TableBody>
304     </Table>
305 )));
306
307 type FileWithSecondaryFiles = {
308     secondaryFiles: File[];
309 }
310
311 export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
312     switch (true) {
313         case isPrimitiveOfType(input, CWLType.BOOLEAN):
314             const boolValue = (input as BooleanCommandInputParameter).value;
315
316             return boolValue !== undefined &&
317                     !(Array.isArray(boolValue) && boolValue.length === 0) ?
318                 [{display: <pre>{String(boolValue)}</pre> }] :
319                 [{display: <EmptyValue />}];
320
321         case isPrimitiveOfType(input, CWLType.INT):
322         case isPrimitiveOfType(input, CWLType.LONG):
323             const intValue = (input as IntCommandInputParameter).value;
324
325             return intValue !== undefined &&
326                     // Missing values are empty array
327                     !(Array.isArray(intValue) && intValue.length === 0) ?
328                 [{display: <pre>{String(intValue)}</pre> }]
329                 : [{display: <EmptyValue />}];
330
331         case isPrimitiveOfType(input, CWLType.FLOAT):
332         case isPrimitiveOfType(input, CWLType.DOUBLE):
333             const floatValue = (input as FloatCommandInputParameter).value;
334
335             return floatValue !== undefined &&
336                     !(Array.isArray(floatValue) && floatValue.length === 0) ?
337                 [{display: <pre>{String(floatValue)}</pre> }]:
338                 [{display: <EmptyValue />}];
339
340         case isPrimitiveOfType(input, CWLType.STRING):
341             const stringValue = (input as StringCommandInputParameter).value || undefined;
342
343             return stringValue !== undefined &&
344                     !(Array.isArray(stringValue) && stringValue.length === 0) ?
345                 [{display: <pre>{stringValue}</pre> }] :
346                 [{display: <EmptyValue />}];
347
348         case isPrimitiveOfType(input, CWLType.FILE):
349             const mainFile = (input as FileCommandInputParameter).value;
350             // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
351             const secondaryFiles = ((mainFile as unknown) as FileWithSecondaryFiles)?.secondaryFiles || [];
352             const files = [
353                 ...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []),
354                 ...secondaryFiles
355             ];
356
357             return files.length ?
358                 files.map(file => fileToProcessIOValue(file, auth, pdh)) :
359                 [{display: <EmptyValue />}];
360
361         case isPrimitiveOfType(input, CWLType.DIRECTORY):
362             const directory = (input as DirectoryCommandInputParameter).value;
363
364             return directory !== undefined &&
365                     !(Array.isArray(directory) && directory.length === 0) ?
366                 [directoryToProcessIOValue(directory, auth, pdh)] :
367                 [{display: <EmptyValue />}];
368
369         case typeof input.type === 'object' &&
370             !(input.type instanceof Array) &&
371             input.type.type === 'enum':
372             const enumValue = (input as EnumCommandInputParameter).value;
373
374             return enumValue !== undefined ?
375                 [{ display: <pre>{(input as EnumCommandInputParameter).value || ''}</pre> }] :
376                 [{display: <EmptyValue />}];
377
378         case isArrayOfType(input, CWLType.STRING):
379             const strArray = (input as StringArrayCommandInputParameter).value || [];
380             return strArray.length ?
381                 [{ display: <>{((input as StringArrayCommandInputParameter).value || []).map((val) => <Chip label={val} />)}</> }] :
382                 [{display: <EmptyValue />}];
383
384         case isArrayOfType(input, CWLType.INT):
385         case isArrayOfType(input, CWLType.LONG):
386             const intArray = (input as IntArrayCommandInputParameter).value || [];
387
388             return intArray.length ?
389                 [{ display: <>{((input as IntArrayCommandInputParameter).value || []).map((val) => <Chip label={val} />)}</> }] :
390                 [{display: <EmptyValue />}];
391
392         case isArrayOfType(input, CWLType.FLOAT):
393         case isArrayOfType(input, CWLType.DOUBLE):
394             const floatArray = (input as FloatArrayCommandInputParameter).value || [];
395
396             return floatArray.length ?
397                 [{ display: <>{floatArray.map((val) => <Chip label={val} />)}</> }] :
398                 [{display: <EmptyValue />}];
399
400         case isArrayOfType(input, CWLType.FILE):
401             const fileArrayMainFile = ((input as FileArrayCommandInputParameter).value || []);
402             const fileArraySecondaryFiles = fileArrayMainFile.map((file) => (
403                 ((file as unknown) as FileWithSecondaryFiles)?.secondaryFiles || []
404             )).reduce((acc: File[], params: File[]) => (acc.concat(params)), []);
405
406             const fileArrayFiles = [
407                 ...fileArrayMainFile,
408                 ...fileArraySecondaryFiles
409             ];
410
411             return fileArrayFiles.length ?
412                 fileArrayFiles.map(file => fileToProcessIOValue(file, auth, pdh)) :
413                 [{display: <EmptyValue />}];
414
415         case isArrayOfType(input, CWLType.DIRECTORY):
416             const directories = (input as DirectoryArrayCommandInputParameter).value || [];
417
418             return directories.length ?
419                 directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) :
420                 [{display: <EmptyValue />}];
421
422         default:
423             return [];
424     }
425 };
426
427 const getKeepUrl = (file: File | Directory, pdh?: string): string => {
428     const isKeepUrl = file.location?.startsWith('keep:') || false;
429     const keepUrl = isKeepUrl ? file.location : pdh ? `keep:${pdh}/${file.location}` : file.location;
430     return keepUrl || '';
431 };
432
433 interface KeepUrlProps {
434     auth: AuthState;
435     res: File | Directory;
436     pdh?: string;
437 }
438
439 const KeepUrlBase = withStyles(styles)(({auth, res, pdh, classes}: KeepUrlProps & WithStyles<CssRules>) => {
440     const keepUrl = getKeepUrl(res, pdh);
441     const pdhUrl = keepUrl ? keepUrl.split('/').slice(0, 1)[0] : '';
442     // Passing a pdh always returns a relative wb2 collection url
443     const pdhWbPath = getNavUrl(pdhUrl.replace('keep:', ''), auth);
444     return pdhUrl && pdhWbPath ?
445         <Tooltip title={"View collection in Workbench"}><RouterLink to={pdhWbPath} className={classes.keepLink}>{pdhUrl}</RouterLink></Tooltip> :
446         <></>;
447 });
448
449 const KeepUrlPath = withStyles(styles)(({auth, res, pdh, classes}: KeepUrlProps & WithStyles<CssRules>) => {
450     const keepUrl = getKeepUrl(res, pdh);
451     const keepUrlParts = keepUrl ? keepUrl.split('/') : [];
452     const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join('/') : '';
453
454     const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
455     return keepUrlPath && keepUrlPathNav ?
456         <Tooltip title={"View in keep-web"}><MuiLink className={classes.keepLink} onClick={() => handleClick(keepUrlPathNav)}>{keepUrlPath}</MuiLink></Tooltip> :
457         // Show No value for root collection io that lacks path part
458         <EmptyValue />;
459 });
460
461 const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
462     let keepUrl = getKeepUrl(file, pdh).replace('keep:', '');
463     return (getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl));
464 };
465
466 const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
467     const keepUrl = getKeepUrl(file, pdh).replace('keep:', '');
468     return getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl);
469 };
470
471 const isFileImage = (basename?: string): boolean => {
472     return basename ? (mime.getType(basename) || "").startsWith('image/') : false;
473 };
474
475 const normalizeDirectoryLocation = (directory: Directory): Directory => {
476     if (!directory.location) {
477         return directory;
478     }
479     return {
480         ...directory,
481         location: (directory.location || '').endsWith('/') ? directory.location : directory.location + '/',
482     };
483 };
484
485 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
486     const normalizedDirectory = normalizeDirectoryLocation(directory);
487     return {
488         display: <span>
489             <KeepUrlBase auth={auth} res={normalizedDirectory} pdh={pdh}/>/<KeepUrlPath auth={auth} res={normalizedDirectory} pdh={pdh}/>
490         </span>,
491     };
492 };
493
494 const fileToProcessIOValue = (file: File, auth: AuthState, pdh?: string): ProcessIOValue => {
495     return {
496         display: <span>
497             <KeepUrlBase auth={auth} res={file} pdh={pdh}/>/<KeepUrlPath auth={auth} res={file} pdh={pdh}/>
498         </span>,
499         imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
500     }
501 };
502
503 const EmptyValue = withStyles(styles)(
504     ({classes}: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>
505 );