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