Merge branch '16073-process-io-panels' into main. Closes #16073
authorStephen Smith <stephen@curii.com>
Tue, 25 Oct 2022 13:54:35 +0000 (09:54 -0400)
committerStephen Smith <stephen@curii.com>
Tue, 25 Oct 2022 13:54:35 +0000 (09:54 -0400)
Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen@curii.com>

25 files changed:
.licenseignore
cypress/fixtures/files/cat.png [new file with mode: 0644]
cypress/fixtures/webdav-propfind-outputs.xml [new file with mode: 0644]
cypress/integration/collection.spec.js
cypress/integration/process.spec.js
package.json
src/common/webdav.ts
src/components/collection-panel-files/collection-panel-files.tsx
src/components/default-view/default-view.tsx
src/components/icon/icon.tsx
src/models/workflow.ts
src/services/collection-service/collection-service.ts
src/store/process-panel/process-panel-actions.ts
src/store/process-panel/process-panel-reducer.ts
src/store/process-panel/process-panel.ts
src/store/processes/processes-actions.ts
src/views-components/data-explorer/renderers.tsx
src/views-components/projects-tree-picker/generic-projects-tree-picker.tsx
src/views/process-panel/process-details-attributes.tsx
src/views/process-panel/process-io-card.tsx [new file with mode: 0644]
src/views/process-panel/process-output-collection-files.ts [new file with mode: 0644]
src/views/process-panel/process-panel-root.tsx
src/views/process-panel/process-panel.tsx
tools/arvados_config.yml
yarn.lock

