[arvados-workbench2] created: 2.4.0-156-g55acac75

git repository hosting git at public.arvados.org
Tue Jul 12 19:35:49 UTC 2022


        at  55acac755ba2516c63e902a11e90c6e3754e5c48 (commit)


commit 55acac755ba2516c63e902a11e90c6e3754e5c48
Author: Stephen Smith <stephen at curii.com>
Date:   Tue Jul 12 15:13:59 2022 -0400

    16073: Add process IO panels with image preview and raw view
    
    Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen at curii.com>

diff --git a/package.json b/package.json
index a8b3ee81..110d0e70 100644
--- a/package.json
+++ b/package.json
@@ -44,6 +44,7 @@
     "lodash.template": "4.5.0",
     "material-ui-pickers": "^2.2.4",
     "mem": "4.0.0",
+    "mime": "^3.0.0",
     "moment": "2.29.1",
     "parse-duration": "0.4.4",
     "prop-types": "15.7.2",
diff --git a/src/common/webdav.ts b/src/common/webdav.ts
index 93ec21cb..d4f904ae 100644
--- a/src/common/webdav.ts
+++ b/src/common/webdav.ts
@@ -30,6 +30,12 @@ export class WebDAV {
             data
         })
 
+    get = (url: string, config: WebDAVRequestConfig = {}) =>
+        this.request({
+            ...config, url,
+            method: 'GET'
+        })
+
     upload = (url: string, files: File[], config: WebDAVRequestConfig = {}) => {
         return Promise.all(
             files.map(file => this.request({
@@ -88,7 +94,7 @@ export class WebDAV {
                 Object.assign(window, { cancelTokens: {} });
             }
 
-            (window as any).cancelTokens[config.url] = () => { 
+            (window as any).cancelTokens[config.url] = () => {
                 resolve(r);
                 r.abort();
             }
@@ -138,4 +144,4 @@ interface RequestConfig {
     headers?: { [key: string]: string };
     data?: any;
     onUploadProgress?: (event: ProgressEvent) => void;
-}
\ No newline at end of file
+}
diff --git a/src/models/workflow.ts b/src/models/workflow.ts
index 6d21dbc7..12f253ac 100644
--- a/src/models/workflow.ts
+++ b/src/models/workflow.ts
@@ -156,6 +156,10 @@ export const getInputLabel = (input: CommandInputParameter) => {
     return `${input.label || input.id.split('/').pop()}`;
 };
 
+export const getInputId = (input: CommandInputParameter) => {
+    return `${input.id.split('/').pop()}`;
+};
+
 export const isRequiredInput = ({ type }: CommandInputParameter) => {
     if (type instanceof Array) {
         for (const t of type) {
diff --git a/src/services/collection-service/collection-service.ts b/src/services/collection-service/collection-service.ts
index 92e4dfba..e2c420d8 100644
--- a/src/services/collection-service/collection-service.ts
+++ b/src/services/collection-service/collection-service.ts
@@ -107,6 +107,10 @@ export class CollectionService extends TrashableResourceService<CollectionResour
         };
     }
 
+    async getFileContents(file: CollectionFile) {
+        return (await this.webdavClient.get(`c=${file.id}`)).response;
+    }
+
     private async uploadFile(collectionUuid: string, file: File, fileId: number, onProgress: UploadProgress = () => { return; }, targetLocation: string = '') {
         const fileURL = `c=${targetLocation !== '' ? targetLocation : collectionUuid}/${file.name}`.replace('//', '/');
         const requestConfig = {
diff --git a/src/store/process-panel/process-panel-actions.ts b/src/store/process-panel/process-panel-actions.ts
index e77c300d..d21b9b83 100644
--- a/src/store/process-panel/process-panel-actions.ts
+++ b/src/store/process-panel/process-panel-actions.ts
@@ -14,6 +14,7 @@ import { SnackbarKind } from '../snackbar/snackbar-actions';
 import { showWorkflowDetails } from 'store/workflow-panel/workflow-panel-actions';
 import { loadSubprocessPanel } from "../subprocess-panel/subprocess-panel-actions";
 import { initProcessLogsPanel, processLogsPanelActions } from "store/process-logs-panel/process-logs-panel-actions";
+import { CollectionFile } from "models/collection-file";
 
 export const processPanelActions = unionize({
     SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: ofType<string>(),
@@ -45,6 +46,26 @@ export const navigateToOutput = (uuid: string) =>
         }
     };
 
+export const loadOutputs = (uuid: string, setOutputs) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const files = await services.collectionService.files(uuid);
+            const collection = await services.collectionService.get(uuid);
+            const outputFile = files.find((file) => file.name === 'cwl.output.json') as CollectionFile | undefined;
+            let outputData = outputFile ? await services.collectionService.getFileContents(outputFile) : undefined;
+            if ((outputData = JSON.parse(outputData)) && collection.portableDataHash) {
+                setOutputs({
+                    rawOutputs: outputData,
+                    pdh: collection.portableDataHash,
+                });
+            } else {
+                setOutputs({});
+            }
+        } catch {
+            setOutputs({});
+        }
+    };
+
 export const openWorkflow = (uuid: string) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch<any>(navigateToWorkflows);
diff --git a/src/store/processes/processes-actions.ts b/src/store/processes/processes-actions.ts
index 213e292b..ddf71e77 100644
--- a/src/store/processes/processes-actions.ts
+++ b/src/store/processes/processes-actions.ts
@@ -17,7 +17,7 @@ import { initialize } from "redux-form";
 import { RUN_PROCESS_BASIC_FORM, RunProcessBasicFormData } from "views/run-process-panel/run-process-basic-form";
 import { RunProcessAdvancedFormData, RUN_PROCESS_ADVANCED_FORM } from "views/run-process-panel/run-process-advanced-form";
 import { MOUNT_PATH_CWL_WORKFLOW, MOUNT_PATH_CWL_INPUT } from 'models/process';
-import { getWorkflow, getWorkflowInputs } from "models/workflow";
+import { CommandInputParameter, getWorkflow, getWorkflowInputs } from "models/workflow";
 import { ProjectResource } from "models/project";
 import { UserResource } from "models/user";
 
@@ -85,7 +85,7 @@ export const reRunProcess = (processUuid: string, workflowUuid: string) =>
         }
     };
 
-const getInputs = (data: any) => {
+export const getInputs = (data: any): CommandInputParameter[] => {
     if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; }
     const inputs = getWorkflowInputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
     return inputs ? inputs.map(
@@ -95,6 +95,7 @@ const getInputs = (data: any) => {
                 id: it.id,
                 label: it.label,
                 default: data.mounts[MOUNT_PATH_CWL_INPUT].content[it.id],
+                value: data.mounts[MOUNT_PATH_CWL_INPUT].content[it.id.split('/').pop()] || [],
                 doc: it.doc
             }
         )
diff --git a/src/views/process-panel/process-io-card.tsx b/src/views/process-panel/process-io-card.tsx
new file mode 100644
index 00000000..5540caed
--- /dev/null
+++ b/src/views/process-panel/process-io-card.tsx
@@ -0,0 +1,307 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { useState } from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardHeader,
+    IconButton,
+    CardContent,
+    Tooltip,
+    Typography,
+    Tabs,
+    Tab,
+    Table,
+    TableHead,
+    TableBody,
+    TableRow,
+    TableCell,
+    Paper,
+    Link,
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { CloseIcon, ProcessIcon } from 'components/icon/icon';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import {
+  BooleanCommandInputParameter,
+  CommandInputParameter,
+  CWLType,
+  Directory,
+  DirectoryArrayCommandInputParameter,
+  DirectoryCommandInputParameter,
+  EnumCommandInputParameter,
+  FileArrayCommandInputParameter,
+  FileCommandInputParameter,
+  FloatArrayCommandInputParameter,
+  FloatCommandInputParameter,
+  IntArrayCommandInputParameter,
+  IntCommandInputParameter,
+  isArrayOfType,
+  isPrimitiveOfType,
+  StringArrayCommandInputParameter,
+  StringCommandInputParameter,
+} from "models/workflow";
+import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
+import { File } from 'models/workflow';
+import { getInlineFileUrl } from 'views-components/context-menu/actions/helpers';
+import { AuthState } from 'store/auth/auth-reducer';
+import mime from 'mime';
+
+type CssRules = 'card' | 'content' | 'title' | 'header' | 'avatar' | 'iconHeader' | 'tableWrapper' | 'tableRoot' | 'paramValue' | 'keepLink' | 'imagePreview';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        height: '100%'
+    },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: theme.spacing.unit,
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.green700,
+    },
+    avatar: {
+        alignSelf: 'flex-start',
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    content: {
+        padding: theme.spacing.unit * 1.0,
+        paddingTop: theme.spacing.unit * 0.5,
+        '&:last-child': {
+            paddingBottom: theme.spacing.unit * 1,
+        }
+    },
+    title: {
+        overflow: 'hidden',
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    tableWrapper: {
+        overflow: 'auto',
+    },
+    tableRoot: {
+        width: '100%',
+    },
+    paramValue: {
+        display: 'flex',
+        alignItems: 'center',
+    },
+    keepLink: {
+        cursor: 'pointer',
+    },
+    imagePreview: {
+        maxHeight: '15em',
+    },
+});
+
+export interface ProcessIOCardDataProps {
+    label: string;
+    params: ProcessIOParameter[];
+    raw?: any;
+}
+
+type ProcessIOCardProps = ProcessIOCardDataProps & WithStyles<CssRules> & MPVPanelProps;
+
+export const ProcessIOCard = withStyles(styles)(
+    ({ classes, label, params, raw, doHidePanel, panelName }: ProcessIOCardProps) => {
+        const [tabState, setTabState] = useState(0);
+        const handleChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+            setTabState(value);
+        }
+
+        return <Card className={classes.card}>
+            <CardHeader
+                className={classes.header}
+                classes={{
+                    content: classes.title,
+                    avatar: classes.avatar,
+                }}
+                avatar={<ProcessIcon className={classes.iconHeader} />}
+                title={
+                    <Typography noWrap variant='h6' color='inherit'>
+                        {label}
+                    </Typography>
+                }
+                action={
+                    <div>
+                        { doHidePanel &&
+                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
+                        </Tooltip> }
+                    </div>
+                } />
+            <CardContent className={classes.content}>
+                <Tabs value={tabState} onChange={handleChange} variant="fullWidth">
+                    <Tab label="Preview" />
+                    <Tab label="Raw" />
+                </Tabs>
+                {tabState === 0 && <div className={classes.tableWrapper}>
+                    <ProcessIOPreview data={params} />
+                    </div>}
+                {tabState === 1 && <div className={classes.tableWrapper}>
+                    <ProcessIORaw data={raw || params} />
+                    </div>}
+            </CardContent>
+        </Card>;
+    }
+);
+
+export type ProcessIOValue = {
+    display: string;
+    nav?: string;
+    imageUrl?: string;
+}
+
+export type ProcessIOParameter = {
+    id: string;
+    doc: string;
+    value: ProcessIOValue[];
+}
+
+interface ProcessIOPreviewDataProps {
+    data: ProcessIOParameter[];
+}
+
+type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
+
+const ProcessIOPreview = withStyles(styles)(
+    ({ classes, data }: ProcessIOPreviewProps) =>
+        <Table className={classes.tableRoot} aria-label="simple table">
+            <TableHead>
+                <TableRow>
+                    <TableCell>Label</TableCell>
+                    <TableCell>Description</TableCell>
+                    <TableCell>Value</TableCell>
+                </TableRow>
+            </TableHead>
+            <TableBody>
+                {data.map((param: ProcessIOParameter) => {
+                    return <TableRow key={param.id}>
+                        <TableCell component="th" scope="row">
+                            {param.id}
+                        </TableCell>
+                        <TableCell>{param.doc}</TableCell>
+                        <TableCell>{param.value.map(val => (
+                            <Typography className={classes.paramValue}>
+                                {val.imageUrl ? <img className={classes.imagePreview} src={val.imageUrl} alt="Inline Preview" /> : ""}
+                                {val.nav ? <Link className={classes.keepLink} onClick={() => handleClick(val.nav)}>{val.display}</Link> : val.display}
+                            </Typography>
+                        ))}</TableCell>
+                    </TableRow>;
+                })}
+            </TableBody>
+        </Table>
+);
+
+const handleClick = (url) => {
+    window.open(url, '_blank');
+}
+
+const ProcessIORaw = withStyles(styles)(
+    ({ data }: ProcessIOPreviewProps) =>
+        <Paper elevation={0}>
+            <pre>
+                {JSON.stringify(data, null, 2)}
+            </pre>
+        </Paper>
+);
+
+// secondaryFiles File[] is not part of CommandOutputParameter so we pass in an extra param
+export const getInputDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string, secondaryFiles: File[] = []): ProcessIOValue[] => {
+    switch (true) {
+        case isPrimitiveOfType(input, CWLType.BOOLEAN):
+            return [{display: String((input as BooleanCommandInputParameter).value)}];
+
+        case isPrimitiveOfType(input, CWLType.INT):
+        case isPrimitiveOfType(input, CWLType.LONG):
+            return [{display: String((input as IntCommandInputParameter).value)}];
+
+        case isPrimitiveOfType(input, CWLType.FLOAT):
+        case isPrimitiveOfType(input, CWLType.DOUBLE):
+            return [{display: String((input as FloatCommandInputParameter).value)}];
+
+        case isPrimitiveOfType(input, CWLType.STRING):
+            return [{display: (input as StringCommandInputParameter).value || ""}];
+
+        case isPrimitiveOfType(input, CWLType.FILE):
+            const mainFile = (input as FileCommandInputParameter).value;
+            const files = [
+                ...(mainFile ? [mainFile] : []),
+                ...secondaryFiles
+            ];
+
+            return files.map(file => ({
+                display: getKeepUrl(file, pdh),
+                nav: getNavUrl(auth, file, pdh),
+                imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
+            }));
+
+        case isPrimitiveOfType(input, CWLType.DIRECTORY):
+            const directory = (input as DirectoryCommandInputParameter).value;
+            return directory ? [{
+                display: getKeepUrl(directory, pdh),
+                nav: getNavUrl(auth, directory, pdh),
+            }] : [];
+
+        case typeof input.type === 'object' &&
+            !(input.type instanceof Array) &&
+            input.type.type === 'enum':
+            return [{ display: (input as EnumCommandInputParameter).value || '' }];
+
+        case isArrayOfType(input, CWLType.STRING):
+            return [{ display: ((input as StringArrayCommandInputParameter).value || []).join(', ') }];
+
+        case isArrayOfType(input, CWLType.INT):
+        case isArrayOfType(input, CWLType.LONG):
+            return [{ display: ((input as IntArrayCommandInputParameter).value || []).join(', ') }];
+
+        case isArrayOfType(input, CWLType.FLOAT):
+        case isArrayOfType(input, CWLType.DOUBLE):
+            return [{ display: ((input as FloatArrayCommandInputParameter).value || []).join(', ') }];
+
+        case isArrayOfType(input, CWLType.FILE):
+            return ((input as FileArrayCommandInputParameter).value || []).map(file => ({
+                display: getKeepUrl(file, pdh),
+                nav: getNavUrl(auth, file, pdh),
+                imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
+            }));
+
+        case isArrayOfType(input, CWLType.DIRECTORY):
+            const directories = (input as DirectoryArrayCommandInputParameter).value || [];
+            return directories.map(directory => ({
+                display: getKeepUrl(directory, pdh),
+                nav: getNavUrl(auth, directory, pdh),
+            }));
+
+        default:
+            return [{display: ''}];
+    }
+};
+
+const getKeepUrl = (file: File | Directory, pdh?: string): string => {
+    const isKeepUrl = file.location?.startsWith('keep:') || false;
+    const keepUrl = isKeepUrl ? file.location : pdh ? `keep:${pdh}/${file.location}` : file.location;
+    return keepUrl || '';
+}
+
+const getNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
+    let keepUrl = getKeepUrl(file, pdh).replace('keep:', '');
+    // Directory urls lack a trailing slash
+    if (!keepUrl.endsWith('/')) {
+        keepUrl += '/';
+    }
+    return (getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl));
+}
+
+const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
+    const keepUrl = getKeepUrl(file, pdh).replace('keep:', '');
+    return getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl);
+}
+
+const isFileImage = (basename?: string): boolean => {
+    return basename ? (mime.getType(basename) || "").startsWith('image/') : false;
+}
diff --git a/src/views/process-panel/process-panel-root.tsx b/src/views/process-panel/process-panel-root.tsx
index 4f95d0d8..e130054b 100644
--- a/src/views/process-panel/process-panel-root.tsx
+++ b/src/views/process-panel/process-panel-root.tsx
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
+import React, { useState } from 'react';
 import { Grid, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
 import { DefaultView } from 'components/default-view/default-view';
 import { ProcessIcon } from 'components/icon/icon';
@@ -12,9 +12,15 @@ import { SubprocessFilterDataProps } from 'components/subprocess-filter/subproce
 import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
 import { ArvadosTheme } from 'common/custom-theme';
 import { ProcessDetailsCard } from './process-details-card';
+import { getInputDisplayValue, ProcessIOCard, ProcessIOParameter } from './process-io-card';
+
 import { getProcessPanelLogs, ProcessLogsPanel } from 'store/process-logs-panel/process-logs-panel';
 import { ProcessLogsCard } from './process-log-card';
 import { FilterOption } from 'views/process-panel/process-log-form';
+import { getInputs } from 'store/processes/processes-actions';
+import { CommandInputParameter, getInputId } from 'models/workflow';
+import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
+import { AuthState } from 'store/auth/auth-reducer';
 
 type CssRules = 'root';
 
@@ -29,6 +35,7 @@ export interface ProcessPanelRootDataProps {
     subprocesses: Array<Process>;
     filters: Array<SubprocessFilterDataProps>;
     processLogsPanel: ProcessLogsPanel;
+    auth: AuthState;
 }
 
 export interface ProcessPanelRootActionProps {
@@ -38,19 +45,58 @@ export interface ProcessPanelRootActionProps {
     onLogFilterChange: (filter: FilterOption) => void;
     navigateToLog: (uuid: string) => void;
     onLogCopyToClipboard: (uuid: string) => void;
+    fetchOutputs: (uuid: string, fetchOutputs) => void;
 }
 
 export type ProcessPanelRootProps = ProcessPanelRootDataProps & ProcessPanelRootActionProps & WithStyles<CssRules>;
 
+type OutputDetails = {
+    rawOutputs?: any;
+    pdh?: string;
+}
+
 const panelsData: MPVPanelState[] = [
     {name: "Details"},
     {name: "Logs", visible: true},
+    {name: "Inputs"},
+    {name: "Outputs"},
     {name: "Subprocesses"},
 ];
 
 export const ProcessPanelRoot = withStyles(styles)(
-    ({ process, processLogsPanel, ...props }: ProcessPanelRootProps) =>
-    process
+    ({ process, auth, processLogsPanel, fetchOutputs, ...props }: ProcessPanelRootProps) => {
+
+    const [outputDetails, setOutputs] = useState<OutputDetails>({});
+    const [rawInputs, setInputs] = useState<CommandInputParameter[]>([]);
+
+
+    const [processedOutputs, setProcessedOutputs] = useState<ProcessIOParameter[]>([]);
+    const [processedInputs, setProcessedInputs] = useState<ProcessIOParameter[]>([]);
+
+    const outputUuid = process?.containerRequest.outputUuid;
+    const requestUuid = process?.containerRequest.uuid;
+
+    React.useEffect(() => {
+        if (outputUuid) {
+            fetchOutputs(outputUuid, setOutputs);
+        }
+    }, [outputUuid, fetchOutputs]);
+
+    React.useEffect(() => {
+        if (outputDetails.rawOutputs) {
+            setProcessedOutputs(formatOutputData(outputDetails.rawOutputs, outputDetails.pdh, auth));
+        }
+    }, [outputDetails, auth]);
+
+    React.useEffect(() => {
+        if (process) {
+            const rawInputs = getInputs(process.containerRequest);
+            setInputs(rawInputs);
+            setProcessedInputs(formatInputData(rawInputs, auth));
+        }
+    }, [requestUuid, auth, process]);
+
+    return process
         ? <MPVContainer className={props.classes.root} spacing={8} panelStates={panelsData}  justify-content="flex-start" direction="column" wrap="nowrap">
             <MPVPanelContent forwardProps xs="auto" data-cy="process-details">
                 <ProcessDetailsCard
@@ -75,6 +121,20 @@ export const ProcessPanelRoot = withStyles(styles)(
                     navigateToLog={props.navigateToLog}
                 />
             </MPVPanelContent>
+            <MPVPanelContent forwardProps xs="auto" data-cy="process-inputs">
+                <ProcessIOCard
+                    label="Inputs"
+                    params={processedInputs}
+                    raw={rawInputs}
+                 />
+            </MPVPanelContent>
+            <MPVPanelContent forwardProps xs="auto" data-cy="process-outputs">
+                <ProcessIOCard
+                    label="Outputs"
+                    params={processedOutputs}
+                    raw={outputDetails.rawOutputs}
+                 />
+            </MPVPanelContent>
             <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-children">
                 <SubprocessPanel />
             </MPVPanelContent>
@@ -86,4 +146,39 @@ export const ProcessPanelRoot = withStyles(styles)(
             <DefaultView
                 icon={ProcessIcon}
                 messages={['Process not found']} />
-        </Grid>);
+        </Grid>;
+    }
+);
+
+const formatInputData = (inputs: CommandInputParameter[], auth: AuthState): ProcessIOParameter[] => {
+    return inputs.map(input => {
+        return {
+            id: getInputId(input),
+            doc: input.label || "",
+            value: getInputDisplayValue(auth, input)
+        };
+    });
+};
+
+const formatOutputData = (rawData: any, pdh: string | undefined, auth: AuthState): ProcessIOParameter[] => {
+    if (!rawData) { return []; }
+    return Object.keys(rawData).map((id): ProcessIOParameter => {
+        const multiple = rawData[id].length > 0;
+        const outputArray = multiple ? rawData[id] : [rawData[id]];
+        return {
+            id,
+            doc: outputArray.map((outputParam: CommandOutputParameter) => (outputParam.doc))
+                        // Doc can be string or string[], concat conveniently works with both
+                        .reduce((acc: string[], input: string | string[]) => (acc.concat(input)), [])
+                        // Remove undefined and empty doc strings
+                        .filter(str => str)
+                        .join(", "),
+            value: outputArray.map(outputParam => getInputDisplayValue(auth, {
+                    type: outputParam.class,
+                    value: outputParam,
+                    ...outputParam
+                }, pdh, outputParam.secondaryFiles))
+                .reduce((acc: ProcessIOParameter[], params: ProcessIOParameter[]) => (acc.concat(params)), [])
+        };
+    });
+};
diff --git a/src/views/process-panel/process-panel.tsx b/src/views/process-panel/process-panel.tsx
index de6b13b3..348222f6 100644
--- a/src/views/process-panel/process-panel.tsx
+++ b/src/views/process-panel/process-panel.tsx
@@ -18,13 +18,14 @@ import {
 } from 'store/process-panel/process-panel';
 import { groupBy } from 'lodash';
 import {
+    loadOutputs,
     toggleProcessPanelFilter,
 } from 'store/process-panel/process-panel-actions';
 import { cancelRunningWorkflow } from 'store/processes/processes-actions';
 import { navigateToLogCollection, setProcessLogsPanelFilter } from 'store/process-logs-panel/process-logs-panel-actions';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 
-const mapStateToProps = ({ router, resources, processPanel, processLogsPanel }: RootState): ProcessPanelRootDataProps => {
+const mapStateToProps = ({ router, auth, resources, processPanel, processLogsPanel }: RootState): ProcessPanelRootDataProps => {
     const uuid = getProcessPanelCurrentUuid(router) || '';
     const subprocesses = getSubprocesses(uuid)(resources);
     return {
@@ -32,6 +33,7 @@ const mapStateToProps = ({ router, resources, processPanel, processLogsPanel }:
         subprocesses: subprocesses.filter(subprocess => processPanel.filters[getProcessStatus(subprocess)]),
         filters: getFilters(processPanel, subprocesses),
         processLogsPanel: processLogsPanel,
+        auth: auth,
     };
 };
 
@@ -52,6 +54,7 @@ const mapDispatchToProps = (dispatch: Dispatch): ProcessPanelRootActionProps =>
     cancelProcess: (uuid) => dispatch<any>(cancelRunningWorkflow(uuid)),
     onLogFilterChange: (filter) => dispatch(setProcessLogsPanelFilter(filter.value)),
     navigateToLog: (uuid) => dispatch<any>(navigateToLogCollection(uuid)),
+    fetchOutputs: (uuid, setOutputs) => dispatch<any>(loadOutputs(uuid, setOutputs)),
 });
 
 const getFilters = (processPanel: ProcessPanelState, processes: Process[]) => {
diff --git a/yarn.lock b/yarn.lock
index 13ea553a..7fa499fd 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3798,6 +3798,7 @@ __metadata:
     lodash.template: 4.5.0
     material-ui-pickers: ^2.2.4
     mem: 4.0.0
+    mime: ^3.0.0
     moment: 2.29.1
     node-sass: ^4.9.4
     node-sass-chokidar: 1.5.0
@@ -12019,6 +12020,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"mime at npm:^3.0.0":
+  version: 3.0.0
+  resolution: "mime at npm:3.0.0"
+  bin:
+    mime: cli.js
+  checksum: f43f9b7bfa64534e6b05bd6062961681aeb406a5b53673b53b683f27fcc4e739989941836a355eef831f4478923651ecc739f4a5f6e20a76487b432bfd4db928
+  languageName: node
+  linkType: hard
+
 "mimic-fn at npm:^1.0.0":
   version: 1.2.0
   resolution: "mimic-fn at npm:1.2.0"

-----------------------------------------------------------------------


hooks/post-receive
-- 




More information about the arvados-commits mailing list