Merge branch 'main' of git.arvados.org:arvados-workbench2 into 16073-process-io-panels
authorStephen Smith <stephen@curii.com>
Tue, 30 Aug 2022 22:04:05 +0000 (18:04 -0400)
committerStephen Smith <stephen@curii.com>
Tue, 30 Aug 2022 22:04:05 +0000 (18:04 -0400)
Arvados-DCO-1.1-Signed-off-by: Stephen Smith <stephen@curii.com>

19 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/models/workflow.ts
src/services/collection-service/collection-service.ts
src/store/process-panel/process-panel-actions.ts
src/store/processes/processes-actions.ts
src/views-components/data-explorer/renderers.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..c119997d36b7dfff787e5d7dae21c78b7dd0acf0 100644 (file)
@@ -274,4 +274,709 @@ 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, doc, val) => {
+        cy.get('table tr').contains(name).parents('tr').within(() => {
+            doc && cy.contains(doc);
+            if (val) {
+                if (Array.isArray(val)) {
+                    val.forEach(v => cy.contains(v));
+                } else {
+                    cy.contains(val);
+                }
+            }
+        });
+    };
+
+    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(() => {
+                    cy.wait(2000);
+                    cy.waitForDom();
+                    verifyIOParameter('input_file', "Label Description", 'keep:00000000000000000000000000000000+01/input1.tar');
+                    verifyIOParameter('input_file', "Label Description", 'keep:00000000000000000000000000000000+01/input1-2.txt');
+                    verifyIOParameter('input_file', "Label Description", 'keep:00000000000000000000000000000000+01/input1-3.txt');
+                    verifyIOParameter('input_file', "Label Description", 'keep:00000000000000000000000000000000+01/input1-4.txt');
+                    verifyIOParameter('input_dir', "Doc Description", 'keep:11111111111111111111111111111111+01/');
+                    verifyIOParameter('input_bool', "Doc desc 1, Doc desc 2", 'true');
+                    verifyIOParameter('input_int', null, '1');
+                    verifyIOParameter('input_long', null, '1');
+                    verifyIOParameter('input_float', null, '1.5');
+                    verifyIOParameter('input_double', null, '1.3');
+                    verifyIOParameter('input_string', null, 'Hello World');
+                    verifyIOParameter('input_file_array', null, 'keep:00000000000000000000000000000000+02/input2.tar');
+                    verifyIOParameter('input_file_array', null, 'keep:00000000000000000000000000000000+03/input3.tar');
+                    verifyIOParameter('input_file_array', null, 'keep:00000000000000000000000000000000+03/input3-2.txt');
+                    verifyIOParameter('input_dir_array', null, 'keep:11111111111111111111111111111111+02/');
+                    verifyIOParameter('input_dir_array', null, 'keep:11111111111111111111111111111111+03/');
+                    verifyIOParameter('input_int_array', null, ["1", "3", "5"]);
+                    verifyIOParameter('input_long_array', null, ["10", "20"]);
+                    verifyIOParameter('input_float_array', null, ["10.2", "10.4", "10.6"]);
+                    verifyIOParameter('input_double_array', null, ["20.1", "20.2", "20.3"]);
+                    verifyIOParameter('input_string_array', 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();
+                    const outPdh = testOutputCollection.portable_data_hash;
+
+                    verifyIOParameter('output_file', "Label Description", `keep:${outPdh}/cat.png`);
+                    verifyIOParameterImage('output_file', `/c=${outPdh}/cat.png`);
+                    verifyIOParameter('output_file_with_secondary', "Doc Description", `keep:${outPdh}/main.dat`);
+                    verifyIOParameter('output_file_with_secondary', "Doc Description", `keep:${outPdh}/secondary.dat`);
+                    verifyIOParameter('output_file_with_secondary', "Doc Description", `keep:${outPdh}/secondary2.dat`);
+                    verifyIOParameter('output_dir', "Doc desc 1, Doc desc 2", `keep:${outPdh}/outdir1`);
+                    verifyIOParameter('output_bool', null, 'true');
+                    verifyIOParameter('output_int', null, '1');
+                    verifyIOParameter('output_long', null, '1');
+                    verifyIOParameter('output_float', null, '100.5');
+                    verifyIOParameter('output_double', null, '100.3');
+                    verifyIOParameter('output_string', null, 'Hello output');
+                    verifyIOParameter('output_file_array', null, `keep:${outPdh}/output2.tar`);
+                    verifyIOParameter('output_file_array', null, `keep:${outPdh}/output3.tar`);
+                    verifyIOParameter('output_dir_array', null, `keep:${outPdh}/outdir2`);
+                    verifyIOParameter('output_dir_array', null, `keep:${outPdh}/outdir3`);
+                    verifyIOParameter('output_int_array', null, ["10", "11", "12"]);
+                    verifyIOParameter('output_long_array', null, ["51", "52"]);
+                    verifyIOParameter('output_float_array', null, ["100.2", "100.4", "100.6"]);
+                    verifyIOParameter('output_double_array', null, ["100.1", "100.2", "100.3"]);
+                    verifyIOParameter('output_string_array', 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 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..d21b9b83d0669108b2bf017b72bb43cb44cfb9c4 100644 (file)
@@ -14,6 +14,7 @@ import { SnackbarKind } from '../snackbar/snackbar-actions';
 import { showWorkflowDetails } from 'store/workflow-panel/workflow-panel-actions';
 import { loadSubprocessPanel } from "../subprocess-panel/subprocess-panel-actions";
 import { initProcessLogsPanel, processLogsPanelActions } from "store/process-logs-panel/process-logs-panel-actions";
+import { CollectionFile } from "models/collection-file";
 
 export const processPanelActions = unionize({
     SET_PROCESS_PANEL_CONTAINER_REQUEST_UUID: ofType<string>(),
@@ -45,6 +46,26 @@ export const navigateToOutput = (uuid: string) =>
         }
     };
 
+export const loadOutputs = (uuid: string, setOutputs) =>
+    async (dispatch: Dispatch<any>, getState: () => RootState, services: ServiceRepository) => {
+        try {
+            const files = await services.collectionService.files(uuid);
+            const collection = await services.collectionService.get(uuid);
+            const outputFile = files.find((file) => file.name === 'cwl.output.json') as CollectionFile | undefined;
+            let outputData = outputFile ? await services.collectionService.getFileContents(outputFile) : undefined;
+            if ((outputData = JSON.parse(outputData)) && collection.portableDataHash) {
+                setOutputs({
+                    rawOutputs: outputData,
+                    pdh: collection.portableDataHash,
+                });
+            } else {
+                setOutputs({});
+            }
+        } catch {
+            setOutputs({});
+        }
+    };
+
 export const openWorkflow = (uuid: string) =>
     (dispatch: Dispatch, getState: () => RootState, services: ServiceRepository) => {
         dispatch<any>(navigateToWorkflows);
index c4d421ac09d9b5719a9f8d1b8f9a00833b7cf662..dbca03ab6387c069993f80c3423f2d5881ac3507 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,7 +133,7 @@ export const reRunProcess = (processUuid: string, workflowUuid: string) =>
         }
     };
 
-const getInputs = (data: any) => {
+export const getInputs = (data: any): CommandInputParameter[] => {
     if (!data || !data.mounts || !data.mounts[MOUNT_PATH_CWL_WORKFLOW]) { return []; }
     const inputs = getWorkflowInputs(data.mounts[MOUNT_PATH_CWL_WORKFLOW].content);
     return inputs ? inputs.map(
@@ -137,6 +143,43 @@ const getInputs = (data: any) => {
                 id: it.id,
                 label: it.label,
                 default: data.mounts[MOUNT_PATH_CWL_INPUT].content[it.id],
+                value: data.mounts[MOUNT_PATH_CWL_INPUT].content[it.id.split('/').pop()] || [],
+                doc: it.doc
+            }
+        )
+    ) : [];
+};
+
+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 7822bdc6b4cd2411aaa37fc78a6a626200db35cf..5d105577db9689c3f5b0da9c1f16220704485db2 100644 (file)
@@ -839,6 +839,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 4af2c9cda75392cfd45f8707206913a5d56e3d95..6f3277cbf624fb008327098e65d0c017e547e773 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)),
 });
