16073: Fix keep path for directory pdh links, refactor
[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
237             return files.map(file => ({
238                 display: getKeepUrl(file, pdh),
239                 nav: getNavUrl(auth, file, pdh),
240                 imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
241             }));
242
243         case isPrimitiveOfType(input, CWLType.DIRECTORY):
244             const directory = (input as DirectoryCommandInputParameter).value;
245             return directory ? [directoryToProcessIOValue(directory, auth, pdh)] : [];
246
247         case typeof input.type === 'object' &&
248             !(input.type instanceof Array) &&
249             input.type.type === 'enum':
250             return [{ display: (input as EnumCommandInputParameter).value || '' }];
251
252         case isArrayOfType(input, CWLType.STRING):
253             return [{ display: ((input as StringArrayCommandInputParameter).value || []).join(', ') }];
254
255         case isArrayOfType(input, CWLType.INT):
256         case isArrayOfType(input, CWLType.LONG):
257             return [{ display: ((input as IntArrayCommandInputParameter).value || []).join(', ') }];
258
259         case isArrayOfType(input, CWLType.FLOAT):
260         case isArrayOfType(input, CWLType.DOUBLE):
261             return [{ display: ((input as FloatArrayCommandInputParameter).value || []).join(', ') }];
262
263         case isArrayOfType(input, CWLType.FILE):
264             return ((input as FileArrayCommandInputParameter).value || []).map(file => ({
265                 display: getKeepUrl(file, pdh),
266                 nav: getNavUrl(auth, file, pdh),
267                 imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
268             }));
269
270         case isArrayOfType(input, CWLType.DIRECTORY):
271             const directories = (input as DirectoryArrayCommandInputParameter).value || [];
272             return directories.map(directory => directoryToProcessIOValue(directory, auth, pdh));
273
274         default:
275             return [];
276     }
277 };
278
279 const getKeepUrl = (file: File | Directory, pdh?: string): string => {
280     const isKeepUrl = file.location?.startsWith('keep:') || false;
281     const keepUrl = isKeepUrl ? file.location : pdh ? `keep:${pdh}/${file.location}` : file.location;
282     return keepUrl || '';
283 };
284
285 const getNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
286     let keepUrl = getKeepUrl(file, pdh).replace('keep:', '');
287     return (getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl));
288 };
289
290 const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
291     const keepUrl = getKeepUrl(file, pdh).replace('keep:', '');
292     return getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl);
293 };
294
295 const isFileImage = (basename?: string): boolean => {
296     return basename ? (mime.getType(basename) || "").startsWith('image/') : false;
297 };
298
299 const normalizeDirectoryLocation = (directory: Directory): Directory => ({
300     ...directory,
301     location: (directory.location || '').endsWith('/') ? directory.location : directory.location + '/',
302 });
303
304 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
305     const normalizedDirectory = normalizeDirectoryLocation(directory);
306     return {
307         display: getKeepUrl(normalizedDirectory, pdh),
308         nav: getNavUrl(auth, normalizedDirectory, pdh),
309     };
310 };