1 // Copyright (C) The Arvados Authors. All rights reserved.
3 // SPDX-License-Identifier: AGPL-3.0
5 import React, { ReactElement, useState } from 'react';
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';
31 BooleanCommandInputParameter,
32 CommandInputParameter,
35 DirectoryArrayCommandInputParameter,
36 DirectoryCommandInputParameter,
37 EnumCommandInputParameter,
38 FileArrayCommandInputParameter,
39 FileCommandInputParameter,
40 FloatArrayCommandInputParameter,
41 FloatCommandInputParameter,
42 IntArrayCommandInputParameter,
43 IntCommandInputParameter,
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';
63 type CssRules = 'card' | 'content' | 'title' | 'header' | 'avatar' | 'iconHeader' | 'tableWrapper' | 'tableRoot' | 'paramValue' | 'keepLink' | 'imagePreview' | 'valArray' | 'emptyValue';
65 const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
70 paddingTop: theme.spacing.unit,
71 paddingBottom: theme.spacing.unit,
75 color: theme.customs.colors.green700,
78 alignSelf: 'flex-start',
79 paddingTop: theme.spacing.unit * 0.5
82 padding: theme.spacing.unit * 1.0,
83 paddingTop: theme.spacing.unit * 0.5,
85 paddingBottom: theme.spacing.unit * 1,
90 paddingTop: theme.spacing.unit * 0.5
100 alignItems: 'flex-start',
101 flexDirection: 'column',
104 color: theme.palette.primary.main,
105 textDecoration: 'none',
106 overflowWrap: 'break-word',
112 marginBottom: theme.spacing.unit,
123 color: theme.customs.colors.grey500,
127 export enum ProcessIOCardType {
131 export interface ProcessIOCardDataProps {
132 label: ProcessIOCardType;
133 params: ProcessIOParameter[];
135 mounts?: InputCollectionMount[];
139 type ProcessIOCardProps = ProcessIOCardDataProps & WithStyles<CssRules> & MPVPanelProps;
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) => {
148 return <Card className={classes.card} data-cy="process-io-card">
150 className={classes.header}
152 content: classes.title,
153 avatar: classes.avatar,
155 avatar={<ProcessIcon className={classes.iconHeader} />}
157 <Typography noWrap variant='h6' color='inherit'>
164 <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
165 <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
169 <CardContent className={classes.content}>
171 <Tabs value={tabState} onChange={handleChange} variant="fullWidth">
172 <Tab label="Preview" />
174 {label === ProcessIOCardType.INPUT && <Tab label="Input Mounts" />}
175 {label === ProcessIOCardType.OUTPUT && <Tab label="Output Collection" />}
177 {tabState === 0 && <div className={classes.tableWrapper}>
179 <ProcessIOPreview data={params} /> :
180 <Grid container item alignItems='center' justify='center'>
181 <DefaultView messages={["No parameters found"]} icon={InfoIcon} />
184 {tabState === 1 && <div className={classes.tableWrapper}>
186 <ProcessIORaw data={raw || params} /> :
187 <Grid container item alignItems='center' justify='center'>
188 <DefaultView messages={["No parameters found"]} icon={InfoIcon} />
191 {tabState === 2 && <div className={classes.tableWrapper}>
192 {label === ProcessIOCardType.INPUT && <ProcessInputMounts mounts={mounts || []} />}
193 {label === ProcessIOCardType.OUTPUT && <ProcessOutputCollectionFiles isWritable={false} currentItemUuid={outputUuid} />}
201 export type ProcessIOValue = {
202 display: ReactElement<any, any>;
206 export type ProcessIOParameter = {
209 value: ProcessIOValue[];
212 interface ProcessIOPreviewDataProps {
213 data: ProcessIOParameter[];
216 type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
218 const ProcessIOPreview = withStyles(styles)(
219 ({ classes, data }: ProcessIOPreviewProps) =>
220 <Table className={classes.tableRoot} aria-label="Process IO Preview">
223 <TableCell>Label</TableCell>
224 <TableCell>Description</TableCell>
225 <TableCell>Value</TableCell>
229 {data.map((param: ProcessIOParameter) => {
230 return <TableRow key={param.id}>
231 <TableCell component="th" scope="row">
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}>
249 const handleClick = (url) => {
250 window.open(url, '_blank');
253 const ProcessIORaw = withStyles(styles)(
254 ({ data }: ProcessIOPreviewProps) =>
255 <Paper elevation={0}>
257 {JSON.stringify(data, null, 2)}
262 interface ProcessInputMountsDataProps {
263 mounts: InputCollectionMount[];
266 type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
268 const ProcessInputMounts = withStyles(styles)(connect((state: RootState) => ({
270 }))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
271 <Table className={classes.tableRoot} aria-label="Process Input Mounts">
274 <TableCell>Path</TableCell>
275 <TableCell>Portable Data Hash</TableCell>
279 {mounts.map(mount => (
280 <TableRow key={mount.path}>
281 <TableCell><pre>{mount.path}</pre></TableCell>
283 <RouterLink to={getNavUrl(mount.pdh, auth)} className={classes.keepLink}>{mount.pdh}</RouterLink>
291 type FileWithSecondaryFiles = {
292 secondaryFiles: File[];
295 export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
297 case isPrimitiveOfType(input, CWLType.BOOLEAN):
298 const boolValue = (input as BooleanCommandInputParameter).value;
300 return boolValue !== undefined &&
301 !(Array.isArray(boolValue) && boolValue.length === 0) ?
302 [{display: <pre>{String(boolValue)}</pre> }] :
303 [{display: <EmptyValue />}];
305 case isPrimitiveOfType(input, CWLType.INT):
306 case isPrimitiveOfType(input, CWLType.LONG):
307 const intValue = (input as IntCommandInputParameter).value;
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 />}];
315 case isPrimitiveOfType(input, CWLType.FLOAT):
316 case isPrimitiveOfType(input, CWLType.DOUBLE):
317 const floatValue = (input as FloatCommandInputParameter).value;
319 return floatValue !== undefined &&
320 !(Array.isArray(floatValue) && floatValue.length === 0) ?
321 [{display: <pre>{String(floatValue)}</pre> }]:
322 [{display: <EmptyValue />}];
324 case isPrimitiveOfType(input, CWLType.STRING):
325 const stringValue = (input as StringCommandInputParameter).value || undefined;
327 return stringValue !== undefined &&
328 !(Array.isArray(stringValue) && stringValue.length === 0) ?
329 [{display: <pre>{stringValue}</pre> }] :
330 [{display: <EmptyValue />}];
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 || [];
337 ...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []),
341 return files.length ?
342 files.map(file => fileToProcessIOValue(file, auth, pdh)) :
343 [{display: <EmptyValue />}];
345 case isPrimitiveOfType(input, CWLType.DIRECTORY):
346 const directory = (input as DirectoryCommandInputParameter).value;
348 return directory !== undefined &&
349 !(Array.isArray(directory) && directory.length === 0) ?
350 [directoryToProcessIOValue(directory, auth, pdh)] :
351 [{display: <EmptyValue />}];
353 case typeof input.type === 'object' &&
354 !(input.type instanceof Array) &&
355 input.type.type === 'enum':
356 const enumValue = (input as EnumCommandInputParameter).value;
358 return enumValue !== undefined ?
359 [{ display: <pre>{(input as EnumCommandInputParameter).value || ''}</pre> }] :
360 [{display: <EmptyValue />}];
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 />}];
368 case isArrayOfType(input, CWLType.INT):
369 case isArrayOfType(input, CWLType.LONG):
370 const intArray = (input as IntArrayCommandInputParameter).value || [];
372 return intArray.length ?
373 [{ display: <>{((input as IntArrayCommandInputParameter).value || []).map((val) => <Chip label={val} />)}</> }] :
374 [{display: <EmptyValue />}];
376 case isArrayOfType(input, CWLType.FLOAT):
377 case isArrayOfType(input, CWLType.DOUBLE):
378 const floatArray = (input as FloatArrayCommandInputParameter).value || [];
380 return floatArray.length ?
381 [{ display: <>{floatArray.map((val) => <Chip label={val} />)}</> }] :
382 [{display: <EmptyValue />}];
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)), []);
390 const fileArrayFiles = [
391 ...fileArrayMainFile,
392 ...fileArraySecondaryFiles
395 return fileArrayFiles.length ?
396 fileArrayFiles.map(file => fileToProcessIOValue(file, auth, pdh)) :
397 [{display: <EmptyValue />}];
399 case isArrayOfType(input, CWLType.DIRECTORY):
400 const directories = (input as DirectoryArrayCommandInputParameter).value || [];
402 return directories.length ?
403 directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) :
404 [{display: <EmptyValue />}];
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 || '';
417 interface KeepUrlProps {
419 res: File | Directory;
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> :
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('/') : '';
438 const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
439 return keepUrlPath && keepUrlPathNav ?
440 <MuiLink className={classes.keepLink} onClick={() => handleClick(keepUrlPathNav)}>{keepUrlPath}</MuiLink> :
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));
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);
454 const isFileImage = (basename?: string): boolean => {
455 return basename ? (mime.getType(basename) || "").startsWith('image/') : false;
458 const normalizeDirectoryLocation = (directory: Directory): Directory => {
459 if (!directory.location) {
464 location: (directory.location || '').endsWith('/') ? directory.location : directory.location + '/',
468 const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
469 const normalizedDirectory = normalizeDirectoryLocation(directory);
472 <KeepUrlBase auth={auth} res={normalizedDirectory} pdh={pdh}/>/<KeepUrlPath auth={auth} res={normalizedDirectory} pdh={pdh}/>
477 const fileToProcessIOValue = (file: File, auth: AuthState, pdh?: string): ProcessIOValue => {
480 <KeepUrlBase auth={auth} res={file} pdh={pdh}/>/<KeepUrlPath auth={auth} res={file} pdh={pdh}/>
482 imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
486 const EmptyValue = withStyles(styles)(
487 ({classes}: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>