@@ -105,12 +102,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}>
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..4b2413c
--- /dev/null
@@ -0,0 +1,488 @@
+// Copyright (C) The Arvados Authors. All rights reserved.
+//
+// SPDX-License-Identifier: AGPL-3.0
+
+import React, { ReactElement, useState } from 'react';
+import {
+    StyleRulesCallback,
+    WithStyles,
+    withStyles,
+    Card,
+    CardHeader,
+    IconButton,
+    CardContent,
+    Tooltip,
+    Typography,
+    Tabs,
+    Tab,
+    Table,
+    TableHead,
+    TableBody,
+    TableRow,
+    TableCell,
+    Paper,
+    Grid,
+    Chip,
+} from '@material-ui/core';
+import { ArvadosTheme } from 'common/custom-theme';
+import { CloseIcon, InfoIcon, ProcessIcon } from 'components/icon/icon';
+import { MPVPanelProps } from 'components/multi-panel-view/multi-panel-view';
+import {
+  BooleanCommandInputParameter,
+  CommandInputParameter,
+  CWLType,
+  Directory,
+  DirectoryArrayCommandInputParameter,
+  DirectoryCommandInputParameter,
+  EnumCommandInputParameter,
+  FileArrayCommandInputParameter,
+  FileCommandInputParameter,
+  FloatArrayCommandInputParameter,
+  FloatCommandInputParameter,
+  IntArrayCommandInputParameter,
+  IntCommandInputParameter,
+  isArrayOfType,
+  isPrimitiveOfType,
+  StringArrayCommandInputParameter,
+  StringCommandInputParameter,
+} from "models/workflow";
+import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
+import { File } from 'models/workflow';
+import { getInlineFileUrl } from 'views-components/context-menu/actions/helpers';
+import { AuthState } from 'store/auth/auth-reducer';
+import mime from 'mime';
+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';
+
+type CssRules = 'card' | 'content' | 'title' | 'header' | 'avatar' | 'iconHeader' | 'tableWrapper' | 'tableRoot' | 'paramValue' | 'keepLink' | 'imagePreview' | 'valArray' | 'emptyValue';
+
+const styles: StyleRulesCallback<CssRules> = (theme: ArvadosTheme) => ({
+    card: {
+        height: '100%'
+    },
+    header: {
+        paddingTop: theme.spacing.unit,
+        paddingBottom: theme.spacing.unit,
+    },
+    iconHeader: {
+        fontSize: '1.875rem',
+        color: theme.customs.colors.green700,
+    },
+    avatar: {
+        alignSelf: 'flex-start',
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    content: {
+        padding: theme.spacing.unit * 1.0,
+        paddingTop: theme.spacing.unit * 0.5,
+        '&:last-child': {
+            paddingBottom: theme.spacing.unit * 1,
+        }
+    },
+    title: {
+        overflow: 'hidden',
+        paddingTop: theme.spacing.unit * 0.5
+    },
+    tableWrapper: {
+        overflow: 'auto',
+    },
+    tableRoot: {
+        width: '100%',
+    },
+    paramValue: {
+        display: 'flex',
+        alignItems: 'flex-start',
+        flexDirection: 'column',
+    },
+    keepLink: {
+        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',
+        }
+    },
+    emptyValue: {
+        color: theme.customs.colors.grey500,
+    },
+});
+
+export enum ProcessIOCardType {
+    INPUT = 'Inputs',
+    OUTPUT = 'Outputs',
+}
+export interface ProcessIOCardDataProps {
+    label: ProcessIOCardType;
+    params: ProcessIOParameter[];
+    raw?: any;
+    mounts?: InputCollectionMount[];
+    outputUuid?: string;
+}
+
+type ProcessIOCardProps = ProcessIOCardDataProps & WithStyles<CssRules> & MPVPanelProps;
+
+export const ProcessIOCard = withStyles(styles)(
+    ({ classes, label, params, raw, mounts, outputUuid, doHidePanel, panelName }: ProcessIOCardProps) => {
+        const [tabState, setTabState] = useState(0);
+        const handleChange = (event: React.MouseEvent<HTMLElement>, value: number) => {
+            setTabState(value);
+        }
+
+        return <Card className={classes.card} data-cy="process-io-card">
+            <CardHeader
+                className={classes.header}
+                classes={{
+                    content: classes.title,
+                    avatar: classes.avatar,
+                }}
+                avatar={<ProcessIcon className={classes.iconHeader} />}
+                title={
+                    <Typography noWrap variant='h6' color='inherit'>
+                        {label}
+                    </Typography>
+                }
+                action={
+                    <div>
+                        { doHidePanel &&
+                        <Tooltip title={`Close ${panelName || 'panel'}`} disableFocusListener>
+                            <IconButton onClick={doHidePanel}><CloseIcon /></IconButton>
+                        </Tooltip> }
+                    </div>
+                } />
+            <CardContent className={classes.content}>
+                <div>
+                    <Tabs value={tabState} onChange={handleChange} variant="fullWidth">
+                        <Tab label="Preview" />
+                        <Tab label="Raw" />
+                        {label === ProcessIOCardType.INPUT && <Tab label="Input Mounts" />}
+                        {label === ProcessIOCardType.OUTPUT && <Tab label="Output Collection" />}
+                    </Tabs>
+                    {tabState === 0 && <div className={classes.tableWrapper}>
+                        {params.length ?
+                            <ProcessIOPreview data={params} /> :
+                            <Grid container item alignItems='center' justify='center'>
+                                <DefaultView messages={["No parameters found"]} icon={InfoIcon} />
+                            </Grid>}
+                        </div>}
+                    {tabState === 1 && <div className={classes.tableWrapper}>
+                        {params.length ?
+                            <ProcessIORaw data={raw || params} /> :
+                            <Grid container item alignItems='center' justify='center'>
+                                <DefaultView messages={["No parameters found"]} icon={InfoIcon} />
+                            </Grid>}
+                        </div>}
+                    {tabState === 2 && <div className={classes.tableWrapper}>
+                        {label === ProcessIOCardType.INPUT && <ProcessInputMounts mounts={mounts || []} />}
+                        {label === ProcessIOCardType.OUTPUT && <ProcessOutputCollectionFiles isWritable={false} currentItemUuid={outputUuid} />}
+                        </div>}
+                </div>
+            </CardContent>
+        </Card>;
+    }
+);
+
+export type ProcessIOValue = {
+    display: ReactElement<any, any>;
+    imageUrl?: string;
+}
+
+export type ProcessIOParameter = {
+    id: string;
+    doc: string;
+    value: ProcessIOValue[];
+}
+
+interface ProcessIOPreviewDataProps {
+    data: ProcessIOParameter[];
+}
+
+type ProcessIOPreviewProps = ProcessIOPreviewDataProps & WithStyles<CssRules>;
+
+const ProcessIOPreview = withStyles(styles)(
+    ({ classes, data }: ProcessIOPreviewProps) =>
+        <Table className={classes.tableRoot} aria-label="Process IO Preview">
+            <TableHead>
+                <TableRow>
+                    <TableCell>Label</TableCell>
+                    <TableCell>Description</TableCell>
+                    <TableCell>Value</TableCell>
+                </TableRow>
+            </TableHead>
+            <TableBody>
+                {data.map((param: ProcessIOParameter) => {
+                    return <TableRow key={param.id}>
+                        <TableCell component="th" scope="row">
+                            {param.id}
+                        </TableCell>
+                        <TableCell>{param.doc}</TableCell>
+                        <TableCell>{param.value.map(val => (
+                            <Typography className={classes.paramValue}>
+                                {val.imageUrl ? <img className={classes.imagePreview} src={val.imageUrl} alt="Inline Preview" /> : ""}
+                                <span className={classes.valArray}>
+                                    {val.display}
+                                </span>
+                            </Typography>
+                        ))}</TableCell>
+                    </TableRow>;
+                })}
+            </TableBody>
+        </Table>
+);
+
+const handleClick = (url) => {
+    window.open(url, '_blank');
+}
+
+const ProcessIORaw = withStyles(styles)(
+    ({ data }: ProcessIOPreviewProps) =>
+        <Paper elevation={0}>
+            <pre>
+                {JSON.stringify(data, null, 2)}
+            </pre>
+        </Paper>
+);
+
+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
+            ];
+
+            return files.length ?
+                files.map(file => fileToProcessIOValue(file, auth, pdh)) :
+                [{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 fileArrayMainFile = ((input as FileArrayCommandInputParameter).value || []);
+            const fileArraySecondaryFiles = fileArrayMainFile.map((file) => (
+                ((file as unknown) as FileWithSecondaryFiles)?.secondaryFiles || []
+            )).reduce((acc: File[], params: File[]) => (acc.concat(params)), []);
+
+            const fileArrayFiles = [
+                ...fileArrayMainFile,
+                ...fileArraySecondaryFiles
+            ];
+
+            return fileArrayFiles.length ?
+                fileArrayFiles.map(file => fileToProcessIOValue(file, auth, pdh)) :
+                [{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 [];
+    }
+};
+
+const getKeepUrl = (file: File | Directory, pdh?: string): string => {
+    const isKeepUrl = file.location?.startsWith('keep:') || false;
+    const keepUrl = isKeepUrl ? file.location : pdh ? `keep:${pdh}/${file.location}` : file.location;
+    return keepUrl || '';
+};
+
+interface KeepUrlProps {
+    auth: AuthState;
+    res: File | Directory;
+    pdh?: string;
+}
+
+const KeepUrlBase = withStyles(styles)(({auth, res, pdh, classes}: KeepUrlProps & WithStyles<CssRules>) => {
+    const keepUrl = getKeepUrl(res, pdh);
+    const pdhUrl = keepUrl ? keepUrl.split('/').slice(0, 1)[0] : '';
+    // Passing a pdh always returns a relative wb2 collection url
+    const pdhWbPath = getNavUrl(pdhUrl.replace('keep:', ''), auth);
+    return pdhUrl && pdhWbPath ?
+        <RouterLink to={pdhWbPath} className={classes.keepLink}>{pdhUrl}</RouterLink> :
+        <></>;
+});
+
+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 keepUrlPath && keepUrlPathNav ?
+        <MuiLink className={classes.keepLink} onClick={() => handleClick(keepUrlPathNav)}>{keepUrlPath}</MuiLink> :
+        <></>;
+});
+
+const getKeepNavUrl = (auth: AuthState, file: File | Directory, pdh?: string): string => {
+    let keepUrl = getKeepUrl(file, pdh).replace('keep:', '');
+    return (getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl));
+};
+
+const getImageUrl = (auth: AuthState, file: File, pdh?: string): string => {
+    const keepUrl = getKeepUrl(file, pdh).replace('keep:', '');
+    return getInlineFileUrl(`${auth.config.keepWebServiceUrl}/c=${keepUrl}?api_token=${auth.apiToken}`, auth.config.keepWebServiceUrl, auth.config.keepWebInlineServiceUrl);
+};
+
+const isFileImage = (basename?: string): boolean => {
+    return basename ? (mime.getType(basename) || "").startsWith('image/') : false;
+};
+
+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: <span>
+            <KeepUrlBase auth={auth} res={normalizedDirectory} pdh={pdh}/>/<KeepUrlPath auth={auth} res={normalizedDirectory} pdh={pdh}/>
+        </span>,
+    };
+};
+
+const fileToProcessIOValue = (file: File, auth: AuthState, pdh?: string): ProcessIOValue => {
+    return {
+        display: <span>
+            <KeepUrlBase auth={auth} res={file} pdh={pdh}/>/<KeepUrlPath auth={auth} res={file} pdh={pdh}/>
+        </span>,
+        imageUrl: isFileImage(file.basename) ? getImageUrl(auth, file, pdh) : undefined,
+    }
+};
+
+const EmptyValue = withStyles(styles)(
+    ({classes}: WithStyles<CssRules>) => <span className={classes.emptyValue}>No value</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..1cf6030015426d87a9742790db6aa40bc30913bc 100644 (file)
@@ -2,7 +2,7 @@
 //
 // SPDX-License-Identifier: AGPL-3.0
 
-import React from 'react';
+import React, { useState } from 'react';
 import { Grid, StyleRulesCallback, WithStyles, withStyles } from '@material-ui/core';
 import { DefaultView } from 'components/default-view/default-view';
 import { ProcessIcon } from 'components/icon/icon';
@@ -12,9 +12,15 @@ import { SubprocessFilterDataProps } from 'components/subprocess-filter/subproce
 import { MPVContainer, MPVPanelContent, MPVPanelState } from 'components/multi-panel-view/multi-panel-view';
 import { ArvadosTheme } from 'common/custom-theme';
 import { ProcessDetailsCard } from './process-details-card';
+import { getIOParamDisplayValue, 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 { getInputs, getInputCollectionMounts, getOutputParameters } from 'store/processes/processes-actions';
+import { CommandInputParameter, getIOParamId } from 'models/workflow';
+import { CommandOutputParameter } from 'cwlts/mappings/v1.0/CommandOutputParameter';
+import { AuthState } from 'store/auth/auth-reducer';
 import { ProcessCmdCard } from './process-cmd-card';
 
 type CssRules = 'root';
@@ -30,6 +36,7 @@ export interface ProcessPanelRootDataProps {
     subprocesses: Array<Process>;
     filters: Array<SubprocessFilterDataProps>;
     processLogsPanel: ProcessLogsPanel;
+    auth: AuthState;
 }
 
 export interface ProcessPanelRootActionProps {
@@ -39,20 +46,64 @@ export interface ProcessPanelRootActionProps {
     onLogFilterChange: (filter: FilterOption) => void;
     navigateToLog: (uuid: string) => void;
     onCopyToClipboard: (uuid: string) => void;
+    fetchOutputs: (uuid: string, fetchOutputs) => void;
 }
 
 export type ProcessPanelRootProps = ProcessPanelRootDataProps & ProcessPanelRootActionProps & WithStyles<CssRules>;
 
+type OutputDetails = {
+    rawOutputs?: any;
+    pdh?: string;
+}
+
 const panelsData: MPVPanelState[] = [
     {name: "Details"},
     {name: "Command"},
     {name: "Logs", visible: true},
+    {name: "Inputs"},
+    {name: "Outputs"},
     {name: "Subprocesses"},
 ];
 
 export const ProcessPanelRoot = withStyles(styles)(
-    ({ process, processLogsPanel, ...props }: ProcessPanelRootProps) =>
-    process
+    ({ process, auth, processLogsPanel, fetchOutputs, ...props }: ProcessPanelRootProps) => {
+
+    const [outputDetails, setOutputs] = useState<OutputDetails>({});
+    const [rawInputs, setInputs] = useState<CommandInputParameter[]>([]);
+
+
+    const [processedOutputs, setProcessedOutputs] = useState<ProcessIOParameter[]>([]);
+    const [processedInputs, setProcessedInputs] = useState<ProcessIOParameter[]>([]);
+
+    const outputUuid = process?.containerRequest.outputUuid;
+    const requestUuid = process?.containerRequest.uuid;
+
+    const inputMounts = getInputCollectionMounts(process?.containerRequest);
+
+    React.useEffect(() => {
+        if (outputUuid) {
+            fetchOutputs(outputUuid, setOutputs);
+        }
+    }, [outputUuid, fetchOutputs]);
+
+    React.useEffect(() => {
+        if (outputDetails.rawOutputs && process) {
+            const outputDefinitions = getOutputParameters(process.containerRequest);
+            setProcessedOutputs(formatOutputData(outputDefinitions, outputDetails.rawOutputs, outputDetails.pdh, auth));
+        } else {
+            setProcessedOutputs([]);
+        }
+    }, [outputDetails, auth, process]);
+
+    React.useEffect(() => {
+        if (process) {
+            const rawInputs = getInputs(process.containerRequest);
+            setInputs(rawInputs);
+            setProcessedInputs(formatInputData(rawInputs, auth));
+        }
+    }, [requestUuid, auth, process]);
+
+    return process
         ? <MPVContainer className={props.classes.root} spacing={8} panelStates={panelsData}  justify-content="flex-start" direction="column" wrap="nowrap">
             <MPVPanelContent forwardProps xs="auto" data-cy="process-details">
                 <ProcessDetailsCard
@@ -82,6 +133,22 @@ export const ProcessPanelRoot = withStyles(styles)(
                     navigateToLog={props.navigateToLog}
                 />
             </MPVPanelContent>
+            <MPVPanelContent forwardProps xs="auto" data-cy="process-inputs">
+                <ProcessIOCard
+                    label={ProcessIOCardType.INPUT}
+                    params={processedInputs}
+                    raw={rawInputs}
+                    mounts={inputMounts}
+                 />
+            </MPVPanelContent>
+            <MPVPanelContent forwardProps xs="auto" data-cy="process-outputs">
+                <ProcessIOCard
+                    label={ProcessIOCardType.OUTPUT}
+                    params={processedOutputs}
+                    raw={outputDetails.rawOutputs}
+                    outputUuid={outputUuid || ""}
+                 />
+            </MPVPanelContent>
             <MPVPanelContent forwardProps xs maxHeight='50%' data-cy="process-children">
                 <SubprocessPanel />
             </MPVPanelContent>
@@ -93,4 +160,28 @@ export const ProcessPanelRoot = withStyles(styles)(
             <DefaultView
                 icon={ProcessIcon}
                 messages={['Process not found']} />
-        </Grid>);
+        </Grid>;
+    }
+);
+
+const formatInputData = (inputs: CommandInputParameter[], auth: AuthState): ProcessIOParameter[] => {
+    return inputs.map(input => {
+        const doc = Array.isArray(input.doc) ? input.doc.join(', ') : input.doc;
+        return {
+            id: getIOParamId(input),
+            doc: input.label || doc || "",
+            value: getIOParamDisplayValue(auth, input)
+        };
+    });
+};
+
+const formatOutputData = (definitions: CommandOutputParameter[], values: any, pdh: string | undefined, auth: AuthState): ProcessIOParameter[] => {
+    return definitions.map(output => {
+        const doc = Array.isArray(output.doc) ? output.doc.join(', ') : output.doc;
+        return {
+            id: getIOParamId(output),
+            doc: output.label || doc || "",
+            value: getIOParamDisplayValue(auth, Object.assign(output, { value: values[getIOParamId(output)] || [] }), pdh)
+        };
+    });
+};
index 7afaa04d94b95f90e47181f2a449985c0879fd62..8adec3bd9f78cdce8558f5759f56ad54b37a46ee 100644 (file)
@@ -18,13 +18,14 @@ import {
 } from 'store/process-panel/process-panel';
 import { groupBy } from 'lodash';
 import {
+    loadOutputs,
     toggleProcessPanelFilter,
 } from 'store/process-panel/process-panel-actions';
 import { cancelRunningWorkflow } from 'store/processes/processes-actions';
 import { navigateToLogCollection, setProcessLogsPanelFilter } from 'store/process-logs-panel/process-logs-panel-actions';
 import { snackbarActions, SnackbarKind } from 'store/snackbar/snackbar-actions';
 
-const mapStateToProps = ({ router, resources, processPanel, processLogsPanel }: RootState): ProcessPanelRootDataProps => {
+const mapStateToProps = ({ router, auth, resources, processPanel, processLogsPanel }: RootState): ProcessPanelRootDataProps => {
     const uuid = getProcessPanelCurrentUuid(router) || '';
     const subprocesses = getSubprocesses(uuid)(resources);
     return {
@@ -32,6 +33,7 @@ const mapStateToProps = ({ router, resources, processPanel, processLogsPanel }:
         subprocesses: subprocesses.filter(subprocess => processPanel.filters[getProcessStatus(subprocess)]),
         filters: getFilters(processPanel, subprocesses),
         processLogsPanel: processLogsPanel,
+        auth: auth,
     };
 };
 
@@ -52,6 +54,7 @@ const mapDispatchToProps = (dispatch: Dispatch): ProcessPanelRootActionProps =>
     cancelProcess: (uuid) => dispatch<any>(cancelRunningWorkflow(uuid)),
     onLogFilterChange: (filter) => dispatch(setProcessLogsPanelFilter(filter.value)),
     navigateToLog: (uuid) => dispatch<any>(navigateToLogCollection(uuid)),
+    fetchOutputs: (uuid, setOutputs) => dispatch<any>(loadOutputs(uuid, setOutputs)),
 });
 
 const getFilters = (processPanel: ProcessPanelState, processes: Process[]) => {
index 3b2ecd8d8fe8f93aabbddd3392917c5f6906d9b3..e745379702268fe3a99ebbfd00e355281f60f6ee 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 6dfb5b18b8aae518b209c72c9888a5fbb65d00cc..64f25cc2e921d34644d2fad2d8b9a3a3cb10ec92 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
@@ -11998,6 +11999,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"