16073: Refactor process io file processing
[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, { 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     Link,
25 } from '@material-ui/core';
26 import { ArvadosTheme } from 'common/custom-theme';
27 import { CloseIcon, ProcessIcon } from 'components/icon/icon';
28 import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
29 import {
30   BooleanCommandInputParameter,
31   CommandInputParameter,
32   CWLType,
33   Directory,
34   DirectoryArrayCommandInputParameter,
35   DirectoryCommandInputParameter,
36   EnumCommandInputParameter,
37   FileArrayCommandInputParameter,
38   FileCommandInputParameter,
39   FloatArrayCommandInputParameter,
40   FloatCommandInputParameter,
41   IntArrayCommandInputParameter,
42   IntCommandInputParameter,
43   isArrayOfType,
44   isPrimitiveOfType,
45   StringArrayCommandInputParameter,
46   StringCommandInputParameter,
47 } from "models/workflow";
48 import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
49 import { File } from 'models/workflow';
50 import { getInlineFileUrl } from 'views-components/context-menu/actions/helpers';
51 import { AuthState } from 'store/auth/auth-reducer';
52 import mime from 'mime';
53
54 type CssRules = 'card' | 'content' | 'title' | 'header' | 'avatar' | 'iconHeader' | 'tableWrapper' | 'tableRoot' | 'paramValue' | 'keepLink' | 'imagePreview';
55
56 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
57     card: {
58         height: '100%'
59     },
60     header: {
61         paddingTop: theme.spacing.unit,
62         paddingBottom: theme.spacing.unit,
63     },
64     iconHeader: {
65         fontSize: '1.875rem',
66         color: theme.customs.colors.green700,
67     },
68     avatar: {
69         alignSelf: 'flex-start',
70         paddingTop: theme.spacing.unit * 0.5
71     },
72     content: {
73         padding: theme.spacing.unit * 1.0,
74         paddingTop: theme.spacing.unit * 0.5,
75         '&:last-child': {
76             paddingBottom: theme.spacing.unit * 1,
77         }
78     },
79     title: {
80         overflow: 'hidden',
81         paddingTop: theme.spacing.unit * 0.5
82     },
83     tableWrapper: {
84         overflow: 'auto',
85     },
86     tableRoot: {
87         width: '100%',
88     },
89     paramValue: {
90         display: 'flex',
91         alignItems: 'center',
92     },
93     keepLink: {
94         cursor: 'pointer',
95     },
96     imagePreview: {
97         maxHeight: '15em',
98     },
99 });
100
101 export interface ProcessIOCardDataProps {
102     label: string;
103     params: ProcessIOParameter[];
104     raw?: any;
105 }
106
107 type ProcessIOCardProps = ProcessIOCardDataProps & WithStyles<CssRules> & MPVPanelProps;
108
109 export const ProcessIOCard = withStyles(styles)(
110     ({ classes, label, params, raw, doHidePanel, panelName }: ProcessIOCardProps) => {
111         const [tabState, setTabState] = useState(0);
112         const handleChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
113             setTabState(value);
114         }
115
116         return <Card className={classes.card}>
117             <CardHeader
118                 className={classes.header}
119                 classes={{
120                     content: classes.title,
121                     avatar: classes.avatar,
122                 }}
123                 avatar={<ProcessIcon className={classes.iconHeader} />}
124                 title={
125                     <Typography noWrap variant='h6' color='inherit'>
126                         {label}
127                     </Typography>
128                 }
129                 action={
130                     <div>
131                         { doHidePanel &&
132                         <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
133                             <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
134                         </Tooltip> }
135                     </div>
136                 } />
137             <CardContent className={classes.content}>
138                 <Tabs value={tabState} onChange={handleChange} variant="fullWidth">
139                     <Tab label="Preview" />
140                     <Tab label="Raw" />
141                 </Tabs>
142                 {tabState === 0 && <div className={classes.tableWrapper}>
143                     <ProcessIOPreview data={params} />
144                     </div>}
145                 {tabState === 1 && <div className={classes.tableWrapper}>
146                     <ProcessIORaw data={raw || params} />
147                     </div>}
148             </CardContent>
149         </Card>;
150     }
151 );
152
153 export type ProcessIOValue = {
154     display: string;
155     nav?: string;
156     imageUrl?: string;
157 }
158
159 export type ProcessIOParameter = {
160     id: string;
161     doc: string;
162     value: ProcessIOValue[];
163 }
164
165 interface ProcessIOPreviewDataProps {
166     data: ProcessIOParameter[];
167 }
168
169 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
170
171 const ProcessIOPreview = withStyles(styles)(
172     ({ classes, data }: ProcessIOPreviewProps) =>
173         <Table className={classes.tableRoot} aria-label="simple table">
174             <TableHead>
175                 <TableRow>
176                     <TableCell>Label</TableCell>
177                     <TableCell>Description</TableCell>
178                     <TableCell>Value</TableCell>
179                 </TableRow>
180             </TableHead>
181             <TableBody>
182                 {data.map((param: ProcessIOParameter) => {
183                     return <TableRow key={param.id}>
184                         <TableCell component="th" scope="row">
185                             {param.id}
186                         </TableCell>
187                         <TableCell>{param.doc}</TableCell>
188                         <TableCell>{param.value.map(val => (
189                             <Typography className={classes.paramValue}>
190                                 {val.imageUrl ? <img className={classes.imagePreview} src={val.imageUrl} alt="Inline Preview" /> : ""}
191                                 {val.nav ? <Link className={classes.keepLink} onClick={() => handleClick(val.nav)}>{val.display}</Link> : val.display}
192                             </Typography>
193                         ))}</TableCell>
194                     </TableRow>;
195                 })}
196             </TableBody>
197         </Table>
198 );
199
200 const handleClick = (url) => {
201     window.open(url, '_blank');
202 }
203
204 const ProcessIORaw = withStyles(styles)(
205     ({ data }: ProcessIOPreviewProps) =>
206         <Paper elevation={0}>
207             <pre>
208                 {JSON.stringify(data, null, 2)}
209             </pre>
210         </Paper>
211 );
212
213 // secondaryFiles File[] is not part of CommandOutputParameter so we pass in an extra param
214 export const getInputDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string, secondaryFiles: File[] = []): ProcessIOValue[] => {
215     switch (true) {
216         case isPrimitiveOfType(input, CWLType.BOOLEAN):
217             return [{display: String((input as BooleanCommandInputParameter).value)}];
218
219         case isPrimitiveOfType(input, CWLType.INT):
220         case isPrimitiveOfType(input, CWLType.LONG):
221             return [{display: String((input as IntCommandInputParameter).value)}];
222
223         case isPrimitiveOfType(input, CWLType.FLOAT):
224         case isPrimitiveOfType(input, CWLType.DOUBLE):
225             return [{display: String((input as FloatCommandInputParameter).value)}];
226
227         case isPrimitiveOfType(input, CWLType.STRING):
228             return [{display: (input as StringCommandInputParameter).value || ""}];
229
230         case isPrimitiveOfType(input, CWLType.FILE):
231             const mainFile = (input as FileCommandInputParameter).value;
232             const files = [
233                 ...(mainFile ? [mainFile] : []),
234                 ...secondaryFiles
235             ];
236             return files.map(file => fileToProcessIOValue(file, auth, pdh));
237
238         case isPrimitiveOfType(input, CWLType.DIRECTORY):
239             const directory = (input as DirectoryCommandInputParameter).value;
240             return directory ? [directoryToProcessIOValue(directory, auth, pdh)] : [];
241
242         case typeof input.type === 'object' &&
243             !(input.type instanceof Array) &&
244             input.type.type === 'enum':
245             return [{ display: (input as EnumCommandInputParameter).value || '' }];
246
247         case isArrayOfType(input, CWLType.STRING):
248             return [{ display: ((input as StringArrayCommandInputParameter).value || []).join(', ') }];
249
250         case isArrayOfType(input, CWLType.INT):
251         case isArrayOfType(input, CWLType.LONG):
252             return [{ display: ((input as IntArrayCommandInputParameter).value || []).join(', ') }];
253
254         case isArrayOfType(input, CWLType.FLOAT):
255         case isArrayOfType(input, CWLType.DOUBLE):
256             return [{ display: ((input as FloatArrayCommandInputParameter).value || []).join(', ') }];
257
258         case isArrayOfType(input, CWLType.FILE):
259             return ((input as FileArrayCommandInputParameter).value || [])
260                 .map(file => fileToProcessIOValue(file, auth, pdh));
261
262         case isArrayOfType(input, CWLType.DIRECTORY):
263             const directories = (input as DirectoryArrayCommandInputParameter).value || [];
264             return directories.map(directory => directoryToProcessIOValue(directory, auth, pdh));
265
266         default:
267             return [];
268     }
269 };
270
271 const getKeepUrl = (file: File | Directory, pdh?: string): string => {
272     const isKeepUrl = file.location?.startsWith('keep:') || false;
273     const keepUrl = isKeepUrl ? file.location : pdh ? `keep:${pdh}/${file.location}` : file.location;
274     return keepUrl || '';
275 };
276
277 const getNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
278     let keepUrl = getKeepUrl(file, pdh).replace('keep:', '');
279     return (getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl));
280 };
281
282 const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
283     const keepUrl = getKeepUrl(file, pdh).replace('keep:', '');
284     return getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl);
285 };
286
287 const isFileImage = (basename?: string): boolean => {
288     return basename ? (mime.getType(basename) || "").startsWith('image/') : false;
289 };
290
291 const normalizeDirectoryLocation = (directory: Directory): Directory => ({
292     ...directory,
293     location: (directory.location || '').endsWith('/') ? directory.location : directory.location + '/',
294 });
295
296 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
297     const normalizedDirectory = normalizeDirectoryLocation(directory);
298     return {
299         display: getKeepUrl(normalizedDirectory, pdh),
300         nav: getNavUrl(auth, normalizedDirectory, pdh),
301     };
302 };
303
304 const fileToProcessIOValue = (file: File, auth: AuthState, pdh?: string): ProcessIOValue => ({
305     display: getKeepUrl(file, pdh),
306     nav: getNavUrl(auth, file, pdh),
307     imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
308 });