index 7622a59435b0c54d3ed22a3a57691dc4ebd73dea..2440cc334e144c8aecc3fa8a8952069c16634b68 100644 (file)
@@ -15,6 +15,8 @@ public/*
 src/lib/cwl-svg/*
 tools/arvados_config.yml
 cypress/fixtures/files/5mb.bin
+cypress/fixtures/files/cat.png
+cypress/fixtures/webdav-propfind-outputs.xml
 .yarn/releases/*
 package.json
 yarn.lock
diff --git a/cypress/fixtures/files/cat.png b/cypress/fixtures/files/cat.png
new file mode 100644 (file)
index 0000000..6ebc4ba
Binary files /dev/null and b/cypress/fixtures/files/cat.png differ
diff --git a/cypress/fixtures/webdav-propfind-outputs.xml b/cypress/fixtures/webdav-propfind-outputs.xml
new file mode 100644 (file)
index 0000000..4bd1659
--- /dev/null
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<D:multistatus xmlns:D="DAV:">
+  <D:response>
+    <D:href>/c=zzzzz-4zz18-zzzzzzzzzzzzzzz/</D:href>
+    <D:propstat>
+      <D:prop>
+        <D:resourcetype>
+          <D:collection xmlns:D="DAV:" />
+        </D:resourcetype>
+        <D:getlastmodified>Mon, 11 Jul 2022 21:54:20 GMT</D:getlastmodified>
+        <D:supportedlock>
+          <D:lockentry xmlns:D="DAV:">
+            <D:lockscope>
+              <D:exclusive />
+            </D:lockscope>
+            <D:locktype>
+              <D:write />
+            </D:locktype>
+          </D:lockentry>
+        </D:supportedlock>
+        <D:displayname></D:displayname>
+      </D:prop>
+      <D:status>HTTP/1.1 200 OK</D:status>
+    </D:propstat>
+  </D:response>
+  <D:response>
+    <D:href>/c=zzzzz-4zz18-zzzzzzzzzzzzzzz/cwl.output.json</D:href>
+    <D:propstat>
+      <D:prop>
+        <D:displayname>cwl.output.json</D:displayname>
+        <D:getcontentlength>141</D:getcontentlength>
+        <D:getlastmodified>Mon, 11 Jul 2022 21:54:20 GMT</D:getlastmodified>
+        <D:supportedlock>
+          <D:lockentry xmlns:D="DAV:">
+            <D:lockscope>
+              <D:exclusive />
+            </D:lockscope>
+            <D:locktype>
+              <D:write />
+            </D:locktype>
+          </D:lockentry>
+        </D:supportedlock>
+        <D:resourcetype></D:resourcetype>
+        <D:getcontenttype>application/json</D:getcontenttype>
+        <D:getetag>"000000000000000000"</D:getetag>
+      </D:prop>
+      <D:status>HTTP/1.1 200 OK</D:status>
+    </D:propstat>
+  </D:response>
+</D:multistatus>
index 28454a9093b3e87499b8daf652ecafb01df4fdaa..74506aea21b67533e8318bbae402be3b0497a9ee 100644 (file)
@@ -208,7 +208,7 @@ describe('Collection panel tests', function () {
                 // a bogus manifest text without block signatures.
                 cy.doRequest('GET', '/arvados/v1/config', null, null)
                     .its('body').should((clusterConfig) => {
-                      expect(clusterConfig.Collections, "clusterConfig").to.have.property("TrustAllContent", false);
+                      expect(clusterConfig.Collections, "clusterConfig").to.have.property("TrustAllContent", true);
                       expect(clusterConfig.Services, "clusterConfig").to.have.property("WebDAV").have.property("ExternalURL");
                       expect(clusterConfig.Services, "clusterConfig").to.have.property("WebDAVDownload").have.property("ExternalURL");
                       const inlineUrl = clusterConfig.Services.WebDAV.ExternalURL !== ""
@@ -264,7 +264,7 @@ describe('Collection panel tests', function () {
                             .contains(fileName).rightclick();
                         cy.get('[data-cy=context-menu]')
                             .should('contain', 'Download')
-                            .and('not.contain', 'Open in new tab')
+                            .and('contain', 'Open in new tab')
                             .and('contain', 'Copy to clipboard')
                             .and(`${isWritable ? '' : 'not.'}contain`, 'Rename')
                             .and(`${isWritable ? '' : 'not.'}contain`, 'Remove');
@@ -273,7 +273,7 @@ describe('Collection panel tests', function () {
                             .contains(subDirName).rightclick();
                         cy.get('[data-cy=context-menu]')
                             .should('not.contain', 'Download')
-                            .and('not.contain', 'Open in new tab')
+                            .and('contain', 'Open in new tab')
                             .and('contain', 'Copy to clipboard')
                             .and(`${isWritable ? '' : 'not.'}contain`, 'Rename')
                             .and(`${isWritable ? '' : 'not.'}contain`, 'Remove');
index 55290fa36bb4d8a97ecd47b3056f185a4ed83396..84c786bdef42e74f7eb2f85060bf789d0bf7aa67 100644 (file)
@@ -274,4 +274,728 @@ describe('Process tests', function() {
                 .should('contain', 'Process retried 2 times');
         });
     });
+
+
+    const testInputs = [
+        {
+            definition: {
+                "id": "#main/input_file",
+                "label": "Label Description",
+                "type": "File"
+            },
+            input: {
+                "input_file": {
+                    "basename": "input1.tar",
+                    "class": "File",
+                    "location": "keep:00000000000000000000000000000000+01/input1.tar",
+                    "secondaryFiles": [
+                        {
+                            "basename": "input1-2.txt",
+                            "class": "File",
+                            "location": "keep:00000000000000000000000000000000+01/input1-2.txt"
+                        },
+                        {
+                            "basename": "input1-3.txt",
+                            "class": "File",
+                            "location": "keep:00000000000000000000000000000000+01/input1-3.txt"
+                        },
+                        {
+                            "basename": "input1-4.txt",
+                            "class": "File",
+                            "location": "keep:00000000000000000000000000000000+01/input1-4.txt"
+                        }
+                    ]
+                }
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_dir",
+                "doc": "Doc Description",
+                "type": "Directory"
+            },
+            input: {
+                "input_dir": {
+                    "basename": "11111111111111111111111111111111+01",
+                    "class": "Directory",
+                    "location": "keep:11111111111111111111111111111111+01"
+                }
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_bool",
+                "doc": ["Doc desc 1", "Doc desc 2"],
+                "type": "boolean"
+            },
+            input: {
+                "input_bool": true,
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_int",
+                "type": "int"
+            },
+            input: {
+                "input_int": 1,
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_long",
+                "type": "long"
+            },
+            input: {
+                "input_long" : 1,
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_float",
+                "type": "float"
+            },
+            input: {
+                "input_float": 1.5,
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_double",
+                "type": "double"
+            },
+            input: {
+                "input_double": 1.3,
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_string",
+                "type": "string"
+            },
+            input: {
+                "input_string": "Hello World",
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_file_array",
+                "type": {
+                  "items": "File",
+                  "type": "array"
+                }
+            },
+            input: {
+                "input_file_array": [
+                    {
+                        "basename": "input2.tar",
+                        "class": "File",
+                        "location": "keep:00000000000000000000000000000000+02/input2.tar"
+                    },
+                    {
+                        "basename": "input3.tar",
+                        "class": "File",
+                        "location": "keep:00000000000000000000000000000000+03/input3.tar",
+                        "secondaryFiles": [
+                            {
+                                "basename": "input3-2.txt",
+                                "class": "File",
+                                "location": "keep:00000000000000000000000000000000+03/input3-2.txt"
+                            }
+                        ]
+                    }
+                ]
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_dir_array",
+                "type": {
+                  "items": "Directory",
+                  "type": "array"
+                }
+            },
+            input: {
+                "input_dir_array": [
+                    {
+                        "basename": "11111111111111111111111111111111+02",
+                        "class": "Directory",
+                        "location": "keep:11111111111111111111111111111111+02"
+                    },
+                    {
+                        "basename": "11111111111111111111111111111111+03",
+                        "class": "Directory",
+                        "location": "keep:11111111111111111111111111111111+03"
+                    }
+                ]
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_int_array",
+                "type": {
+                  "items": "int",
+                  "type": "array"
+                }
+            },
+            input: {
+                "input_int_array": [
+                    1,
+                    3,
+                    5
+                ]
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_long_array",
+                "type": {
+                  "items": "long",
+                  "type": "array"
+                }
+            },
+            input: {
+                "input_long_array": [
+                    10,
+                    20
+                ]
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_float_array",
+                "type": {
+                  "items": "float",
+                  "type": "array"
+                }
+            },
+            input: {
+                "input_float_array": [
+                    10.2,
+                    10.4,
+                    10.6
+                ]
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_double_array",
+                "type": {
+                  "items": "double",
+                  "type": "array"
+                }
+            },
+            input: {
+                "input_double_array": [
+                    20.1,
+                    20.2,
+                    20.3
+                ]
+            }
+        },
+        {
+            definition: {
+                "id": "#main/input_string_array",
+                "type": {
+                  "items": "string",
+                  "type": "array"
+                }
+            },
+            input: {
+                "input_string_array": [
+                    "Hello",
+                    "World",
+                    "!"
+                ]
+            }
+        }
+    ];
+
+    const testOutputs = [
+        {
+            definition: {
+                "id": "#main/output_file",
+                "label": "Label Description",
+                "type": "File"
+            },
+            output: {
+                "output_file": {
+                    "basename": "cat.png",
+                    "class": "File",
+                    "location": "cat.png"
+                }
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_file_with_secondary",
+                "doc": "Doc Description",
+                "type": "File"
+            },
+            output: {
+                "output_file_with_secondary": {
+                    "basename": "main.dat",
+                    "class": "File",
+                    "location": "main.dat",
+                    "secondaryFiles": [
+                        {
+                            "basename": "secondary.dat",
+                            "class": "File",
+                            "location": "secondary.dat"
+                        },
+                        {
+                            "basename": "secondary2.dat",
+                            "class": "File",
+                            "location": "secondary2.dat"
+                        }
+                    ]
+                }
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_dir",
+                "doc": ["Doc desc 1", "Doc desc 2"],
+                "type": "Directory"
+            },
+            output: {
+                "output_dir": {
+                    "basename": "outdir1",
+                    "class": "Directory",
+                    "location": "outdir1"
+                }
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_bool",
+                "type": "boolean"
+            },
+            output: {
+                "output_bool": true
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_int",
+                "type": "int"
+            },
+            output: {
+                "output_int": 1
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_long",
+                "type": "long"
+            },
+            output: {
+                "output_long": 1
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_float",
+                "type": "float"
+            },
+            output: {
+                "output_float": 100.5
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_double",
+                "type": "double"
+            },
+            output: {
+                "output_double": 100.3
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_string",
+                "type": "string"
+            },
+            output: {
+                "output_string": "Hello output"
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_file_array",
+                "type": {
+                    "items": "File",
+                    "type": "array"
+                }
+            },
+            output: {
+                "output_file_array": [
+                    {
+                        "basename": "output2.tar",
+                        "class": "File",
+                        "location": "output2.tar"
+                    },
+                    {
+                        "basename": "output3.tar",
+                        "class": "File",
+                        "location": "output3.tar"
+                    }
+                ]
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_dir_array",
+                "type": {
+                    "items": "Directory",
+                    "type": "array"
+                }
+            },
+            output: {
+                "output_dir_array": [
+                    {
+                        "basename": "outdir2",
+                        "class": "Directory",
+                        "location": "outdir2"
+                    },
+                    {
+                        "basename": "outdir3",
+                        "class": "Directory",
+                        "location": "outdir3"
+                    }
+                ]
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_int_array",
+                "type": {
+                    "items": "int",
+                    "type": "array"
+                }
+            },
+            output: {
+                "output_int_array": [
+                    10,
+                    11,
+                    12
+                ]
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_long_array",
+                "type": {
+                    "items": "long",
+                    "type": "array"
+                }
+            },
+            output: {
+                "output_long_array": [
+                    51,
+                    52
+                ]
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_float_array",
+                "type": {
+                    "items": "float",
+                    "type": "array"
+                }
+            },
+            output: {
+                "output_float_array": [
+                    100.2,
+                    100.4,
+                    100.6
+                ]
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_double_array",
+                "type": {
+                    "items": "double",
+                    "type": "array"
+                }
+            },
+            output: {
+                "output_double_array": [
+                    100.1,
+                    100.2,
+                    100.3
+                ]
+            }
+        },
+        {
+            definition: {
+                "id": "#main/output_string_array",
+                "type": {
+                    "items": "string",
+                    "type": "array"
+                }
+            },
+            output: {
+                "output_string_array": [
+                    "Hello",
+                    "Output",
+                    "!"
+                ]
+            }
+        }
+    ];
+
+    const verifyIOParameter = (name, label, doc, val, collection, multipleRows) => {
+        cy.get('table tr').contains(name).parents('tr').within(($mainRow) => {
+            label && cy.contains(label);
+
+            if (multipleRows) {
+                cy.get($mainRow).nextUntil('[data-cy="process-io-param"]').as('secondaryRows');
+                if (val) {
+                    if (Array.isArray(val)) {
+                        val.forEach(v => cy.get('@secondaryRows').contains(v));
+                    } else {
+                        cy.get('@secondaryRows').contains(val);
+                    }
+                }
+                if (collection) {
+                    cy.get('@secondaryRows').contains(collection);
+                }
+            } else {
+                if (val) {
+                    if (Array.isArray(val)) {
+                        val.forEach(v => cy.contains(v));
+                    } else {
+                        cy.contains(val);
+                    }
+                }
+                if (collection) {
+                    cy.contains(collection);
+                }
+            }
+
+
+        });
+    };
+
+    const verifyIOParameterImage = (name, url) => {
+        cy.get('table tr').contains(name).parents('tr').within(() => {
+            cy.get('[alt="Inline Preview"]')
+                .should('be.visible')
+                .and(($img) => {
+                    expect($img[0].naturalWidth).to.be.greaterThan(0);
+                    expect($img[0].src).contains(url);
+                })
+        });
+    };
+
+    it('displays IO parameters with keep links and previews', function() {
+        // Create output collection for real files
+        cy.createCollection(adminUser.token, {
+            name: `Test collection ${Math.floor(Math.random() * 999999)}`,
+            owner_uuid: activeUser.user.uuid,
+        }).then((testOutputCollection) => {
+                    cy.loginAs(activeUser);
+
+                    cy.goToPath(`/collections/${testOutputCollection.uuid}`);
+
+                    cy.get('[data-cy=upload-button]').click();
+
+                    cy.fixture('files/cat.png', 'base64').then(content => {
+                        cy.get('[data-cy=drag-and-drop]').upload(content, 'cat.png');
+                        cy.get('[data-cy=form-submit-btn]').click();
+                        cy.waitForDom().get('[data-cy=form-submit-btn]').should('not.exist');
+                        // Confirm final collection state.
+                        cy.get('[data-cy=collection-files-panel]')
+                            .contains('cat.png').should('exist');
+                    });
+
+                    cy.getCollection(activeUser.token, testOutputCollection.uuid).as('testOutputCollection');
+                });
+
+        // Get updated collection pdh
+        cy.getAll('@testOutputCollection').then(([testOutputCollection]) => {
+            // Add output uuid and inputs to container request
+            cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => {
+                req.reply((res) => {
+                    res.body.output_uuid = testOutputCollection.uuid;
+                    res.body.mounts["/var/lib/cwl/cwl.input.json"] = {
+                        content: testInputs.map((param) => (param.input)).reduce((acc, val) => (Object.assign(acc, val)), {})
+                    };
+                    res.body.mounts["/var/lib/cwl/workflow.json"] = {
+                        content: {
+                            $graph: [{
+                                id: "#main",
+                                inputs: testInputs.map((input) => (input.definition)),
+                                outputs: testOutputs.map((output) => (output.definition))
+                            }]
+                        }
+                    };
+                });
+            });
+
+            // Stub fake output collection
+            cy.intercept({method: 'GET', url: `**/arvados/v1/collections/${testOutputCollection.uuid}*`}, {
+                statusCode: 200,
+                body: {
+                    uuid: testOutputCollection.uuid,
+                    portable_data_hash: testOutputCollection.portable_data_hash,
+                }
+            });
+
+            // Stub fake output json
+            cy.intercept({method: 'GET', url: '**/c%3Dzzzzz-4zz18-zzzzzzzzzzzzzzz/cwl.output.json'}, {
+                statusCode: 200,
+                body: testOutputs.map((param) => (param.output)).reduce((acc, val) => (Object.assign(acc, val)), {})
+            });
+
+            // Stub webdav response, points to output json
+            cy.intercept({method: 'PROPFIND', url: '*'}, {
+                fixture: 'webdav-propfind-outputs.xml',
+            });
+        });
+
+        createContainerRequest(
+            activeUser,
+            'test_container_request',
+            'arvados/jobs',
+            ['echo', 'hello world'],
+            false, 'Committed')
+        .as('containerRequest');
+
+        cy.getAll('@containerRequest', '@testOutputCollection').then(function([containerRequest, testOutputCollection]) {
+            cy.goToPath(`/processes/${containerRequest.uuid}`);
+            cy.get('[data-cy=process-io-card] h6').contains('Inputs')
+                .parents('[data-cy=process-io-card]').within(() => {
+                    verifyIOParameter('input_file', null, "Label Description", 'input1.tar', '00000000000000000000000000000000+01');
+                    verifyIOParameter('input_file', null, "Label Description", 'input1-2.txt', undefined, true);
+                    verifyIOParameter('input_file', null, "Label Description", 'input1-3.txt', undefined, true);
+                    verifyIOParameter('input_file', null, "Label Description", 'input1-4.txt', undefined, true);
+                    verifyIOParameter('input_dir', null, "Doc Description", '/', '11111111111111111111111111111111+01');
+                    verifyIOParameter('input_bool', null, "Doc desc 1, Doc desc 2", 'true');
+                    verifyIOParameter('input_int', null, null, '1');
+                    verifyIOParameter('input_long', null, null, '1');
+                    verifyIOParameter('input_float', null, null, '1.5');
+                    verifyIOParameter('input_double', null, null, '1.3');
+                    verifyIOParameter('input_string', null, null, 'Hello World');
+                    verifyIOParameter('input_file_array', null, null, 'input2.tar', '00000000000000000000000000000000+02');
+                    verifyIOParameter('input_file_array', null, null, 'input3.tar', undefined, true);
+                    verifyIOParameter('input_file_array', null, null, 'input3-2.txt', undefined, true);
+                    verifyIOParameter('input_dir_array', null, null, '/', '11111111111111111111111111111111+02');
+                    verifyIOParameter('input_dir_array', null, null, '/', '11111111111111111111111111111111+03', true);
+                    verifyIOParameter('input_int_array', null, null, ["1", "3", "5"]);
+                    verifyIOParameter('input_long_array', null, null, ["10", "20"]);
+                    verifyIOParameter('input_float_array', null, null, ["10.2", "10.4", "10.6"]);
+                    verifyIOParameter('input_double_array', null, null, ["20.1", "20.2", "20.3"]);
+                    verifyIOParameter('input_string_array', null, null, ["Hello", "World", "!"]);
+                });
+            cy.get('[data-cy=process-io-card] h6').contains('Outputs')
+                .parents('[data-cy=process-io-card]').within((ctx) => {
+                    cy.get(ctx).scrollIntoView();
+                    cy.get('[data-cy="io-preview-image-toggle"]').click();
+                    const outPdh = testOutputCollection.portable_data_hash;
+
+                    verifyIOParameter('output_file', null, "Label Description", 'cat.png', `${outPdh}`);
+                    verifyIOParameterImage('output_file', `/c=${outPdh}/cat.png`);
+                    verifyIOParameter('output_file_with_secondary', null, "Doc Description", 'main.dat', `${outPdh}`);
+                    verifyIOParameter('output_file_with_secondary', null, "Doc Description", 'secondary.dat', undefined, true);
+                    verifyIOParameter('output_file_with_secondary', null, "Doc Description", 'secondary2.dat', undefined, true);
+                    verifyIOParameter('output_dir', null, "Doc desc 1, Doc desc 2", 'outdir1', `${outPdh}`);
+                    verifyIOParameter('output_bool', null, null, 'true');
+                    verifyIOParameter('output_int', null, null, '1');
+                    verifyIOParameter('output_long', null, null, '1');
+                    verifyIOParameter('output_float', null, null, '100.5');
+                    verifyIOParameter('output_double', null, null, '100.3');
+                    verifyIOParameter('output_string', null, null, 'Hello output');
+                    verifyIOParameter('output_file_array', null, null, 'output2.tar', `${outPdh}`);
+                    verifyIOParameter('output_file_array', null, null, 'output3.tar', undefined, true);
+                    verifyIOParameter('output_dir_array', null, null, 'outdir2', `${outPdh}`);
+                    verifyIOParameter('output_dir_array', null, null, 'outdir3', undefined, true);
+                    verifyIOParameter('output_int_array', null, null, ["10", "11", "12"]);
+                    verifyIOParameter('output_long_array', null, null, ["51", "52"]);
+                    verifyIOParameter('output_float_array', null, null, ["100.2", "100.4", "100.6"]);
+                    verifyIOParameter('output_double_array', null, null, ["100.1", "100.2", "100.3"]);
+                    verifyIOParameter('output_string_array', null, null, ["Hello", "Output", "!"]);
+                });
+        });
+    });
+
+    it('displays IO parameters with no value', function() {
+
+        const fakeOutputUUID = 'zzzzz-4zz18-abcdefghijklmno';
+        const fakeOutputPDH = '11111111111111111111111111111111+99/';
+
+        cy.loginAs(activeUser);
+
+        // Add output uuid and inputs to container request
+        cy.intercept({method: 'GET', url: '**/arvados/v1/container_requests/*'}, (req) => {
+            req.reply((res) => {
+                res.body.output_uuid = fakeOutputUUID;
+                res.body.mounts["/var/lib/cwl/cwl.input.json"] = {
+                    content: {}
+                };
+                res.body.mounts["/var/lib/cwl/workflow.json"] = {
+                    content: {
+                        $graph: [{
+                            id: "#main",
+                            inputs: testInputs.map((input) => (input.definition)),
+                            outputs: testOutputs.map((output) => (output.definition))
+                        }]
+                    }
+                };
+            });
+        });
+
+        // Stub fake output collection
+        cy.intercept({method: 'GET', url: `**/arvados/v1/collections/${fakeOutputUUID}*`}, {
+            statusCode: 200,
+            body: {
+                uuid: fakeOutputUUID,
+                portable_data_hash: fakeOutputPDH,
+            }
+        });
+
+        // Stub fake output json
+        cy.intercept({method: 'GET', url: `**/c%3D${fakeOutputUUID}/cwl.output.json`}, {
+            statusCode: 200,
+            body: {}
+        });
+
+        cy.readFile('cypress/fixtures/webdav-propfind-outputs.xml').then((data) => {
+            // Stub webdav response, points to output json
+            cy.intercept({method: 'PROPFIND', url: '*'}, {
+                statusCode: 200,
+                body: data.replace(/zzzzz-4zz18-zzzzzzzzzzzzzzz/g, fakeOutputUUID)
+            });
+        });
+
+        createContainerRequest(
+            activeUser,
+            'test_container_request',
+            'arvados/jobs',
+            ['echo', 'hello world'],
+            false, 'Committed')
+        .as('containerRequest');
+
+        cy.getAll('@containerRequest').then(function([containerRequest]) {
+            cy.goToPath(`/processes/${containerRequest.uuid}`);
+            cy.get('[data-cy=process-io-card] h6').contains('Inputs')
+                .parents('[data-cy=process-io-card]').within(() => {
+                    cy.wait(2000);
+                    cy.waitForDom();
+                    cy.get('tbody tr').each((item) => {
+                        cy.wrap(item).contains('No value');
+                    });
+                });
+            cy.get('[data-cy=process-io-card] h6').contains('Outputs')
+                .parents('[data-cy=process-io-card]').within(() => {
+                    cy.get('tbody tr').each((item) => {
+                        cy.wrap(item).contains('No value');
+                    });
+                });
+        });
+    });
+
 });
index 9e663ca6ac5a440946e91f1f3a50ce030db355e0..347ca0f40a652aa499c8c04c5b5fd9b8d1a5afb3 100644 (file)
@@ -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",
index 93ec21cb724f26d2ede4cf9be4b211defaf85b9c..d4f904ae9832461abd777e5c8f6a112c49d14e65 100644 (file)
@@ -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
+}
index 06c3504ac52400ee13e0592d6e64fd326ddf95eb..cf0b5e46e8b98f2f89dc9e5783a679736894bbcb 100644 (file)
@@ -154,8 +154,8 @@ const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
         marginTop: '-15px',
     },
     pathPanel: {
-        padding: '1rem',
-        marginBottom: '1rem',
+        padding: '0.5rem',
+        marginBottom: '0.5rem',
         backgroundColor: '#fff',
         boxShadow: '0px 1px 3px 0px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 2px 1px -1px rgb(0 0 0 / 12%)',
     },
@@ -164,7 +164,7 @@ const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     },
     leftPanel: {
         flex: 0,
-        padding: '1rem',
+        padding: '0 1rem 1rem',
         marginRight: '1rem',
         whiteSpace: 'nowrap',
         position: 'relative',
@@ -195,8 +195,8 @@ const styles: StyleRulesCallback<CssRules> = (theme: Theme) => ({
     rightPanel: {
         flex: '50%',
         padding: '1rem',
-        paddingTop: '2rem',
-        marginTop: '-1rem',
+        paddingTop: '0.5rem',
+        marginTop: '-0.5rem',
         position: 'relative',
         backgroundColor: '#fff',
         boxShadow: '0px 3px 3px 0px rgb(0 0 0 / 20%), 0px 3px 1px 0px rgb(0 0 0 / 14%), 0px 3px 1px -1px rgb(0 0 0 / 12%)',
index 014b8cc48a7022fbf04f07e9895521c171175f9d..5acea6193be906ddb9ec3c865f1d9e80ea068b2f 100644 (file)
@@ -29,7 +29,7 @@ export interface DefaultViewDataProps {
     messages: string[];
     filtersApplied?: boolean;
     classMessage?: string;
-    icon: IconType;
+    icon?: IconType;
     classIcon?: string;
 }
 
@@ -38,7 +38,7 @@ type DefaultViewProps = DefaultViewDataProps & WithStyles<CssRules>;
 export const DefaultView = withStyles(styles)(
     ({ classes, classRoot, messages, classMessage, icon: Icon, classIcon }: DefaultViewProps) =>
         <Typography className={classnames([classes.root, classRoot])} component="div">
-            <Icon className={classnames([classes.icon, classIcon])} />
+            {Icon && <Icon className={classnames([classes.icon, classIcon])} />}
             {messages.map((msg: string, index: number) => {
                 return <Typography key={index}
                     className={classnames([classes.message, classMessage])}>{msg}</Typography>;
index 9ddc2aa2997c54a810b2e06df174916e37535d15..daa776a06c3b269d5f51dbbf2aec73400ec0fef1 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import React from 'react';
-import { Badge, Tooltip } from '@material-ui/core';
+import { Badge, SvgIcon, Tooltip } from '@material-ui/core';
 import Add from '@material-ui/icons/Add';
 import ArrowBack from '@material-ui/icons/ArrowBack';
 import ArrowDropDown from '@material-ui/icons/ArrowDropDown';
@@ -34,6 +34,7 @@ import Help from '@material-ui/icons/Help';
 import HelpOutline from '@material-ui/icons/HelpOutline';
 import History from '@material-ui/icons/History';
 import Inbox from '@material-ui/icons/Inbox';
+import MoveToInbox from '@material-ui/icons/MoveToInbox';
 import Info from '@material-ui/icons/Info';
 import Input from '@material-ui/icons/Input';
 import InsertDriveFile from '@material-ui/icons/InsertDriveFile';
@@ -43,7 +44,6 @@ import ListAlt from '@material-ui/icons/ListAlt';
 import Menu from '@material-ui/icons/Menu';
 import MoreVert from '@material-ui/icons/MoreVert';
 import Mail from '@material-ui/icons/Mail';
-import MoveToInbox from '@material-ui/icons/MoveToInbox';
 import Notifications from '@material-ui/icons/Notifications';
 import OpenInNew from '@material-ui/icons/OpenInNew';
 import People from '@material-ui/icons/People';
@@ -71,6 +71,7 @@ import ExitToApp from '@material-ui/icons/ExitToApp';
 import CheckCircleOutline from '@material-ui/icons/CheckCircleOutline';
 import RemoveCircleOutline from '@material-ui/icons/RemoveCircleOutline';
 import NotInterested from '@material-ui/icons/NotInterested';
+import Image from '@material-ui/icons/Image';
 
 // Import FontAwesome icons
 import { library } from '@fortawesome/fontawesome-svg-core';
@@ -123,6 +124,18 @@ export const CollectionOldVersionIcon = (props: any) =>
         </Badge>
     </Tooltip>;
 
+// https://materialdesignicons.com/icon/image-off
+export const ImageOffIcon = (props: any) =>
+    <SvgIcon {...props}>
+        <path d="M21 17.2L6.8 3H19C20.1 3 21 3.9 21 5V17.2M20.7 22L19.7 21H5C3.9 21 3 20.1 3 19V4.3L2 3.3L3.3 2L22 20.7L20.7 22M16.8 18L12.9 14.1L11 16.5L8.5 13.5L5 18H16.8Z" />
+    </SvgIcon>;
+
+// https://materialdesignicons.com/icon/inbox-arrow-up
+export const OutputIcon: IconType = (props: any) =>
+    <SvgIcon {...props}>
+        <path d="M14,14H10V11H8L12,7L16,11H14V14M16,11M5,15V5H19V15H15A3,3 0 0,1 12,18A3,3 0 0,1 9,15H5M19,3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3" />
+    </SvgIcon>;
+
 export type IconType = React.SFC<{ className?: string, style?: object }>;
 
 export const AddIcon: IconType = (props) => <Add {...props} />;
@@ -150,7 +163,7 @@ export const HelpIcon: IconType = (props) => <Help {...props} />;
 export const HelpOutlineIcon: IconType = (props) => <HelpOutline {...props} />;
 export const ImportContactsIcon: IconType = (props) => <ImportContacts {...props} />;
 export const InfoIcon: IconType = (props) => <Info {...props} />;
-export const InputIcon: IconType = (props) => <InsertDriveFile {...props} />;
+export const FileInputIcon: IconType = (props) => <InsertDriveFile {...props} />;
 export const KeyIcon: IconType = (props) => <VpnKey {...props} />;
 export const LogIcon: IconType = (props) => <SettingsEthernet {...props} />;
 export const MailIcon: IconType = (props) => <Mail {...props} />;
@@ -160,7 +173,7 @@ export const MoveToIcon: IconType = (props) => <Input {...props} />;
 export const NewProjectIcon: IconType = (props) => <CreateNewFolder {...props} />;
 export const NotificationIcon: IconType = (props) => <Notifications {...props} />;
 export const OpenIcon: IconType = (props) => <OpenInNew {...props} />;
-export const OutputIcon: IconType = (props) => <MoveToInbox {...props} />;
+export const InputIcon: IconType = (props) => <MoveToInbox {...props} />;
 export const PaginationDownIcon: IconType = (props) => <ArrowDropDown {...props} />;
 export const PaginationLeftArrowIcon: IconType = (props) => <ChevronLeft {...props} />;
 export const PaginationRightArrowIcon: IconType = (props) => <ChevronRight {...props} />;
@@ -200,3 +213,4 @@ export const LoginAsIcon: IconType = (props) => <ExitToApp {...props} />;
 export const ActiveIcon: IconType = (props) => <CheckCircleOutline {...props} />;
 export const SetupIcon: IconType = (props) => <RemoveCircleOutline {...props} />;
 export const InactiveIcon: IconType = (props) => <NotInterested {...props} />;
+export const ImageIcon: IconType = (props) => <Image {...props} />;
index 6d21dbc766381831a1e048529913e77f51784a38..e85dce7a6a02e697fe9b674630b530b9599d09cc 100644 (file)
@@ -4,6 +4,7 @@
 
 import { Resource, ResourceKind } from "./resource";
 import { safeLoad } from 'js-yaml';
+import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
 
 export interface WorkflowResource extends Resource {
     kind: ResourceKind.WORKFLOW;
@@ -152,10 +153,21 @@ export const getWorkflowInputs = (workflowDefinition: WorkflowResourceDefinition
         : undefined;
 };
 
+export const getWorkflowOutputs = (workflowDefinition: WorkflowResourceDefinition) => {
+    if (!workflowDefinition) { return undefined; }
+    return getWorkflow(workflowDefinition)
+        ? getWorkflow(workflowDefinition)!.outputs
+        : undefined;
+};
+
 export const getInputLabel = (input: CommandInputParameter) => {
     return `${input.label || input.id.split('/').pop()}`;
 };
 
+export const getIOParamId = (input: CommandInputParameter | CommandOutputParameter) => {
+    return `${input.id.split('/').pop()}`;
+};
+
 export const isRequiredInput = ({ type }: CommandInputParameter) => {
     if (type instanceof Array) {
         for (const t of type) {
index 92e4dfbae3e6d43f28f73b03dfce992da3f3fcfc..e2c420d8d8c6e9f4ebbf112b41daa85dbc559662 100644 (file)
@@ -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 = {
index e77c300d8c07cc50a73da44734a59b69b1737211..64fc493fb2ea4f5b41a207cfecf63fb294f0d51c 100644 (file)
@@ -3,7 +3,7 @@
 // SPDX-License-Identifier: AGPL-3.0
 
 import { unionize, ofType, UnionOf } from "common/unionize";
-import { loadProcess } from 'store/processes/processes-actions';
+import { getInputs, getOutputParameters, getRawInputs, getRawOutputs, loadProcess } from 'store/processes/processes-actions';
 import { Dispatch } from 'redux';
 import { ProcessStatus } from 'store/processes/process';
 import { RootState } from 'store/store';
@@ -14,11 +14,24 @@ 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";
+import { ContainerRequestResource } from "models/container-request";
+import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
+import { CommandInputParameter, getIOParamId } from 'models/workflow';
+import { getIOParamDisplayValue, ProcessIOParameter } from "views/process-panel/process-io-card";
+import { OutputDetails } from "./process-panel";
+import { AuthState } from "store/auth/auth-reducer";
 
 export const processPanelActions = unionize({
+    RESET_PROCESS_PANEL: ofType<{}>(),
     SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: ofType<string>(),
     SET_PROCESS_PANEL_FILTERS: ofType<string[]>(),
     TOGGLE_PROCESS_PANEL_FILTER: ofType<string>(),
+    SET_INPUT_RAW: ofType<CommandInputParameter[] | null>(),
+    SET_INPUT_PARAMS: ofType<ProcessIOParameter[] | null>(),
+    SET_OUTPUT_RAW: ofType<OutputDetails | null>(),
+    SET_OUTPUT_DEFINITIONS: ofType<CommandOutputParameter[]>(),
+    SET_OUTPUT_PARAMS: ofType<ProcessIOParameter[] | null>(),
 });
 
 export type ProcessPanelAction = UnionOf<typeof processPanelActions>;
@@ -27,6 +40,7 @@ export const toggleProcessPanelFilter = processPanelActions.TOGGLE_PROCESS_PANEL
 
 export const loadProcessPanel = (uuid: string) =>
     async (dispatch: Dispatch) => {
+        dispatch(processPanelActions.RESET_PROCESS_PANEL());
         dispatch(processLogsPanelActions.RESET_PROCESS_LOGS_PANEL());
         dispatch<ProcessPanelAction>(processPanelActions.SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID(uuid));
         await dispatch<any>(loadProcess(uuid));
@@ -45,6 +59,66 @@ export const navigateToOutput = (uuid: string) =>
         }
     };
 
+export const loadInputs = (containerRequest: ContainerRequestResource) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        dispatch<ProcessPanelAction>(processPanelActions.SET_INPUT_RAW(getRawInputs(containerRequest)));
+        dispatch<ProcessPanelAction>(processPanelActions.SET_INPUT_PARAMS(formatInputData(getInputs(containerRequest), getState().auth)));
+    };
+
+export const loadOutputs = (containerRequest: ContainerRequestResource) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const noOutputs = {rawOutputs: {}};
+        if (!containerRequest.outputUuid) {
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW(noOutputs));
+            return;
+        };
+        try {
+            const propsOutputs = getRawOutputs(containerRequest);
+            const filesPromise = services.collectionService.files(containerRequest.outputUuid);
+            const collectionPromise = services.collectionService.get(containerRequest.outputUuid);
+            const [files, collection] = await Promise.all([filesPromise, collectionPromise]);
+
+            // If has propsOutput, skip fetching cwl.output.json
+            if (propsOutputs !== undefined) {
+                dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({
+                    rawOutputs: propsOutputs,
+                    pdh: collection.portableDataHash
+                }));
+            } else {
+                // Fetch outputs from keep
+                const outputFile = files.find((file) => file.name === 'cwl.output.json') as CollectionFile | undefined;
+                let outputData = outputFile ? await services.collectionService.getFileContents(outputFile) : undefined;
+                if (outputData && (outputData = JSON.parse(outputData)) && collection.portableDataHash) {
+                    dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW({
+                        rawOutputs: outputData,
+                        pdh: collection.portableDataHash,
+                    }));
+                } else {
+                    dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW(noOutputs));
+                }
+            }
+        } catch {
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_RAW(noOutputs));
+        }
+    };
+
+export const loadOutputDefinitions = (containerRequest: ContainerRequestResource) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        if (containerRequest && containerRequest.mounts) {
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_DEFINITIONS(getOutputParameters(containerRequest)));
+        }
+    };
+
+export const updateOutputParams = () =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        const outputDefinitions = getState().processPanel.outputDefinitions;
+        const outputRaw = getState().processPanel.outputRaw;
+
+        if (outputRaw !== null && outputRaw.rawOutputs) {
+            dispatch<ProcessPanelAction>(processPanelActions.SET_OUTPUT_PARAMS(formatOutputData(outputDefinitions, outputRaw.rawOutputs, outputRaw.pdh, getState().auth)));
+        }
+    };
+
 export const openWorkflow = (uuid: string) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch<any>(navigateToWorkflows);
@@ -61,3 +135,23 @@ export const initProcessPanelFilters = processPanelActions.SET_PROCESS_PANEL_FIL
     ProcessStatus.WARNING,
     ProcessStatus.CANCELLED
 ]);
+
+const formatInputData = (inputs: CommandInputParameter[], auth: AuthState): ProcessIOParameter[] => {
+    return inputs.map(input => {
+        return {
+            id: getIOParamId(input),
+            label: input.label || "",
+            value: getIOParamDisplayValue(auth, input)
+        };
+    });
+};
+
+const formatOutputData = (definitions: CommandOutputParameter[], values: any, pdh: string | undefined, auth: AuthState): ProcessIOParameter[] => {
+    return definitions.map(output => {
+        return {
+            id: getIOParamId(output),
+            label: output.label || "",
+            value: getIOParamDisplayValue(auth, Object.assign(output, { value: values[getIOParamId(output)] || [] }), pdh)
+        };
+    });
+};
index d26e76932038bf500e33074a68548645f6a1064f..48cdb39f8402ce1f1e63ca57d5fea3a1c3517a3b 100644 (file)
@@ -7,11 +7,17 @@ import { ProcessPanelAction, processPanelActions } from 'store/process-panel/pro
 
 const initialState: ProcessPanel = {
     containerRequestUuid: "",
-    filters: {}
+    filters: {},
+    inputRaw: null,
+    inputParams: null,
+    outputRaw: null,
+    outputDefinitions: [],
+    outputParams: null,
 };
 
 export const processPanelReducer = (state = initialState, action: ProcessPanelAction): ProcessPanel =>
     processPanelActions.match(action, {
+        RESET_PROCESS_PANEL: () => initialState,
         SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: containerRequestUuid => ({
             ...state, containerRequestUuid
         }),
@@ -23,5 +29,37 @@ export const processPanelReducer = (state = initialState, action: ProcessPanelAc
             const filters = { ...state.filters, [status]: !state.filters[status] };
             return { ...state, filters };
         },
+        SET_INPUT_RAW: inputRaw => {
+            // Since mounts can disappear and reappear, only set inputs
+            //   if current state is null or new inputs has content
+            if (state.inputRaw === null || (inputRaw && inputRaw.length)) {
+                return { ...state, inputRaw };
+            } else {
+                return state;
+            }
+        },
+        SET_INPUT_PARAMS: inputParams => {
+            // Since mounts can disappear and reappear, only set inputs
+            //   if current state is null or new inputs has content
+            if (state.inputParams === null || (inputParams && inputParams.length)) {
+                return { ...state, inputParams };
+            } else {
+                return state;
+            }
+        },
+        SET_OUTPUT_RAW: outputRaw => {
+            return { ...state, outputRaw };
+        },
+        SET_OUTPUT_DEFINITIONS: outputDefinitions => {
+            // Set output definitions is only additive to avoid clearing when mounts go temporarily missing
+            if (outputDefinitions.length) {
+                return { ...state, outputDefinitions }
+            } else {
+                return state;
+            }
+        },
+        SET_OUTPUT_PARAMS: outputParams => {
+            return { ...state, outputParams };
+        },
         default: () => state,
     });
index 49c2691d9d4c21c3fdd2ee48eea60a74f2db31c6..d0d5edebf3e7c35807c8f35c11a0f3aafc45c13a 100644 (file)
@@ -2,16 +2,29 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
+import { CommandInputParameter } from 'models/workflow';
 import { RouterState } from "react-router-redux";
 import { matchProcessRoute } from "routes/routes";
+import { ProcessIOParameter } from "views/process-panel/process-io-card";
+import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
+
+export type OutputDetails = {
+    rawOutputs?: any;
+    pdh?: string;
+}
 
 export interface ProcessPanel {
     containerRequestUuid: string;
     filters: { [status: string]: boolean };
+    inputRaw: CommandInputParameter[] | null;
+    inputParams: ProcessIOParameter[] | null;
+    outputRaw: OutputDetails | null;
+    outputDefinitions: CommandOutputParameter[];
+    outputParams: ProcessIOParameter[] | null;
 }
 
 export const getProcessPanelCurrentUuid = (router: RouterState) => {
     const pathname = router.location ? router.location.pathname : '';
     const match = matchProcessRoute(pathname);
     return match ? match.params.id : undefined;
-};
\ No newline at end of file
+};
index c4d421ac09d9b5719a9f8d1b8f9a00833b7cf662..458efa205f44104d59b9a20862215fa7ff131b06 100644 (file)
@@ -17,15 +17,21 @@ 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, getWorkflowOutputs } from "models/workflow";
 import { ProjectResource } from "models/project";
 import { UserResource } from "models/user";
+import { CommandOutputParameter } from "cwlts/mappings/v1.0/CommandOutputParameter";
 
 export const loadProcess = (containerRequestUuid: string) =>
     async (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository): Promise<Process> => {
         const containerRequest = await services.containerRequestService.get(containerRequestUuid);
         dispatch<any>(updateResources([containerRequest]));
 
+        if (containerRequest.outputUuid) {
+            const collection = await services.collectionService.get(containerRequest.outputUuid);
+            dispatch<any>(updateResources([collection]));
+        }
+
         if (containerRequest.containerUuid) {
             const container = await services.containerService.get(containerRequest.containerUuid);
             dispatch<any>(updateResources([container]));
@@ -127,8 +133,25 @@ export const reRunProcess = (processUuid: string, workflowUuid: string) =>
         }
     };
 
-const getInputs = (data: any) => {
+/*
+ * Fetches raw inputs from containerRequest mounts with fallback to properties
+ * Returns undefined if containerRequest not loaded
+ * Returns [] if inputs not found in mounts or props
+ */
+export const getRawInputs = (data: any): CommandInputParameter[] | undefined => {
+    if (!data) { return undefined; }
+    const mountInput = data.mounts?.[MOUNT_PATH_CWL_INPUT]?.content;
+    const propsInput = data.properties?.cwl_input;
+    if (!mountInput && !propsInput) { return []; }
+    return (mountInput || propsInput);
+}
+
+export const getInputs = (data: any): CommandInputParameter[] => {
+    // Definitions from mounts are needed so we return early if missing
     if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; }
+    const content  = getRawInputs(data) as any;
+    if (!content) { return []; }
+
     const inputs = getWorkflowInputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
     return inputs ? inputs.map(
         (it: any) => (
@@ -136,7 +159,53 @@ const getInputs = (data: any) => {
                 type: it.type,
                 id: it.id,
                 label: it.label,
-                default: data.mounts[MOUNT_PATH_CWL_INPUT].content[it.id],
+                default: content[it.id],
+                value: content[it.id.split('/').pop()] || [],
+                doc: it.doc
+            }
+        )
+    ) : [];
+};
+
+/*
+ * Fetches raw outputs from containerRequest properties
+ * Assumes containerRequest is loaded
+ */
+export const getRawOutputs = (data: any): CommandInputParameter[] | undefined => {
+    if (!data || !data.properties || !data.properties.cwl_output) { return undefined; }
+    return (data.properties.cwl_output);
+}
+
+export type InputCollectionMount = {
+    path: string;
+    pdh: string;
+}
+
+export const getInputCollectionMounts = (data: any): InputCollectionMount[] => {
+    if (!data || !data.mounts) { return []; }
+    return Object.keys(data.mounts)
+        .map(key => ({
+            ...data.mounts[key],
+            path: key,
+        }))
+        .filter(mount => mount.kind === 'collection' &&
+                mount.portable_data_hash &&
+                mount.path)
+        .map(mount => ({
+            path: mount.path,
+            pdh: mount.portable_data_hash,
+        }));
+};
+
+export const getOutputParameters = (data: any): CommandOutputParameter[] => {
+    if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; }
+    const outputs = getWorkflowOutputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
+    return outputs ? outputs.map(
+        (it: any) => (
+            {
+                type: it.type,
+                id: it.id,
+                label: it.label,
                 doc: it.doc
             }
         )
index e09160661b50a356422bc3d06a7e97434446573b..47e5b287ae7acb3fce265916da02964286b68748 100644 (file)
@@ -862,6 +862,16 @@ export const CollectionStatus = connect((state: RootState, props: { uuid: string
         : <Typography>head version</Typography>
 );
 
+export const CollectionName = connect((state: RootState, props: { uuid: string, className?: string }) => {
+    return {
+                collection: getResource<CollectionResource>(props.uuid)(state.resources),
+                uuid: props.uuid,
+                className: props.className,
+            };
+})((props: { collection: CollectionResource, uuid: string, className?: string }) =>
+        <Typography className={props.className}>{props.collection?.name || props.uuid}</Typography>
+);
+
 export const ProcessStatus = compose(
     connect((state: RootState, props: { uuid: string }) => {
         return { process: getProcess(props.uuid)(state.resources) };
index d1c6fc6072c70ec8032623922e386c0904e78b97..dd6e63bfc87fd8d9a77f082e303bce94714554cc 100644 (file)
@@ -10,7 +10,7 @@ import { TreeItem, TreeItemStatus } from 'components/tree/tree';
 import { ProjectResource } from "models/project";
 import { treePickerActions } from "store/tree-picker/tree-picker-actions";
 import { ListItemTextIcon } from "components/list-item-text-icon/list-item-text-icon";
-import { ProjectIcon, InputIcon, IconType, CollectionIcon } from 'components/icon/icon';
+import { ProjectIcon, FileInputIcon, IconType, CollectionIcon } from 'components/icon/icon';
 import { loadProject, loadCollection } from 'store/tree-picker/tree-picker-actions';
 import { GroupContentsResource } from 'services/groups-service/groups-service';
 import { CollectionDirectory, CollectionFile, CollectionFileType } from 'models/collection-file';
@@ -104,7 +104,7 @@ const getProjectPickerIcon = ({ data }: TreeItem<ProjectsTreePickerItem>, rootIc
     } else if ('type' in data) {
         switch (data.type) {
             case CollectionFileType.FILE:
-                return InputIcon;
+                return FileInputIcon;
             default:
                 return ProjectIcon;
         }
index 4af2c9cda75392cfd45f8707206913a5d56e3d95..4892eb33025bd7f841a1c6645fa3470af5300489 100644 (file)
@@ -9,13 +9,12 @@ import { formatDate } from "common/formatters";
 import { resourceLabel } from "common/labels";
 import { DetailsAttribute } from "components/details-attribute/details-attribute";
 import { ResourceKind } from "models/resource";
-import { ContainerRunTime, ResourceWithName } from "views-components/data-explorer/renderers";
+import { CollectionName, ContainerRunTime, ResourceWithName } from "views-components/data-explorer/renderers";
 import { getProcess, getProcessStatus } from "store/processes/process";
 import { RootState } from "store/store";
 import { connect } from "react-redux";
 import { ProcessResource } from "models/process";
 import { ContainerResource } from "models/container";
-import { openProcessInputDialog } from "store/processes/process-input-actions";
 import { navigateToOutput, openWorkflow } from "store/process-panel/process-panel-actions";
 import { ArvadosTheme } from "common/custom-theme";
 import { ProcessRuntimeStatus } from "views-components/process-runtime-status/process-runtime-status";
@@ -44,13 +43,11 @@ const mapStateToProps = (state: RootState, props: { request: ProcessResource })
 };
 
 interface ProcessDetailsAttributesActionProps {
-    openProcessInputDialog: (uuid: string) => void;
     navigateToOutput: (uuid: string) => void;
     openWorkflow: (uuid: string) => void;
 }
 
 const mapDispatchToProps = (dispatch: Dispatch): ProcessDetailsAttributesActionProps => ({
-    openProcessInputDialog: (uuid) => dispatch<any>(openProcessInputDialog(uuid)),
     navigateToOutput: (uuid) => dispatch<any>(navigateToOutput(uuid)),
     openWorkflow: (uuid) => dispatch<any>(openWorkflow(uuid)),
 });
@@ -62,6 +59,8 @@ export const ProcessDetailsAttributes = withStyles(styles, { withTheme: true })(
             const container = props.container;
             const classes = props.classes;
             const mdSize = props.twoCol ? 6 : 12;
+            const filteredPropertyKeys = Object.keys(containerRequest.properties)
+                                            .filter(k => (typeof containerRequest.properties[k] !== 'object'));
             return <Grid container>
                 <Grid item xs={12}>
                     <ProcessRuntimeStatus runtimeStatus={container?.runtimeStatus} containerCount={containerRequest.containerCount} />
@@ -105,12 +104,10 @@ export const ProcessDetailsAttributes = withStyles(styles, { withTheme: true })(
                     <DetailsAttribute label='Requesting Container UUID' value={containerRequest.requestingContainerUuid || "(none)"} />
                 </Grid>
                 <Grid item xs={6}>
-                    <span onClick={() => props.navigateToOutput(containerRequest.outputUuid!)}>
-                        <DetailsAttribute classLabel={classes.link} label='Outputs' />
-                    </span>
-                    <span onClick={() => props.openProcessInputDialog(containerRequest.uuid)}>
-                        <DetailsAttribute classLabel={classes.link} label='Inputs' />
-                    </span>
+                    <DetailsAttribute label='Output Collection' />
+                    {containerRequest.outputUuid && <span onClick={() => props.navigateToOutput(containerRequest.outputUuid!)}>
+                        <CollectionName className={classes.link} uuid={containerRequest.outputUuid} />
+                    </span>}
                 </Grid>
                 {containerRequest.properties.template_uuid &&
                     <Grid item xs={12} md={mdSize}>
@@ -128,8 +125,8 @@ export const ProcessDetailsAttributes = withStyles(styles, { withTheme: true })(
                 */}
                 <Grid item xs={12} md={12}>
                     <DetailsAttribute label='Properties' />
-                    {Object.keys(containerRequest.properties).length > 0
-                        ? Object.keys(containerRequest.properties).map(k =>
+                    {filteredPropertyKeys.length > 0
+                        ? filteredPropertyKeys.map(k =>
                             Array.isArray(containerRequest.properties[k])
                                 ? containerRequest.properties[k].map((v: string) =>
                                     getPropertyChip(k, v, undefined, classes.propertyTag))
diff --git a/src/views/process-panel/process-io-card.tsx b/src/views/process-panel/process-io-card.tsx
new file mode 100644 (file)
index 0000000..5fd444b
--- /dev/null
@@ -0,0 +1,682 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { ReactElement, useState } from 'react';
+import { Dispatch } from 'redux';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardHeader,
+    IconButton,
+    CardContent,
+    Tooltip,
+    Typography,
+    Tabs,
+    Tab,
+    Table,
+    TableHead,
+    TableBody,
+    TableRow,
+    TableCell,
+    Paper,
+    Grid,
+    Chip,
+    CircularProgress,
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { CloseIcon, ImageIcon, InputIcon, ImageOffIcon, OutputIcon, MaximizeIcon } 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';
+import { DefaultView } from 'components/default-view/default-view';
+import { getNavUrl } from 'routes/routes';
+import { Link as RouterLink } from 'react-router-dom';
+import { Link as MuiLink } from '@material-ui/core';
+import { InputCollectionMount } from 'store/processes/processes-actions';
+import { connect } from 'react-redux';
+import { RootState } from 'store/store';
+import { ProcessOutputCollectionFiles } from './process-output-collection-files';
+import { Process } from 'store/processes/process';
+import { navigateTo } from 'store/navigation/navigation-action';
+import classNames from 'classnames';
+import { DefaultCodeSnippet } from 'components/default-code-snippet/default-code-snippet';
+
+type CssRules =
+  | "card"
+  | "content"
+  | "title"
+  | "header"
+  | "avatar"
+  | "iconHeader"
+  | "tableWrapper"
+  | "tableRoot"
+  | "paramValue"
+  | "keepLink"
+  | "collectionLink"
+  | "imagePreview"
+  | "valArray"
+  | "secondaryVal"
+  | "secondaryRow"
+  | "emptyValue"
+  | "noBorderRow"
+  | "symmetricTabs"
+  | "imagePlaceholder"
+  | "rowWithPreview"
+  | "labelColumn";
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        height: '100%'
+    },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: 0,
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.green700,
+    },
+    avatar: {
+        alignSelf: 'flex-start',
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    content: {
+        height: `calc(100% - ${theme.spacing.unit * 7}px - ${theme.spacing.unit * 1.5}px)`,
+        padding: theme.spacing.unit * 1.0,
+        paddingTop: 0,
+        '&:last-child': {
+            paddingBottom: theme.spacing.unit * 1,
+        }
+    },
+    title: {
+        overflow: 'hidden',
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    tableWrapper: {
+        height: `calc(100% - ${theme.spacing.unit * 6}px)`,
+        overflow: 'auto',
+    },
+    tableRoot: {
+        width: '100%',
+        '& thead th': {
+            verticalAlign: 'bottom',
+            paddingBottom: '10px',
+        },
+        '& td, & th': {
+            paddingRight: '25px',
+        }
+    },
+    paramValue: {
+        display: 'flex',
+        alignItems: 'flex-start',
+        flexDirection: 'column',
+    },
+    keepLink: {
+        color: theme.palette.primary.main,
+        textDecoration: 'none',
+        overflowWrap: 'break-word',
+        cursor: 'pointer',
+    },
+    collectionLink: {
+        margin: '10px',
+        '& a': {
+            color: theme.palette.primary.main,
+            textDecoration: 'none',
+            overflowWrap: 'break-word',
+            cursor: 'pointer',
+        }
+    },
+    imagePreview: {
+        maxHeight: '15em',
+        maxWidth: '15em',
+        marginBottom: theme.spacing.unit,
+    },
+    valArray: {
+        display: 'flex',
+        gap: '10px',
+        flexWrap: 'wrap',
+        '& span': {
+            display: 'inline',
+        }
+    },
+    secondaryVal: {
+        paddingLeft: '20px',
+    },
+    secondaryRow: {
+        height: '29px',
+        verticalAlign: 'top',
+        position: 'relative',
+        top: '-9px',
+    },
+    emptyValue: {
+        color: theme.customs.colors.grey500,
+    },
+    noBorderRow: {
+        '& td': {
+            borderBottom: 'none',
+        }
+    },
+    symmetricTabs: {
+        '& button': {
+            flexBasis: '0',
+        }
+    },
+    imagePlaceholder: {
+        width: '60px',
+        height: '60px',
+        display: 'flex',
+        alignItems: 'center',
+        justifyContent: 'center',
+        backgroundColor: '#cecece',
+        borderRadius: '10px',
+    },
+    rowWithPreview: {
+        verticalAlign: 'bottom',
+    },
+    labelColumn: {
+        minWidth: '120px',
+    },
+});
+
+export enum ProcessIOCardType {
+    INPUT = 'Inputs',
+    OUTPUT = 'Outputs',
+}
+export interface ProcessIOCardDataProps {
+    process: Process;
+    label: ProcessIOCardType;
+    params: ProcessIOParameter[] | null;
+    raw: any;
+    mounts?: InputCollectionMount[];
+    outputUuid?: string;
+}
+
+export interface ProcessIOCardActionProps {
+    navigateTo: (uuid: string) => void;
+}
+
+const mapDispatchToProps = (dispatch: Dispatch): ProcessIOCardActionProps => ({
+    navigateTo: (uuid) => dispatch<any>(navigateTo(uuid)),
+});
+
+type ProcessIOCardProps = ProcessIOCardDataProps & ProcessIOCardActionProps & WithStyles<CssRules> & MPVPanelProps;
+
+export const ProcessIOCard = withStyles(styles)(connect(null, mapDispatchToProps)(
+    ({ classes, label, params, raw, mounts, outputUuid, doHidePanel, doMaximizePanel, panelMaximized, panelName, process, navigateTo }: ProcessIOCardProps) => {
+        const [mainProcTabState, setMainProcTabState] = useState(0);
+        const handleMainProcTabChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+            setMainProcTabState(value);
+        }
+
+        const [showImagePreview, setShowImagePreview] = useState(false);
+
+        const PanelIcon = label === ProcessIOCardType.INPUT ? InputIcon : OutputIcon;
+        const mainProcess = !process.containerRequest.requestingContainerUuid;
+
+        const loading = raw === null || raw === undefined || params === null;
+        const hasRaw = !!(raw && Object.keys(raw).length > 0);
+        const hasParams = !!(params && params.length > 0);
+
+        return <Card className={classes.card} data-cy="process-io-card">
+            <CardHeader
+                className={classes.header}
+                classes={{
+                    content: classes.title,
+                    avatar: classes.avatar,
+                }}
+                avatar={<PanelIcon className={classes.iconHeader} />}
+                title={
+                    <Typography noWrap variant='h6' color='inherit'>
+                        {label}
+                    </Typography>
+                }
+                action={
+                    <div>
+                        { mainProcess && <Tooltip title={"Toggle Image Preview"} disableFocusListener>
+                            <IconButton data-cy="io-preview-image-toggle" onClick={() =>{setShowImagePreview(!showImagePreview)}}>{showImagePreview ? <ImageIcon /> : <ImageOffIcon />}</IconButton>
+                        </Tooltip> }
+                        { doMaximizePanel && !panelMaximized &&
+                        <Tooltip title={`Maximize ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton onClick={doMaximizePanel}><MaximizeIcon /></IconButton>
+                        </Tooltip> }
+                        { doHidePanel &&
+                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
+                        </Tooltip> }
+                    </div>
+                } />
+            <CardContent className={classes.content}>
+                {mainProcess ?
+                    (<>
+                        {/* raw is undefined until params are loaded */}
+                        {loading && <Grid container item alignItems='center' justify='center'>
+                            <CircularProgress />
+                        </Grid>}
+                        {/* Once loaded, either raw or params may still be empty
+                          *   Raw when all params are empty
+                          *   Params when raw is provided by containerRequest properties but workflow mount is absent for preview
+                          */}
+                        {(!loading && (hasRaw || hasParams)) &&
+                            <>
+                                <Tabs value={mainProcTabState} onChange={handleMainProcTabChange} variant="fullWidth" className={classes.symmetricTabs}>
+                                    {/* params will be empty on processes without workflow definitions in mounts, so we only show raw */}
+                                    {hasParams && <Tab label="Parameters" />}
+                                    <Tab label="JSON" />
+                                </Tabs>
+                                {(mainProcTabState === 0 && params && hasParams) && <div className={classes.tableWrapper}>
+                                        <ProcessIOPreview data={params} showImagePreview={showImagePreview} />
+                                    </div>}
+                                {(mainProcTabState === 1 || !hasParams) && <div className={classes.tableWrapper}>
+                                        <ProcessIORaw data={raw} />
+                                    </div>}
+                            </>}
+                        {!loading && !hasRaw && !hasParams && <Grid container item alignItems='center' justify='center'>
+                            <DefaultView messages={["No parameters found"]} />
+                        </Grid>}
+                    </>) :
+                    // Subprocess
+                    (<>
+                        {((mounts && mounts.length) || outputUuid) ?
+                            <>
+                                <Tabs value={0} variant="fullWidth" className={classes.symmetricTabs}>
+                                    {label === ProcessIOCardType.INPUT && <Tab label="Collections" />}
+                                    {label === ProcessIOCardType.OUTPUT && <Tab label="Collection" />}
+                                </Tabs>
+                                <div className={classes.tableWrapper}>
+                                    {label === ProcessIOCardType.INPUT && <ProcessInputMounts mounts={mounts || []} />}
+                                    {label === ProcessIOCardType.OUTPUT && <>
+                                        {outputUuid && <Typography className={classes.collectionLink}>
+                                            Output Collection: <MuiLink className={classes.keepLink} onClick={() => {navigateTo(outputUuid || "")}}>
+                                            {outputUuid}
+                                        </MuiLink></Typography>}
+                                        <ProcessOutputCollectionFiles isWritable={false} currentItemUuid={outputUuid} />
+                                    </>}
+                                </div>
+                            </> :
+                            <Grid container item alignItems='center' justify='center'>
+                                <DefaultView messages={["No collection(s) found"]} />
+                            </Grid>
+                        }
+                    </>)
+                }
+            </CardContent>
+        </Card>;
+    }
+));
+
+export type ProcessIOValue = {
+    display: ReactElement<any, any>;
+    imageUrl?: string;
+    collection?: ReactElement<any, any>;
+    secondary?: boolean;
+}
+
+export type ProcessIOParameter = {
+    id: string;
+    label: string;
+    value: ProcessIOValue[];
+}
+
+interface ProcessIOPreviewDataProps {
+    data: ProcessIOParameter[];
+    showImagePreview: boolean;
+}
+
+type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
+
+const ProcessIOPreview = withStyles(styles)(
+    ({ classes, data, showImagePreview }: ProcessIOPreviewProps) => {
+        const showLabel = data.some((param: ProcessIOParameter) => param.label);
+        return <Table className={classes.tableRoot} aria-label="Process IO Preview">
+            <TableHead>
+                <TableRow>
+                    <TableCell>Name</TableCell>
+                    {showLabel && <TableCell className={classes.labelColumn}>Label</TableCell>}
+                    <TableCell>Value</TableCell>
+                    <TableCell>Collection</TableCell>
+                </TableRow>
+            </TableHead>
+            <TableBody>
+                {data.map((param: ProcessIOParameter) => {
+                    const firstVal = param.value.length > 0 ? param.value[0] : undefined;
+                    const rest = param.value.slice(1);
+                    const mainRowClasses = {
+                        [classes.noBorderRow]: (rest.length > 0),
+                    };
+
+                    return <>
+                        <TableRow className={classNames(mainRowClasses)} data-cy="process-io-param">
+                            <TableCell>
+                                {param.id}
+                            </TableCell>
+                            {showLabel && <TableCell >{param.label}</TableCell>}
+                            <TableCell>
+                                {firstVal && <ProcessValuePreview value={firstVal} showImagePreview={showImagePreview} />}
+                            </TableCell>
+                            <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
+                                <Typography className={classes.paramValue}>
+                                    {firstVal?.collection}
+                                </Typography>
+                            </TableCell>
+                        </TableRow>
+                        {rest.map((val, i) => {
+                            const rowClasses = {
+                                [classes.noBorderRow]: (i < rest.length-1),
+                                [classes.secondaryRow]: val.secondary,
+                            };
+                            return <TableRow className={classNames(rowClasses)}>
+                                <TableCell />
+                                {showLabel && <TableCell />}
+                                <TableCell>
+                                    <ProcessValuePreview value={val} showImagePreview={showImagePreview} />
+                                </TableCell>
+                                <TableCell className={firstVal?.imageUrl ? classes.rowWithPreview : undefined}>
+                                    <Typography className={classes.paramValue}>
+                                        {val.collection}
+                                    </Typography>
+                                </TableCell>
+                            </TableRow>
+                        })}
+                    </>;
+                })}
+            </TableBody>
+        </Table>;
+});
+
+interface ProcessValuePreviewProps {
+    value: ProcessIOValue;
+    showImagePreview: boolean;
+}
+
+const ProcessValuePreview = withStyles(styles)(
+    ({value, showImagePreview, classes}: ProcessValuePreviewProps & WithStyles<CssRules>) =>
+        <Typography className={classes.paramValue}>
+            {value.imageUrl && showImagePreview ? <img className={classes.imagePreview} src={value.imageUrl} alt="Inline Preview" /> : ""}
+            {value.imageUrl && !showImagePreview ? <ImagePlaceholder /> : ""}
+            <span className={classNames(classes.valArray, value.secondary && classes.secondaryVal)}>
+                {value.display}
+            </span>
+        </Typography>
+)
+
+interface ProcessIORawDataProps {
+    data: ProcessIOParameter[];
+}
+
+const ProcessIORaw = withStyles(styles)(
+    ({ data }: ProcessIORawDataProps) =>
+        <Paper elevation={0}>
+            <DefaultCodeSnippet lines={[JSON.stringify(data, null, 2)]} linked />
+        </Paper>
+);
+
+interface ProcessInputMountsDataProps {
+    mounts: InputCollectionMount[];
+}
+
+type ProcessInputMountsProps = ProcessInputMountsDataProps & WithStyles<CssRules>;
+
+const ProcessInputMounts = withStyles(styles)(connect((state: RootState) => ({
+    auth: state.auth,
+}))(({ mounts, classes, auth }: ProcessInputMountsProps & { auth: AuthState }) => (
+    <Table className={classes.tableRoot} aria-label="Process Input Mounts">
+        <TableHead>
+            <TableRow>
+                <TableCell>Path</TableCell>
+                <TableCell>Portable Data Hash</TableCell>
+            </TableRow>
+        </TableHead>
+        <TableBody>
+            {mounts.map(mount => (
+                <TableRow key={mount.path}>
+                    <TableCell><pre>{mount.path}</pre></TableCell>
+                    <TableCell>
+                        <RouterLink to={getNavUrl(mount.pdh, auth)} className={classes.keepLink}>{mount.pdh}</RouterLink>
+                    </TableCell>
+                </TableRow>
+            ))}
+        </TableBody>
+    </Table>
+)));
+
+type FileWithSecondaryFiles = {
+    secondaryFiles: File[];
+}
+
+export const getIOParamDisplayValue = (auth: AuthState, input: CommandInputParameter | CommandOutputParameter, pdh?: string): ProcessIOValue[] => {
+    switch (true) {
+        case isPrimitiveOfType(input, CWLType.BOOLEAN):
+            const boolValue = (input as BooleanCommandInputParameter).value;
+
+            return boolValue !== undefined &&
+                    !(Array.isArray(boolValue) && boolValue.length === 0) ?
+                [{display: <pre>{String(boolValue)}</pre> }] :
+                [{display: <EmptyValue />}];
+
+        case isPrimitiveOfType(input, CWLType.INT):
+        case isPrimitiveOfType(input, CWLType.LONG):
+            const intValue = (input as IntCommandInputParameter).value;
+
+            return intValue !== undefined &&
+                    // Missing values are empty array
+                    !(Array.isArray(intValue) && intValue.length === 0) ?
+                [{display: <pre>{String(intValue)}</pre> }]
+                : [{display: <EmptyValue />}];
+
+        case isPrimitiveOfType(input, CWLType.FLOAT):
+        case isPrimitiveOfType(input, CWLType.DOUBLE):
+            const floatValue = (input as FloatCommandInputParameter).value;
+
+            return floatValue !== undefined &&
+                    !(Array.isArray(floatValue) && floatValue.length === 0) ?
+                [{display: <pre>{String(floatValue)}</pre> }]:
+                [{display: <EmptyValue />}];
+
+        case isPrimitiveOfType(input, CWLType.STRING):
+            const stringValue = (input as StringCommandInputParameter).value || undefined;
+
+            return stringValue !== undefined &&
+                    !(Array.isArray(stringValue) && stringValue.length === 0) ?
+                [{display: <pre>{stringValue}</pre> }] :
+                [{display: <EmptyValue />}];
+
+        case isPrimitiveOfType(input, CWLType.FILE):
+            const mainFile = (input as FileCommandInputParameter).value;
+            // secondaryFiles: File[] is not part of CommandOutputParameter so we cast to access secondaryFiles
+            const secondaryFiles = ((mainFile as unknown) as FileWithSecondaryFiles)?.secondaryFiles || [];
+            const files = [
+                ...(mainFile && !(Array.isArray(mainFile) && mainFile.length === 0) ? [mainFile] : []),
+                ...secondaryFiles
+            ];
+            const mainFilePdhUrl = mainFile ? getResourcePdhUrl(mainFile, pdh) : "";
+
+            return files.length ?
+                files.map((file, i) => fileToProcessIOValue(file, (i > 0), auth, pdh, (i > 0 ? mainFilePdhUrl : ""))) :
+                [{display: <EmptyValue />}];
+
+        case isPrimitiveOfType(input, CWLType.DIRECTORY):
+            const directory = (input as DirectoryCommandInputParameter).value;
+
+            return directory !== undefined &&
+                    !(Array.isArray(directory) && directory.length === 0) ?
+                [directoryToProcessIOValue(directory, auth, pdh)] :
+                [{display: <EmptyValue />}];
+
+        case typeof input.type === 'object' &&
+            !(input.type instanceof Array) &&
+            input.type.type === 'enum':
+            const enumValue = (input as EnumCommandInputParameter).value;
+
+            return enumValue !== undefined ?
+                [{ display: <pre>{(input as EnumCommandInputParameter).value || ''}</pre> }] :
+                [{display: <EmptyValue />}];
+
+        case isArrayOfType(input, CWLType.STRING):
+            const strArray = (input as StringArrayCommandInputParameter).value || [];
+            return strArray.length ?
+                [{ display: <>{((input as StringArrayCommandInputParameter).value || []).map((val) => <Chip label={val} />)}</> }] :
+                [{display: <EmptyValue />}];
+
+        case isArrayOfType(input, CWLType.INT):
+        case isArrayOfType(input, CWLType.LONG):
+            const intArray = (input as IntArrayCommandInputParameter).value || [];
+
+            return intArray.length ?
+                [{ display: <>{((input as IntArrayCommandInputParameter).value || []).map((val) => <Chip label={val} />)}</> }] :
+                [{display: <EmptyValue />}];
+
+        case isArrayOfType(input, CWLType.FLOAT):
+        case isArrayOfType(input, CWLType.DOUBLE):
+            const floatArray = (input as FloatArrayCommandInputParameter).value || [];
+
+            return floatArray.length ?
+                [{ display: <>{floatArray.map((val) => <Chip label={val} />)}</> }] :
+                [{display: <EmptyValue />}];
+
+        case isArrayOfType(input, CWLType.FILE):
+            const fileArrayMainFiles = ((input as FileArrayCommandInputParameter).value || []);
+            const firstMainFilePdh = (fileArrayMainFiles.length > 0 && fileArrayMainFiles[0]) ? getResourcePdhUrl(fileArrayMainFiles[0], pdh) : "";
+
+            // Convert each main file into separate arrays of ProcessIOValue to preserve secondaryFile grouping
+            const fileArrayValues = fileArrayMainFiles.map((mainFile: File, i): ProcessIOValue[] => {
+                const secondaryFiles = ((mainFile as unknown) as FileWithSecondaryFiles)?.secondaryFiles || [];
+                return [
+                    // Pass firstMainFilePdh to secondary files and every main file besides the first to hide pdh if equal
+                    ...(mainFile ? [fileToProcessIOValue(mainFile, false, auth, pdh, i > 0 ? firstMainFilePdh : "")] : []),
+                    ...(secondaryFiles.map(file => fileToProcessIOValue(file, true, auth, pdh, firstMainFilePdh)))
+                ];
+            // Reduce each mainFile/secondaryFile group into single array preserving ordering
+            }).reduce((acc: ProcessIOValue[], mainFile: ProcessIOValue[]) => (acc.concat(mainFile)), []);
+
+            return fileArrayValues.length ?
+                fileArrayValues :
+                [{display: <EmptyValue />}];
+
+        case isArrayOfType(input, CWLType.DIRECTORY):
+            const directories = (input as DirectoryArrayCommandInputParameter).value || [];
+
+            return directories.length ?
+                directories.map(directory => directoryToProcessIOValue(directory, auth, pdh)) :
+                [{display: <EmptyValue />}];
+
+        default:
+            return [];
+    }
+};
+
+/*
+ * @returns keep url without keep: prefix
+ */
+const getKeepUrl = (file: File | Directory, pdh?: string): string => {
+    const isKeepUrl = file.location?.startsWith('keep:') || false;
+    const keepUrl = isKeepUrl ?
+                        file.location?.replace('keep:', '') :
+                        pdh ? `${pdh}/${file.location}` : file.location;
+    return keepUrl || '';
+};
+
+interface KeepUrlProps {
+    auth: AuthState;
+    res: File | Directory;
+    pdh?: string;
+}
+
+const getResourcePdhUrl = (res: File | Directory, pdh?: string): string => {
+    const keepUrl = getKeepUrl(res, pdh);
+    return keepUrl ? keepUrl.split('/').slice(0, 1)[0] : '';
+};
+
+const KeepUrlBase = withStyles(styles)(({auth, res, pdh, classes}: KeepUrlProps & WithStyles<CssRules>) => {
+    const pdhUrl = getResourcePdhUrl(res, pdh);
+    // Passing a pdh always returns a relative wb2 collection url
+    const pdhWbPath = getNavUrl(pdhUrl, auth);
+    return pdhUrl && pdhWbPath ?
+        <Tooltip title={"View collection in Workbench"}><RouterLink to={pdhWbPath} className={classes.keepLink}>{pdhUrl}</RouterLink></Tooltip> :
+        <></>;
+});
+
+const KeepUrlPath = withStyles(styles)(({auth, res, pdh, classes}: KeepUrlProps & WithStyles<CssRules>) => {
+    const keepUrl = getKeepUrl(res, pdh);
+    const keepUrlParts = keepUrl ? keepUrl.split('/') : [];
+    const keepUrlPath = keepUrlParts.length > 1 ? keepUrlParts.slice(1).join('/') : '';
+
+    const keepUrlPathNav = getKeepNavUrl(auth, res, pdh);
+    return keepUrlPathNav ?
+        <Tooltip title={"View in keep-web"}><a className={classes.keepLink} href={keepUrlPathNav} target="_blank" rel="noopener noreferrer">{keepUrlPath || '/'}</a></Tooltip> :
+        <EmptyValue />;
+});
+
+const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
+    let keepUrl = getKeepUrl(file, pdh);
+    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);
+    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;
+};
+
+const normalizeDirectoryLocation = (directory: Directory): Directory => {
+    if (!directory.location) {
+        return directory;
+    }
+    return {
+        ...directory,
+        location: (directory.location || '').endsWith('/') ? directory.location : directory.location + '/',
+    };
+};
+
+const directoryToProcessIOValue = (directory: Directory, auth: AuthState, pdh?: string): ProcessIOValue => {
+    const normalizedDirectory = normalizeDirectoryLocation(directory);
+    return {
+        display: <KeepUrlPath auth={auth} res={normalizedDirectory} pdh={pdh}/>,
+        collection: <KeepUrlBase auth={auth} res={normalizedDirectory} pdh={pdh}/>,
+    };
+};
+
+const fileToProcessIOValue = (file: File, secondary: boolean, auth: AuthState, pdh: string | undefined, mainFilePdh: string): ProcessIOValue => {
+    const resourcePdh = getResourcePdhUrl(file, pdh);
+    return {
+        display: <KeepUrlPath auth={auth} res={file} pdh={pdh}/>,
+        secondary,
+        imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
+        collection: (resourcePdh !== mainFilePdh) ? <KeepUrlBase auth={auth} res={file} pdh={pdh}/> : <></>,
+    }
+};
+
+const EmptyValue = withStyles(styles)(
+    ({classes}: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</span>
+);
+
+const ImagePlaceholder = withStyles(styles)(
+    ({classes}: WithStyles<CssRules>) => <span className={classes.imagePlaceholder}><ImageIcon /></span>
+);
diff --git a/src/views/process-panel/process-output-collection-files.ts b/src/views/process-panel/process-output-collection-files.ts
new file mode 100644 (file)
index 0000000..d0b44cd
--- /dev/null
@@ -0,0 +1,58 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import { connect } from "react-redux";
+import {
+    CollectionPanelFiles as Component,
+    CollectionPanelFilesProps
+} from "components/collection-panel-files/collection-panel-files";
+import { Dispatch } from "redux";
+import { collectionPanelFilesAction } from "store/collection-panel/collection-panel-files/collection-panel-files-actions";
+import { ContextMenuKind } from "views-components/context-menu/context-menu";
+import { openContextMenu, openCollectionFilesContextMenu } from 'store/context-menu/context-menu-actions';
+import { openUploadCollectionFilesDialog } from 'store/collections/collection-upload-actions';
+import { ResourceKind } from "models/resource";
+import { openDetailsPanel } from 'store/details-panel/details-panel-action';
+
+const mapDispatchToProps = (dispatch: Dispatch): Pick<CollectionPanelFilesProps, 'onSearchChange' | 'onFileClick' | 'onUploadDataClick' | 'onCollapseToggle' | 'onSelectionToggle' | 'onItemMenuOpen' | 'onOptionsMenuOpen'> => ({
+    onUploadDataClick: (targetLocation?: string) => {
+        dispatch<any>(openUploadCollectionFilesDialog(targetLocation));
+    },
+    onCollapseToggle: (id) => {
+        dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_COLLAPSE({ id }));
+    },
+    onSelectionToggle: (event, item) => {
+        dispatch(collectionPanelFilesAction.TOGGLE_COLLECTION_FILE_SELECTION({ id: item.id }));
+    },
+    onItemMenuOpen: (event, item, isWritable) => {
+        const isDirectory = item.data.type === 'directory';
+        dispatch<any>(openContextMenu(
+            event,
+            {
+                menuKind: isWritable
+                    ? isDirectory
+                        ? ContextMenuKind.COLLECTION_DIRECTORY_ITEM
+                        : ContextMenuKind.COLLECTION_FILE_ITEM
+                    : isDirectory
+                        ? ContextMenuKind.READONLY_COLLECTION_DIRECTORY_ITEM
+                        : ContextMenuKind.READONLY_COLLECTION_FILE_ITEM,
+                kind: ResourceKind.COLLECTION,
+                name: item.data.name,
+                uuid: item.id,
+                ownerUuid: ''
+            }
+        ));
+    },
+    onSearchChange: (searchValue: string) => {
+        dispatch(collectionPanelFilesAction.ON_SEARCH_CHANGE(searchValue));
+    },
+    onOptionsMenuOpen: (event, isWritable) => {
+        dispatch<any>(openCollectionFilesContextMenu(event, isWritable));
+    },
+    onFileClick: (id) => {
+        dispatch<any>(openDetailsPanel(id));
+    },
+});
+
+export const ProcessOutputCollectionFiles = connect(null, mapDispatchToProps)(Component);
index f8ff84304dcb3fb4acc7554ef0f26882ef9cc6d6..bc485d9f9dac5c454a3f82f51ebc6d9d03c8ab73 100644 (file)
@@ -12,10 +12,18 @@ 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 { ProcessIOCard, ProcessIOCardType, 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 { getInputCollectionMounts } from 'store/processes/processes-actions';
+import { CommandInputParameter } from 'models/workflow';
+import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
+import { AuthState } from 'store/auth/auth-reducer';
 import { ProcessCmdCard } from './process-cmd-card';
+import { ContainerRequestResource } from 'models/container-request';
+import { OutputDetails } from 'store/process-panel/process-panel';
 
 type CssRules = 'root';
 
@@ -30,6 +38,12 @@ export interface ProcessPanelRootDataProps {
     subprocesses: Array<Process>;
     filters: Array<SubprocessFilterDataProps>;
     processLogsPanel: ProcessLogsPanel;
+    auth: AuthState;
+    inputRaw: CommandInputParameter[] | null;
+    inputParams: ProcessIOParameter[] | null;
+    outputRaw: OutputDetails | null;
+    outputDefinitions: CommandOutputParameter[];
+    outputParams: ProcessIOParameter[] | null;
 }
 
 export interface ProcessPanelRootActionProps {
@@ -39,6 +53,10 @@ export interface ProcessPanelRootActionProps {
     onLogFilterChange: (filter: FilterOption) => void;
     navigateToLog: (uuid: string) => void;
     onCopyToClipboard: (uuid: string) => void;
+    loadInputs: (containerRequest: ContainerRequestResource) => void;
+    loadOutputs: (containerRequest: ContainerRequestResource) => void;
+    loadOutputDefinitions: (containerRequest: ContainerRequestResource) => void;
+    updateOutputParams: () => void;
 }
 
 export type ProcessPanelRootProps = ProcessPanelRootDataProps & ProcessPanelRootActionProps & WithStyles<CssRules>;
@@ -47,12 +65,49 @@ const panelsData: MPVPanelState[] = [
     {name: "Details"},
     {name: "Command"},
     {name: "Logs", visible: true},
+    {name: "Inputs"},
+    {name: "Outputs"},
     {name: "Subprocesses"},
 ];
 
 export const ProcessPanelRoot = withStyles(styles)(
-    ({ process, processLogsPanel, ...props }: ProcessPanelRootProps) =>
-    process
+    ({
+        process,
+        auth,
+        processLogsPanel,
+        inputRaw,
+        inputParams,
+        outputRaw,
+        outputDefinitions,
+        outputParams,
+        loadInputs,
+        loadOutputs,
+        loadOutputDefinitions,
+        updateOutputParams,
+        ...props
+    }: ProcessPanelRootProps) => {
+
+    const outputUuid = process?.containerRequest.outputUuid;
+    const containerRequest = process?.containerRequest;
+    const inputMounts = getInputCollectionMounts(process?.containerRequest);
+
+    React.useEffect(() => {
+        if (containerRequest) {
+            // Load inputs from mounts or props
+            loadInputs(containerRequest);
+            // Fetch raw output (loads from props or keep)
+            loadOutputs(containerRequest);
+            // Loads output definitions from mounts into store
+            loadOutputDefinitions(containerRequest);
+        }
+    }, [containerRequest, loadInputs, loadOutputs, loadOutputDefinitions]);
+
+    // Trigger processing output params when raw or definitions change
+    React.useEffect(() => {
+        updateOutputParams();
+    }, [outputRaw, outputDefinitions, updateOutputParams]);
+
+    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
@@ -82,6 +137,24 @@ export const ProcessPanelRoot = withStyles(styles)(
                     navigateToLog={props.navigateToLog}
                 />
             </MPVPanelContent>
+            <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-inputs">
+                <ProcessIOCard
+                    label={ProcessIOCardType.INPUT}
+                    process={process}
+                    params={inputParams}
+                    raw={inputRaw}
+                    mounts={inputMounts}
+                 />
+            </MPVPanelContent>
+            <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-outputs">
+                <ProcessIOCard
+                    label={ProcessIOCardType.OUTPUT}
+                    process={process}
+                    params={outputParams}
+                    raw={outputRaw?.rawOutputs}
+                    outputUuid={outputUuid || ""}
+                 />
+            </MPVPanelContent>
             <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-children">
                 <SubprocessPanel />
             </MPVPanelContent>
@@ -93,4 +166,6 @@ export const ProcessPanelRoot = withStyles(styles)(
             <DefaultView
                 icon={ProcessIcon}
                 messages={['Process not found']} />
-        </Grid>);
+        </Grid>;
+    }
+);
index 7afaa04d94b95f90e47181f2a449985c0879fd62..6e2d75c6cb6c4f551f0aab704c2dd583387ccbe9 100644 (file)
@@ -18,13 +18,17 @@ import {
 } from 'store/process-panel/process-panel';
 import { groupBy } from 'lodash';
 import {
+    loadInputs,
+    loadOutputDefinitions,
+    loadOutputs,
     toggleProcessPanelFilter,
+    updateOutputParams,
 } 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 +36,12 @@ const mapStateToProps = ({ router, resources, processPanel, processLogsPanel }:
         subprocesses: subprocesses.filter(subprocess => processPanel.filters[getProcessStatus(subprocess)]),
         filters: getFilters(processPanel, subprocesses),
         processLogsPanel: processLogsPanel,
+        auth: auth,
+        inputRaw: processPanel.inputRaw,
+        inputParams: processPanel.inputParams,
+        outputRaw: processPanel.outputRaw,
+        outputDefinitions: processPanel.outputDefinitions,
+        outputParams: processPanel.outputParams,
     };
 };
 
@@ -52,6 +62,10 @@ const mapDispatchToProps = (dispatch: Dispatch): ProcessPanelRootActionProps =>
     cancelProcess: (uuid) => dispatch<any>(cancelRunningWorkflow(uuid)),
     onLogFilterChange: (filter) => dispatch(setProcessLogsPanelFilter(filter.value)),
     navigateToLog: (uuid) => dispatch<any>(navigateToLogCollection(uuid)),
+    loadInputs: (containerRequest) => dispatch<any>(loadInputs(containerRequest)),
+    loadOutputs: (containerRequest) => dispatch<any>(loadOutputs(containerRequest)),
+    loadOutputDefinitions: (containerRequest) => dispatch<any>(loadOutputDefinitions(containerRequest)),
+    updateOutputParams: () => dispatch<any>(updateOutputParams())
 });
 
 const getFilters = (processPanel: ProcessPanelState, processes: Process[]) => {
index d7156e182e932ab8411d7249f77e2cfb968af21f..9487ee1fd35abb7d04260fa8aaa9fee0063a346b 100644 (file)
@@ -12,7 +12,7 @@ Clusters:
       CollectionVersioning: true
       PreserveVersionIfIdle: -1s
       BlobSigningKey: zfhgfenhffzltr9dixws36j1yhksjoll2grmku38mi7yxd66h5j4q9w4jzanezacp8s6q0ro3hxakfye02152hncy6zml2ed0uc
-      TrustAllContent: false
+      TrustAllContent: true
       ForwardSlashNameSubstitution: /
       ManagedProperties:
         original_owner_uuid: {Function: original_owner, Protected: true}
index db7bf6ead1e30dba617cb653cd7cc9939e8d32af..924540208807fca8d7f7a489e8889919bddf28de 100644 (file)
--- a/yarn.lock
+++ b/yarn.lock
@@ -3777,6 +3777,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
@@ -12005,6 +12006,15 @@ __metadata:
   languageName: node
   linkType: hard
 
+"mime@npm:^3.0.0":
+  version: 3.0.0
+  resolution: "mime@npm:3.0.0"
+  bin:
+    mime: cli.js
+  checksum: f43f9b7bfa64534e6b05bd6062961681aeb406a5b53673b53b683f27fcc4e739989941836a355eef831f4478923651ecc739f4a5f6e20a76487b432bfd4db928
+  languageName: node
+  linkType: hard
+
 "mimic-fn@npm:^1.0.0":
   version: 1.2.0
   resolution: "mimic-fn@npm:1.2.0"